typedoc
Version:
Create api documentation for TypeScript projects.
498 lines (497 loc) • 19.7 kB
JavaScript
import assert, { ok } from "assert";
import { parseDocument as parseYamlDoc } from "yaml";
import { Comment, CommentTag, } from "../../models/index.js";
import { assertNever, removeIf } from "../../utils/index.js";
import { nicePath } from "../../utils/paths.js";
import { TokenSyntaxKind } from "./lexer.js";
import { extractTagName } from "./tagName.js";
import { FileRegistry } from "../../models/FileRegistry.js";
import { textContent, TextParserReentryState } from "./textParser.js";
import { hasDeclarationFileExtension } from "../../utils/fs.js";
function makeLookaheadGenerator(gen) {
let trackHistory = false;
const history = [];
const next = [gen.next()];
return {
done() {
return !!next[0].done;
},
peek() {
ok(!next[0].done);
return next[0].value;
},
take() {
const thisItem = next.shift();
if (trackHistory) {
history.push(thisItem);
}
ok(!thisItem.done);
next.push(gen.next());
return thisItem.value;
},
mark() {
ok(!trackHistory, "Can only mark one location for backtracking at a time");
trackHistory = true;
},
release() {
trackHistory = false;
next.unshift(...history);
history.length = 0;
},
};
}
export function parseComment(tokens, config, file, logger, files) {
const lexer = makeLookaheadGenerator(tokens);
const tok = lexer.done() || lexer.peek();
const comment = new Comment();
comment.sourcePath = file.fileName;
comment.summary = blockContent(comment, lexer, config, logger.i18n, warningImpl, files);
while (!lexer.done()) {
comment.blockTags.push(blockTag(comment, lexer, config, logger.i18n, warningImpl, files));
}
const tok2 = tok;
postProcessComment(comment, logger.i18n, () => `${nicePath(file.fileName)}:${file.getLineAndCharacterOfPosition(tok2.pos).line + 1}`, (message) => logger.warn(message));
return comment;
function warningImpl(message, token) {
if (config.suppressCommentWarningsInDeclarationFiles &&
hasDeclarationFileExtension(file.fileName)) {
return;
}
logger.warn(message, token.pos, file);
}
}
/**
* Intended for parsing markdown documents. This only parses code blocks and
* inline tags outside of code blocks, everything else is text.
*
* If you change this, also look at blockContent, as it likely needs similar
* modifications to ensure parsing is consistent.
*/
export function parseCommentString(tokens, config, file, logger, files) {
const suppressWarningsConfig = {
...config,
jsDocCompatibility: {
defaultTag: true,
exampleTag: true,
ignoreUnescapedBraces: true,
inheritDocTag: true,
},
suppressCommentWarningsInDeclarationFiles: true,
};
const reentry = new TextParserReentryState();
const content = [];
const lexer = makeLookaheadGenerator(tokens);
let atNewLine = false;
while (!lexer.done()) {
let consume = true;
const next = lexer.peek();
reentry.checkState(next);
switch (next.kind) {
case TokenSyntaxKind.TypeAnnotation:
// Shouldn't have been produced by our lexer
assert(false, "Should be unreachable");
break;
case TokenSyntaxKind.NewLine:
case TokenSyntaxKind.Text:
case TokenSyntaxKind.Tag:
case TokenSyntaxKind.CloseBrace:
textContent(file.fileName, next, logger.i18n, (msg, token) => logger.warn(msg, token.pos, file), content, files, atNewLine, reentry);
break;
case TokenSyntaxKind.Code:
content.push({ kind: "code", text: next.text });
break;
case TokenSyntaxKind.OpenBrace:
inlineTag(lexer, content, suppressWarningsConfig, logger.i18n, (message, token) => logger.warn(message, token.pos, file));
consume = false;
break;
default:
assertNever(next.kind);
}
atNewLine = next.kind === TokenSyntaxKind.NewLine;
if (consume) {
lexer.take();
}
}
// Check for frontmatter
let frontmatterData = {};
const firstBlock = content.at(0);
if (firstBlock?.text.startsWith("---\n")) {
const end = firstBlock.text.indexOf("\n---\n");
if (end !== -1) {
const yamlText = firstBlock.text.slice("---\n".length, end);
firstBlock.text = firstBlock.text
.slice(end + "\n---\n".length)
.trimStart();
const frontmatter = parseYamlDoc(yamlText, { prettyErrors: false });
for (const warning of frontmatter.warnings) {
// Can't translate issues coming from external library...
logger.warn(warning.message, warning.pos[0] + "---\n".length, file);
}
for (const error of frontmatter.errors) {
// Can't translate issues coming from external library...
logger.error(error.message, error.pos[0] + "---\n".length, file);
}
if (frontmatter.errors.length === 0) {
const data = frontmatter.toJS();
if (typeof data === "object") {
frontmatterData = data;
}
else {
logger.error(logger.i18n.yaml_frontmatter_not_an_object(), 5, file);
}
}
}
}
return { content, frontmatter: frontmatterData };
}
const HAS_USER_IDENTIFIER = [
"@callback",
"@param",
"@prop",
"@property",
"@template",
"@typedef",
"@typeParam",
"@inheritDoc",
];
function makeCodeBlock(text) {
return "```ts\n" + text + "\n```";
}
/**
* Loop over comment, produce lint warnings, and set tag names for tags
* which have them.
*/
function postProcessComment(comment, i18n, getPosition, warning) {
for (const tag of comment.blockTags) {
if (HAS_USER_IDENTIFIER.includes(tag.tag) && tag.content.length) {
const first = tag.content[0];
if (first.kind === "text") {
const { name, newText } = extractTagName(first.text);
tag.name = name;
if (newText) {
first.text = newText;
}
else {
// Remove this token, no real text in it.
tag.content.shift();
}
}
}
if (tag.content.some((part) => part.kind === "inline-tag" && part.tag === "@inheritDoc")) {
warning(i18n.inline_inheritdoc_should_not_appear_in_block_tag_in_comment_at_0(getPosition()));
}
}
const remarks = comment.blockTags.filter((tag) => tag.tag === "@remarks");
if (remarks.length > 1) {
warning(i18n.at_most_one_remarks_tag_expected_in_comment_at_0(getPosition()));
removeIf(comment.blockTags, (tag) => remarks.indexOf(tag) > 0);
}
const returns = comment.blockTags.filter((tag) => tag.tag === "@returns");
if (remarks.length > 1) {
warning(i18n.at_most_one_returns_tag_expected_in_comment_at_0(getPosition()));
removeIf(comment.blockTags, (tag) => returns.indexOf(tag) > 0);
}
const inheritDoc = comment.blockTags.filter((tag) => tag.tag === "@inheritDoc");
const inlineInheritDoc = comment.summary.filter((part) => part.kind === "inline-tag" && part.tag === "@inheritDoc");
if (inlineInheritDoc.length + inheritDoc.length > 1) {
warning(i18n.at_most_one_inheritdoc_tag_expected_in_comment_at_0(getPosition()));
const allInheritTags = [...inlineInheritDoc, ...inheritDoc];
removeIf(comment.summary, (part) => allInheritTags.indexOf(part) > 0);
removeIf(comment.blockTags, (tag) => allInheritTags.indexOf(tag) > 0);
}
if ((inlineInheritDoc.length || inheritDoc.length) &&
comment.summary.some((part) => part.kind !== "inline-tag" && /\S/.test(part.text))) {
warning(i18n.content_in_summary_overwritten_by_inheritdoc_in_comment_at_0(getPosition()));
}
if ((inlineInheritDoc.length || inheritDoc.length) && remarks.length) {
warning(i18n.content_in_remarks_block_overwritten_by_inheritdoc_in_comment_at_0(getPosition()));
}
}
const aliasedTags = new Map([["@return", "@returns"]]);
function blockTag(comment, lexer, config, i18n, warning, files) {
const blockTag = lexer.take();
ok(blockTag.kind === TokenSyntaxKind.Tag, "blockTag called not at the start of a block tag."); // blockContent is broken if this fails.
if (!config.blockTags.has(blockTag.text)) {
warning(i18n.unknown_block_tag_0(blockTag.text), blockTag);
}
const tagName = aliasedTags.get(blockTag.text) || blockTag.text;
let content;
if (tagName === "@example") {
return exampleBlock(comment, lexer, config, i18n, warning, files);
}
else if (["@default", "@defaultValue"].includes(tagName) &&
config.jsDocCompatibility.defaultTag) {
content = defaultBlockContent(comment, lexer, config, i18n, warning, files);
}
else {
content = blockContent(comment, lexer, config, i18n, warning, files);
}
return new CommentTag(tagName, content);
}
/**
* The `@default` tag gets a special case because otherwise we will produce many warnings
* about unescaped/mismatched/missing braces in legacy JSDoc comments
*/
function defaultBlockContent(comment, lexer, config, i18n, warning, files) {
lexer.mark();
const tempRegistry = new FileRegistry();
const content = blockContent(comment, lexer, config, i18n, () => { }, tempRegistry);
const end = lexer.done() || lexer.peek();
lexer.release();
if (content.some((part) => part.kind === "code" || part.kind === "inline-tag")) {
return blockContent(comment, lexer, config, i18n, warning, files);
}
const tokens = [];
while ((lexer.done() || lexer.peek()) !== end) {
tokens.push(lexer.take());
}
const blockText = tokens
.map((tok) => tok.text)
.join("")
.trim();
return [
{
kind: "code",
text: makeCodeBlock(blockText),
},
];
}
/**
* The `@example` tag gets a special case because otherwise we will produce many warnings
* about unescaped/mismatched/missing braces in legacy JSDoc comments.
*
* In TSDoc, we also want to treat the first line of the block as the example name.
*/
function exampleBlock(comment, lexer, config, i18n, warning, files) {
lexer.mark();
const tempRegistry = new FileRegistry();
const content = blockContent(comment, lexer, config, i18n, () => { }, tempRegistry);
const end = lexer.done() || lexer.peek();
lexer.release();
if (!config.jsDocCompatibility.exampleTag ||
content.some((part) => part.kind === "code" && part.text.startsWith("```"))) {
let exampleName = "";
// First line of @example block is the example name.
let warnedAboutRichNameContent = false;
outer: while ((lexer.done() || lexer.peek()) !== end) {
const next = lexer.peek();
switch (next.kind) {
case TokenSyntaxKind.NewLine:
lexer.take();
break outer;
case TokenSyntaxKind.Text: {
const newline = next.text.indexOf("\n");
if (newline !== -1) {
exampleName += next.text.substring(0, newline);
next.pos += newline + 1;
break outer;
}
else {
exampleName += lexer.take().text;
}
break;
}
case TokenSyntaxKind.Code:
case TokenSyntaxKind.Tag:
case TokenSyntaxKind.TypeAnnotation:
case TokenSyntaxKind.CloseBrace:
case TokenSyntaxKind.OpenBrace:
if (!warnedAboutRichNameContent) {
warning(i18n.example_tag_literal_name(), lexer.peek());
warnedAboutRichNameContent = true;
}
exampleName += lexer.take().text;
break;
default:
assertNever(next.kind);
}
}
const content = blockContent(comment, lexer, config, i18n, warning, files);
const tag = new CommentTag("@example", content);
if (exampleName.trim()) {
tag.name = exampleName.trim();
}
return tag;
}
const tokens = [];
while ((lexer.done() || lexer.peek()) !== end) {
tokens.push(lexer.take());
}
const blockText = tokens
.map((tok) => tok.text)
.join("")
.trim();
const caption = blockText.match(/^\s*<caption>(.*?)<\/caption>\s*(\n|$)/);
if (caption) {
const tag = new CommentTag("@example", [
{
kind: "code",
text: makeCodeBlock(blockText.slice(caption[0].length)),
},
]);
tag.name = caption[1];
return tag;
}
else {
return new CommentTag("@example", [
{
kind: "code",
text: makeCodeBlock(blockText),
},
]);
}
}
/**
* If you change this, also look at parseCommentString as it
* likely needs similar modifications to ensure parsing is consistent.
*/
function blockContent(comment, lexer, config, i18n, warning, files) {
const content = [];
let atNewLine = true;
const reentry = new TextParserReentryState();
loop: while (!lexer.done()) {
const next = lexer.peek();
reentry.checkState(next);
let consume = true;
switch (next.kind) {
case TokenSyntaxKind.NewLine:
content.push({ kind: "text", text: next.text });
break;
case TokenSyntaxKind.Text:
textContent(comment.sourcePath, next, i18n, warning,
/*out*/ content, files, atNewLine, reentry);
break;
case TokenSyntaxKind.Code:
content.push({ kind: "code", text: next.text });
break;
case TokenSyntaxKind.Tag:
if (next.text === "@inheritdoc") {
if (!config.jsDocCompatibility.inheritDocTag) {
warning(i18n.inheritdoc_tag_properly_capitalized(), next);
}
next.text = "@inheritDoc";
}
if (config.modifierTags.has(next.text)) {
comment.modifierTags.add(next.text);
break;
}
else if (!atNewLine && !config.blockTags.has(next.text)) {
// Treat unknown tag as a modifier, but warn about it.
comment.modifierTags.add(next.text);
warning(i18n.treating_unrecognized_tag_0_as_modifier(next.text), next);
break;
}
else {
// Block tag or unknown tag, handled by our caller.
break loop;
}
case TokenSyntaxKind.TypeAnnotation:
// We always ignore these. In TS files they are redundant, in JS files
// they are required.
break;
case TokenSyntaxKind.CloseBrace:
// Unmatched closing brace, generate a warning, and treat it as text.
if (!config.jsDocCompatibility.ignoreUnescapedBraces) {
warning(i18n.unmatched_closing_brace(), next);
}
content.push({ kind: "text", text: next.text });
break;
case TokenSyntaxKind.OpenBrace:
inlineTag(lexer, content, config, i18n, warning);
consume = false;
break;
default:
assertNever(next.kind);
}
if (consume && lexer.take().kind === TokenSyntaxKind.NewLine) {
atNewLine = true;
}
}
// Collapse adjacent text parts
for (let i = 0; i < content.length - 1 /* inside loop */;) {
if (content[i].kind === "text" && content[i + 1].kind === "text") {
content[i].text += content[i + 1].text;
content.splice(i + 1, 1);
}
else {
i++;
}
}
// Now get rid of extra whitespace, and any empty parts
for (let i = 0; i < content.length /* inside loop */;) {
if (i === 0 || content[i].kind === "inline-tag") {
content[i].text = content[i].text.trimStart();
}
if (i === content.length - 1 || content[i].kind === "inline-tag") {
content[i].text = content[i].text.trimEnd();
}
if (!content[i].text && content[i].kind === "text") {
content.splice(i, 1);
}
else {
i++;
}
}
return content;
}
function inlineTag(lexer, block, config, i18n, warning) {
const openBrace = lexer.take();
// Now skip whitespace to grab the tag name.
// If the first non-whitespace text after the brace isn't a tag,
// then produce a warning and treat what we've consumed as plain text.
if (lexer.done() ||
![TokenSyntaxKind.Text, TokenSyntaxKind.Tag].includes(lexer.peek().kind)) {
if (!config.jsDocCompatibility.ignoreUnescapedBraces) {
warning(i18n.unescaped_open_brace_without_inline_tag(), openBrace);
}
block.push({ kind: "text", text: openBrace.text });
return;
}
let tagName = lexer.take();
if (lexer.done() ||
(tagName.kind === TokenSyntaxKind.Text &&
(!/^\s+$/.test(tagName.text) ||
lexer.peek().kind != TokenSyntaxKind.Tag))) {
if (!config.jsDocCompatibility.ignoreUnescapedBraces) {
warning(i18n.unescaped_open_brace_without_inline_tag(), openBrace);
}
block.push({ kind: "text", text: openBrace.text + tagName.text });
return;
}
if (tagName.kind !== TokenSyntaxKind.Tag) {
tagName = lexer.take();
}
if (!config.inlineTags.has(tagName.text)) {
warning(i18n.unknown_inline_tag_0(tagName.text), tagName);
}
const content = [];
// At this point, we know we have an inline tag. Treat everything following as plain text,
// until we get to the closing brace.
while (!lexer.done() && lexer.peek().kind !== TokenSyntaxKind.CloseBrace) {
const token = lexer.take();
if (token.kind === TokenSyntaxKind.OpenBrace) {
warning(i18n.open_brace_within_inline_tag(), token);
}
content.push(token.kind === TokenSyntaxKind.NewLine ? " " : token.text);
}
if (lexer.done()) {
warning(i18n.inline_tag_not_closed(), openBrace);
}
else {
lexer.take(); // Close brace
}
const inlineTag = {
kind: "inline-tag",
tag: tagName.text,
text: content.join(""),
};
if (tagName.tsLinkTarget) {
inlineTag.target = tagName.tsLinkTarget;
}
// Separated from tsLinkTarget to avoid storing a useless empty string
// if TS doesn't have an opinion on what the link text should be.
if (tagName.tsLinkText) {
inlineTag.tsLinkText = tagName.tsLinkText;
}
block.push(inlineTag);
}