API references from source comments

A comment in a directory listed in js_source_paths has more than one reader, and they do not agree:

  • Descriptions render as reStructuredText. The [text](/path/index.md) form used elsewhere comes out literally, with only the bare URL autolinked. Write the link as a role instead:

    :doc:`Places </browser/places/index>`
    

    A Markdown code span such as `forceForeground` renders as code, because docs/jsdoc-plugins/convert-descriptions-to-rst.mjs doubles its backticks. |name| is a substitution reference, and an undefined one fails the build rather than warning.

  • A @param description renders as one paragraph. sphinx-js replaces every newline in one with a space, so a list or a heading written inside a parameter’s description arrives glued to the parameter above it. Structure belongs in the function’s own description, which keeps it.

  • Write TypeScript types as TypeScript wants them. docs/jsdoc-plugins/accept-typescript-types.mjs passes predicates, generics, indexed access, tuples, arrow and import() types through to the page verbatim, though not as links.

  • jsdoc keeps prose written after an @type tag, on the same line or below it, but logs a warning claiming it will ignore it. Put the prose above the tags to keep the build log quiet.

  • Give @typedef a type – @typedef {object} Name – or jsdoc takes the first { in the prose under it as the type and logs a build ERROR.

  • A documented default value renders, so [options.animate=true] is a claim about the code. Write one only where the signature supplies that default, and describe a computed default in the prose instead, where it can be kept accurate.

  • A destructured parameter’s documented name is printed in front of every property under it, so the name a comment invents for an options bag is part of the rendered API rather than a local choice. Call it options, whether the parameter is destructured or not, unless the function takes two bags or passes one through to another call; there, name it for the call it belongs to, such as loadOptions.

  • @throws renders as a throws field carrying its type. On a class, though, js:autoclass takes parameters, return values and exceptions from the constructor only, so those tags render nowhere from the class’s own comment. A method that checks its own contract at runtime documents the check in @throws rather than in a parameter’s description.

Verify by reading the rendered HTML

A passing build proves little about a generated page. sphinx-js never reads jsdoc’s exit status, so a type jsdoc could not parse costs nothing visible, and make_sphinx_js_skip_missing_objects in docs/conf.py turns a directive that names nothing into a warning. Open the page under <objdir>/docs/html/ and count its member anchors (id="<Class>.<member>") against the source, then spot-check a signature. An unresolvable @see is the exception: it fails the build with 'any' reference target not found.

jsdoc_cache is unset, so a page that disagrees with the source is never a stale cache.

The build log already carries many Invalid type expression lines from across js_source_paths. Search it for your own file rather than counting.

Types jsdoc cannot parse

jsdoc parses types with catharsis, which rejects some spellings that accept-typescript-types.mjs does not pass through. Introduce a named @typedef and use that.

  • ?T parses and loses its nullability. jsdoc records {?Console} as Console alone, where {Console|null} keeps both names, and sphinx-js renders only those names. tsc and eslint treat the two spellings the same, so only the rendered page shows the difference. Write {T|null} in any file with a generated page.

  • An unparseable type on a member that does not render, such as an excluded _-prefixed member, costs only a log line, so the stronger annotation can stay there.

Members that render wrongly or not at all

  • jsdoc cannot name #-private members. docs/jsdoc-plugins/skip-private-members.mjs keeps the shapes it knows off the page. A shape it misses aborts the whole build, either with More than one object matches the path suffix (the doclet takes the enclosing class’s name) or with a parsimonious ParseError on an empty name that mentions no file. Widen the plugin rather than the comment.

  • An inline cast on a member expression yields a doclet named for the expression, so /** @type {X} */ (aTab.documentGlobal) written twice in one file collides with itself. docs/jsdoc-plugins/skip-type-casts.mjs keeps such doclets off the page.

  • A @typedef gets no entry of its own, and a record type renders as Object. js:autoclass renders members only, so a typedef’s name shows as plain text and its @property rows appear nowhere. Name the keys in the parameter’s description when a reader of the page needs them.

  • A subclass page shows only the subclass’s own members. sphinx-js does not pull inherited members in, so a thin subclass of a documented base renders an empty class. Document the base under its own name, with a redirects entry in docs/config.yml from the subclass’s old page, as UrlbarInput does.

  • _-prefixed members, and an empty constructor entry, are kept off the page with :exclude-members:.

Shapes that need a different directive

  • export const Foo = new _Foo(); renders as class _Foo() with _Foo.method anchors. @alias Foo on the class’s doc comment gives the page the name callers use. A plain export class Foo needs no tag.

  • export const Foo = { … } is not a class: an undocumented object literal yields nothing for js:autoclass to find, and documenting it gives a heading that reads as a class with an empty constructor. Write one js:autofunction per member instead, since there is no automodule, or use a prose page with an API reference section. The anchors are Foo.member either way.

  • An exported module function has global scope, so js:autofunction takes foo or <FileBasename>.foo. The bare name aborts the build with More than one object matches the path suffix "foo" as soon as anything in js_source_paths has a member of that name. Qualify every directive on the page with the file’s basename.

Adding a file to js_source_paths

  • Measure the lint work first. The comment above js_source_paths asks that a listed path stay out of the valid-jsdoc and require-jsdoc sections of eslint-rollouts.config.mjs. A path leaves a section by being named in that block’s ignores. Add both entries and run ./mach lint -l eslint -f json <file>; the count follows the comments the file already has, mostly @param types they lack, and is often the bulk of the work.

  • List a single file rather than a directory that also holds window scripts or tests, whose bare globals and duplicate local names trip https://github.com/pyodide/sphinx-js/issues/242.

  • Head the generated page with the class’s name, such as ## Tabbrowser. The directive adds no heading, and without one the class has nothing to nest under in the menu. The menu follows document order, so a {toctree} placed below an in-page ## section lands inside that section.

Probing jsdoc directly

Print every doclet jsdoc produces for a file, which answers whether sphinx-js sees a member at all, how jsdoc recorded its type, and whether it has a name:

cd docs
../mach node ../node_modules/.bin/jsdoc -c jsdoc.json -X <path-from-docs>

node_modules is there once ./mach doc has run.