diff --git a/vite-plugins/gas-expose.js b/vite-plugins/gas-expose.js index b03ad64..ca576d0 100644 --- a/vite-plugins/gas-expose.js +++ b/vite-plugins/gas-expose.js @@ -5,13 +5,59 @@ * 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 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 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('*/')) { + // 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); + } + } + return null; + } + 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 +65,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..6957f56 --- /dev/null +++ b/vite-plugins/gas-expose.test.js @@ -0,0 +1,84 @@ +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(); + }); + + 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(); + }); +});