Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion vite-plugins/gas-expose.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,64 @@
* 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}`;
}
},
});

export default viteExposeGasFunctions;
export { extractJsDoc };
84 changes: 84 additions & 0 deletions vite-plugins/gas-expose.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});