Skip to content

Preserve JSDoc on the global function wrappers gas-expose.js generates - #93

Open
yorkshirelandscape wants to merge 2 commits into
labnol:masterfrom
yorkshirelandscape:fix/library-jsdoc-not-exposed-to-global-wrappers
Open

Preserve JSDoc on the global function wrappers gas-expose.js generates#93
yorkshirelandscape wants to merge 2 commits into
labnol:masterfrom
yorkshirelandscape:fix/library-jsdoc-not-exposed-to-global-wrappers

Conversation

@yorkshirelandscape

Copy link
Copy Markdown

Summary

`generateBundle` in `vite-plugins/gas-expose.js` 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 written on the original source function stays attached to a differently-scoped inner function inside the IIFE - it never reaches the generated global.

The 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 is 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, which is exactly the kind of setup-instructions comment a library's entry point tends to have). The search needs to skip matches that fall inside a still-open comment block to find the real declaration - covered directly by a regression test in `gas-expose.test.js`.

Test plan

  • Added `vite-plugins/gas-expose.test.js` covering: normal case, no-comment case, unrelated-preceding-code case, and the self-referential-example-code regression case
  • `npm test` passes (existing `http.test.js` + 5 new tests)
  • `npm run build` still produces a working bundle

Found while building a project on top of this template that's distributed as an Apps Script Library.

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 10, 2026 23:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the vite-plugins/gas-expose.js Vite plugin so that when it generates top-level Google Apps Script global wrapper functions, it also preserves and attaches the original source JSDoc to those wrappers—enabling Apps Script Library docs/autocomplete to surface documentation correctly.

Changes:

  • Added extractJsDoc(code, fnName) to locate a function’s preceding JSDoc in the bundled output.
  • Updated wrapper generation to prepend extracted JSDoc (when present) onto each generated global wrapper.
  • Added Jest tests covering standard extraction behavior and a regression case involving example code inside another JSDoc block.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
vite-plugins/gas-expose.js Adds JSDoc extraction and prepends it to generated global wrappers; exports helper for testing.
vite-plugins/gas-expose.test.js Introduces unit tests validating extractJsDoc behavior and regression scenarios.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +31
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();
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a67421a — this ended up being a real bug (verified with a failing test before the fix): a plain /* @__PURE__ */-style comment sitting between an earlier JSDoc and the target function would cause lastIndexOf('/**') to grab the wrong, unrelated comment. Now finds the nearest preceding /* (comments can't nest) and only treats it as documentation if that specific comment starts with /**. Added a regression test covering exactly this case.

Comment on lines +19 to +39
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;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixed in a67421a. The regex-escaping issue was real too — confirmed a function literally named $helper (valid JS) never matched at all, since an unescaped $ mid-pattern is the end-of-input anchor and can't be followed by more literal chars. Now escapes fnName before building the RegExp, and the comment-boundary fix is described in the other thread. Added regression tests for both.

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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants