Preserve JSDoc on the global function wrappers gas-expose.js generates - #93
Conversation
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>
There was a problem hiding this comment.
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.
| 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(); | ||
| }); | ||
|
|
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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>
Summary
`generateBundle` in `vite-plugins/gas-expose.js` appends bare pass-through wrappers for each exported function:
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
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