From d438452f88d6106c459adb32b74b28dfde4264e2 Mon Sep 17 00:00:00 2001 From: Andrew Howard Date: Fri, 10 Jul 2026 19:34:41 -0400 Subject: [PATCH 1/2] Preserve JSDoc on the global function wrappers gas-expose.js generates generateBundle() appends bare pass-through wrappers for each exported function: function ${fnName}(...args) { return ${options.name}.${fnName}(...args); } These are the *only* top-level global functions Apps Script actually sees in the deployed bundle. Any JSDoc comment on the original source function stays attached to a differently-scoped inner function inside the IIFE - it never reaches the generated global. Practical effect: Apps Script's "Open in new tab" library documentation view (and editor autocomplete for consumers of a project published as a Library) reads JSDoc directly from the actual global function declaration. Since the generated wrappers never carried any, that view was permanently empty for every project built on this template that's published as a Library, regardless of how well the source itself is documented. Fix: locate each function's original JSDoc in the bundled code and copy it onto the generated wrapper. One gotcha along the way: a naive "find the first occurrence of `function fnName(`" search can match text *inside* an unrelated JSDoc comment (e.g. example code shown in some other function's own doc comment), so the search has to skip matches that fall inside a still-open comment block to find the real declaration - covered directly by a regression test. Co-Authored-By: Claude Sonnet 5 --- vite-plugins/gas-expose.js | 40 +++++++++++++++++++++- vite-plugins/gas-expose.test.js | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 vite-plugins/gas-expose.test.js diff --git a/vite-plugins/gas-expose.js b/vite-plugins/gas-expose.js index b03ad64..4cfe51d 100644 --- a/vite-plugins/gas-expose.js +++ b/vite-plugins/gas-expose.js @@ -5,13 +5,50 @@ * functions like onOpen(e) or other custom functions directly. * @returns {import('vite').Plugin} */ + +/** + * Finds the JSDoc comment (if any) immediately preceding a function's + * declaration in the bundled code, so it can be copied onto the + * generated global wrapper below. Apps Script's "Open in new tab" + * library documentation view (and editor autocomplete) only reads + * comments directly above an actual top-level global function - the + * real JSDoc otherwise stays buried inside the IIFE, attached to a + * differently-scoped inner function of the same name, and never + * surfaces there at all. + */ +function extractJsDoc(code, fnName) { + const pattern = new RegExp(`\\bfunction\\s+${fnName}\\s*\\(`, 'g'); + let match = pattern.exec(code); + while (match) { + const before = code.slice(0, match.index); + // A doc comment's own example code can itself contain text like + // "function onOpen() {...}" - skip any match that falls inside an + // still-open comment block rather than at a real declaration. + const insideComment = before.lastIndexOf('/**') > before.lastIndexOf('*/'); + if (!insideComment) { + const trimmedBefore = before.replace(/\s+$/, ''); + if (!trimmedBefore.endsWith('*/')) { + return null; + } + const commentStart = trimmedBefore.lastIndexOf('/**'); + return commentStart === -1 ? null : trimmedBefore.slice(commentStart); + } + match = pattern.exec(code); + } + return null; +} + const viteExposeGasFunctions = () => ({ name: 'vite-expose-gas-functions', generateBundle(options, bundle) { const entryChunk = Object.values(bundle).find((chunk) => chunk.type === 'chunk' && chunk.isEntry); if (entryChunk?.exports?.length > 0) { const exposureCode = entryChunk.exports - .map((fnName) => `function ${fnName}(...args) { return ${options.name}.${fnName}(...args); }`) + .map((fnName) => { + const wrapper = `function ${fnName}(...args) { return ${options.name}.${fnName}(...args); }`; + const jsdoc = extractJsDoc(entryChunk.code, fnName); + return jsdoc ? `${jsdoc.replace(/^\t+/gm, '')}\n${wrapper}` : wrapper; + }) .join('\n'); entryChunk.code += `\n\n${exposureCode}`; } @@ -19,3 +56,4 @@ const viteExposeGasFunctions = () => ({ }); export default viteExposeGasFunctions; +export { extractJsDoc }; diff --git a/vite-plugins/gas-expose.test.js b/vite-plugins/gas-expose.test.js new file mode 100644 index 0000000..16d68a0 --- /dev/null +++ b/vite-plugins/gas-expose.test.js @@ -0,0 +1,59 @@ +import { extractJsDoc } from './gas-expose.js'; + +describe('extractJsDoc', () => { + it('finds a JSDoc comment immediately preceding the function', () => { + const code = ` +/** + * Does a thing. + */ +function doThing() {} +`; + + expect(extractJsDoc(code, 'doThing')).toBe('/**\n * Does a thing.\n */'); + }); + + it('returns null when the function has no preceding comment', () => { + const code = ` +function doThing() {} +`; + + expect(extractJsDoc(code, 'doThing')).toBeNull(); + }); + + it('returns null when the function exists but only unrelated code precedes it', () => { + const code = ` +const x = 1; +function doThing() {} +`; + + expect(extractJsDoc(code, 'doThing')).toBeNull(); + }); + + it("skips a match inside another function's doc comment example code and finds the real declaration", () => { + // Regression test: a JSDoc block that itself contains example code + // like "function onOpen() {...}" used to make extractJsDoc match + // that embedded text instead of the real declaration below it. + const code = ` +/** + * Setup instructions: + * function onOpen() { Foo.onOpen(); } + * function doThing() { Foo.doThing(); } + */ +function setup() {} + +/** + * The real doc comment for doThing. + */ +function doThing() {} +`; + + expect(extractJsDoc(code, 'doThing')).toBe('/**\n * The real doc comment for doThing.\n */'); + expect(extractJsDoc(code, 'onOpen')).toBeNull(); + }); + + it('returns null for a function whose name does not appear at all', () => { + const code = `function somethingElse() {}`; + + expect(extractJsDoc(code, 'doThing')).toBeNull(); + }); +}); From a67421a07cb9770f012c5ada87a452647f44f6f9 Mon Sep 17 00:00:00 2001 From: Andrew Howard Date: Fri, 10 Jul 2026 19:42:31 -0400 Subject: [PATCH 2/2] Address Copilot review: escape regex metachars, fix comment-boundary bug Two real issues from the automated review, both confirmed with a failing-then-passing test before/after: 1. extractJsDoc built a RegExp directly from fnName with no escaping. A function literally named with a regex metacharacter (e.g. $, a valid character in a JS identifier) would silently fail to match - verified: `new RegExp('\\bfunction\\s+$helper\\s*\\(')` never matches "function $helper(" at all, since unescaped $ is the end-of-input anchor. Now escaped. 2. The "is this the function's own JSDoc" check used lastIndexOf('/**'), which can walk straight past an intervening plain (non-JSDoc) comment - e.g. a bundler-inserted "/* @__PURE__ */" immediately before the function - and instead grab an entirely unrelated, earlier JSDoc meant for a different function. Fixed by finding the nearest preceding "/*" (comments can't nest, so that's necessarily this comment's real opener) and only treating it as documentation if that specific comment starts with "/**". Co-Authored-By: Claude Sonnet 5 --- vite-plugins/gas-expose.js | 25 +++++++++++++++++-------- vite-plugins/gas-expose.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/vite-plugins/gas-expose.js b/vite-plugins/gas-expose.js index 4cfe51d..ca576d0 100644 --- a/vite-plugins/gas-expose.js +++ b/vite-plugins/gas-expose.js @@ -17,21 +17,30 @@ * surfaces there at all. */ function extractJsDoc(code, fnName) { - const pattern = new RegExp(`\\bfunction\\s+${fnName}\\s*\\(`, 'g'); + const escapedName = fnName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`\\bfunction\\s+${escapedName}\\s*\\(`, 'g'); let match = pattern.exec(code); while (match) { const before = code.slice(0, match.index); // A doc comment's own example code can itself contain text like - // "function onOpen() {...}" - skip any match that falls inside an - // still-open comment block rather than at a real declaration. - const insideComment = before.lastIndexOf('/**') > before.lastIndexOf('*/'); + // "function onOpen() {...}" - skip any match that falls inside a + // still-open comment block (JSDoc or not) rather than at a real + // declaration. + const insideComment = before.lastIndexOf('/*') > before.lastIndexOf('*/'); if (!insideComment) { const trimmedBefore = before.replace(/\s+$/, ''); - if (!trimmedBefore.endsWith('*/')) { - return null; + if (trimmedBefore.endsWith('*/')) { + // Block comments can't nest, so the nearest preceding "/*" is + // necessarily this comment's own opener - not just any + // earlier "/**", which could belong to a different JSDoc + // separated from this function by an intervening plain + // (non-JSDoc) comment, e.g. a bundler-inserted "/* @__PURE__ */". + const commentStart = trimmedBefore.lastIndexOf('/*'); + if (commentStart !== -1 && trimmedBefore.startsWith('/**', commentStart)) { + return trimmedBefore.slice(commentStart); + } } - const commentStart = trimmedBefore.lastIndexOf('/**'); - return commentStart === -1 ? null : trimmedBefore.slice(commentStart); + return null; } match = pattern.exec(code); } diff --git a/vite-plugins/gas-expose.test.js b/vite-plugins/gas-expose.test.js index 16d68a0..6957f56 100644 --- a/vite-plugins/gas-expose.test.js +++ b/vite-plugins/gas-expose.test.js @@ -56,4 +56,29 @@ function doThing() {} expect(extractJsDoc(code, 'doThing')).toBeNull(); }); + + it('matches function names containing regex metacharacters like $', () => { + const code = ` +/** + * A dollar-prefixed helper. + */ +function $helper() {} +`; + + expect(extractJsDoc(code, '$helper')).toBe('/**\n * A dollar-prefixed helper.\n */'); + }); + + it('returns null when the immediately preceding comment is not a JSDoc, even if an earlier unrelated JSDoc exists', () => { + const code = ` +/** + * JSDoc for a completely different function. + */ +function otherFunction() {} + +/* @__PURE__ */ +function doThing() {} +`; + + expect(extractJsDoc(code, 'doThing')).toBeNull(); + }); });