Source code

Revision control

Copy as Markdown

Other Tools

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// Firefox JSDoc comments are type-checked by TypeScript (`./mach ts check`),
// so they use TypeScript type syntax. catharsis, the Closure-only parser jsdoc
// hands a type expression to, rejects it -- and jsdoc then drops the whole tag,
// description and all, so what it documented vanishes from the rendered page.
// Anything is a valid Closure type name once quoted, so quote the expressions
// catharsis rejects before it sees them and unquote them again on the doclet.
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// catharsis is jsdoc's dependency rather than ours, so resolve it through
// jsdoc: the grammar an expression is tested against here is then the one
// jsdoc will parse it with, whatever the layout of node_modules.
const catharsis = require(
require.resolve("catharsis", {
paths: [require.resolve("jsdoc/package.json")],
})
);
const MARKER = "$$TS$$";
const WRAPPED_RE = /^"\$\$TS\$\$([\s\S]*)"$/;
const DOC_COMMENT_RE = /\/\*\*[\s\S]*?\*\//g;
// The start of a tag's braced type expression. Only the tags below are
// rewritten: jsdoc keeps the braced text of a tag such as `@this`, and of one
// it does not know at all, verbatim in the doclet, where the quoting would
// show up in the rendered page rather than being undone by `unwrap`.
const TYPE_TAG_RE =
/@(?:param|arg|argument|property|prop|returns?|yields?|throws|exception|type|typedef|member|var|const|constant|enum)[ \t]*\{/g;
const TYPED_PROPERTIES = [
"params",
"properties",
"returns",
"yields",
"exceptions",
];
function isParseable(expression) {
try {
catharsis.parse(expression, { jsdoc: true, useCache: false });
return true;
} catch (e) {
return false;
}
}
// The index of the brace closing the one at `start`, or -1 if it never closes.
function closingBrace(comment, start) {
let depth = 0;
for (let i = start; i < comment.length; i++) {
depth += (comment[i] === "{") - (comment[i] === "}");
if (!depth) {
return i;
}
}
return -1;
}
function rewriteComment(comment) {
let prefix = /\n([ \t]*\*)/.exec(comment)?.[1] ?? " *";
let rewritten = "";
let index = 0;
for (let match of comment.matchAll(TYPE_TAG_RE)) {
let start = match.index + match[0].length - 1;
let end = closingBrace(comment, start);
// A match inside an expression already rewritten is part of that
// expression, not a tag of its own.
if (start < index || end < 0) {
continue;
}
let raw = comment.slice(start + 1, end);
// jsdoc strips the `*` that opens each line of a comment before it parses
// the tags, so an expression has to be read the same way to be recognized.
let expression = raw
.replace(/\n[ \t]*\*/g, "\n")
.replace(/\s+/g, " ")
.replace(/ (?=\.)/g, "")
.trim();
// An inline tag such as `{@link Foo}` is not a type expression.
if (!expression || expression.startsWith("@") || isParseable(expression)) {
continue;
}
// Replacing a multi-line expression with a single line would renumber the
// rest of the file, so pad the replacement back out to its line count.
let quoted = expression.replace(/[\\"]/g, "\\$&");
let padding = `\n${prefix}`.repeat(raw.split("\n").length - 1);
rewritten += `${comment.slice(index, start)}{"${MARKER}${quoted}"}${padding}`;
index = end + 1;
}
return rewritten + comment.slice(index);
}
function unwrap(typed) {
for (let entry of [].concat(typed ?? [])) {
entry?.type?.names?.forEach((typeName, i) => {
entry.type.names[i] = typeName.replace(WRAPPED_RE, (_, expression) =>
expression.replace(/\\([\\"])/g, "$1")
);
});
}
}
export const handlers = {
beforeParse(e) {
e.source = e.source.replace(DOC_COMMENT_RE, rewriteComment);
},
newDoclet({ doclet }) {
unwrap(doclet);
for (let property of TYPED_PROPERTIES) {
unwrap(doclet[property]);
}
},
};