Skip to content

Commit

Permalink
Handle @example tag titles more reasonably
Browse files Browse the repository at this point in the history
Resolves #2440
  • Loading branch information
Gerrit0 committed Nov 26, 2023
1 parent 2cae791 commit deae36a
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 29 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -7,6 +7,7 @@
- Improved handling of function-modules created with `Object.assign`, #2436.
- TypeDoc will no longer warn about duplicate comments with warnings which point to a single comment, #2437
- Fixed an infinite loop when `skipLibCheck` is used to ignore some compiler errors, #2438.
- `@example` tag titles will now be rendered in the example heading, #2440.
- Correctly handle transient symbols in `@namespace`-created namespaces, #2444.

### Thanks!
Expand Down
73 changes: 60 additions & 13 deletions src/lib/converter/comments/parser.ts
Expand Up @@ -206,8 +206,8 @@ function blockTag(
const tagName = aliasedTags.get(blockTag.text) || blockTag.text;

let content: CommentDisplayPart[];
if (tagName === "@example" && config.jsDocCompatibility.exampleTag) {
content = exampleBlockContent(comment, lexer, config, warning);
if (tagName === "@example") {
return exampleBlock(comment, lexer, config, warning);
} else if (
["@default", "@defaultValue"].includes(tagName) &&
config.jsDocCompatibility.defaultTag
Expand Down Expand Up @@ -260,24 +260,73 @@ function defaultBlockContent(
/**
* 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 exampleBlockContent(
function exampleBlock(
comment: Comment,
lexer: LookaheadGenerator<Token>,
config: CommentParserConfig,
warning: (msg: string, token: Token) => void,
): CommentDisplayPart[] {
): CommentTag {
lexer.mark();
const content = blockContent(comment, lexer, config, () => {});
const end = lexer.done() || lexer.peek();
lexer.release();

if (
!config.jsDocCompatibility.exampleTag ||
content.some(
(part) => part.kind === "code" && part.text.startsWith("```"),
)
) {
return blockContent(comment, lexer, config, warning);
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(
"The first line of an example tag will be taken literally as" +
" the example name, and should only contain text.",
lexer.peek(),
);
warnedAboutRichNameContent = true;
}
exampleName += lexer.take().text;
break;
default:
assertNever(next.kind);
}
}

const content = blockContent(comment, lexer, config, warning);
const tag = new CommentTag("@example", content);
if (exampleName.trim()) {
tag.name = exampleName.trim();
}
return tag;
}

const tokens: Token[] = [];
Expand All @@ -293,23 +342,21 @@ function exampleBlockContent(
const caption = blockText.match(/^\s*<caption>(.*?)<\/caption>\s*(\n|$)/);

if (caption) {
return [
{
kind: "text",
text: caption[1] + "\n",
},
const tag = new CommentTag("@example", [
{
kind: "code",
text: makeCodeBlock(blockText.slice(caption[0].length)),
},
];
]);
tag.name = caption[1];
return tag;
} else {
return [
return new CommentTag("@example", [
{
kind: "code",
text: makeCodeBlock(blockText),
},
];
]);
}
}

Expand Down
18 changes: 12 additions & 6 deletions src/lib/output/themes/default/partials/comment.tsx
Expand Up @@ -24,12 +24,18 @@ export function commentTags({ markdown }: DefaultThemeRenderContext, props: Refl

return (
<div class="tsd-comment tsd-typography">
{tags.map((item) => (
<>
<h4>{camelToTitleCase(item.tag.substring(1))}</h4>
<Raw html={markdown(item.content)} />
</>
))}
{tags.map((item) => {
const name = item.name
? `${camelToTitleCase(item.tag.substring(1))}: ${item.name}`
: camelToTitleCase(item.tag.substring(1));

return (
<>
<h4>{name}</h4>
<Raw html={markdown(item.content)} />
</>
);
})}
</div>
);
}
Expand Down
48 changes: 38 additions & 10 deletions src/test/behavior.c2.test.ts
Expand Up @@ -295,26 +295,39 @@ describe("Behavior Tests", () => {
const project = convert("exampleTags");
const foo = query(project, "foo");
const tags = foo.comment?.blockTags.map((tag) => tag.content);
const names = foo.comment?.blockTags.map((tag) => tag.name);

equal(tags, [
[{ kind: "code", text: "```ts\n// JSDoc style\ncodeHere();\n```" }],
[
{ kind: "text", text: "JSDoc specialness\n" },
{
kind: "code",
text: "```ts\n// JSDoc style\ncodeHere();\n```",
},
],
[
{ kind: "text", text: "JSDoc with braces\n" },
{
kind: "code",
text: "```ts\nx.map(() => { return 1; })\n```",
},
],
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
[{ kind: "code", text: "```ts\noops();\n```" }],
]);

equal(names, [
undefined,
"JSDoc specialness",
"JSDoc with braces",
undefined,
"TSDoc name",
"Bad {@link} name",
]);

logger.expectMessage(
"warn: The first line of an example tag will be taken literally as the example name, and should only contain text.",
);
logger.expectNoOtherMessages();
});

Expand All @@ -323,28 +336,43 @@ describe("Behavior Tests", () => {
const project = convert("exampleTags");
const foo = query(project, "foo");
const tags = foo.comment?.blockTags.map((tag) => tag.content);
const names = foo.comment?.blockTags.map((tag) => tag.name);

equal(tags, [
[{ kind: "text", text: "// JSDoc style\ncodeHere();" }],
[
{
kind: "text",
text: "<caption>JSDoc specialness</caption>\n// JSDoc style\ncodeHere();",
text: "// JSDoc style\ncodeHere();",
},
],
[
{
kind: "text",
text: "<caption>JSDoc with braces</caption>\nx.map(() => { return 1; })",
text: "x.map(() => { return 1; })",
},
],
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
[{ kind: "code", text: "```ts\noops();\n```" }],
]);

equal(names, [
undefined,
"<caption>JSDoc specialness</caption>",
"<caption>JSDoc with braces</caption>",
undefined,
"TSDoc name",
"Bad {@link} name",
]);

logger.expectMessage(
"warn: Encountered an unescaped open brace without an inline tag",
);
logger.expectMessage("warn: Unmatched closing brace");
logger.expectMessage(
"warn: The first line of an example tag will be taken literally as the example name, and should only contain text.",
);
logger.expectNoOtherMessages();
});

Expand Down Expand Up @@ -472,14 +500,14 @@ describe("Behavior Tests", () => {
);

const meth = query(project, "InterfaceTarget.someMethod");
const example = new CommentTag("@example", [
{ kind: "code", text: "```ts\nsomeMethod(123)\n```" },
]);
example.name = `This should still be present`;

const methodComment = new Comment(
[{ kind: "text", text: "Method description" }],
[
new CommentTag("@example", [
{ kind: "text", text: "This should still be present\n" },
{ kind: "code", text: "```ts\nsomeMethod(123)\n```" },
]),
],
[example],
);
equal(meth.signatures?.[0].comment, methodComment);
});
Expand Down
11 changes: 11 additions & 0 deletions src/test/converter2/behavior/exampleTags.ts
Expand Up @@ -20,5 +20,16 @@
* // TSDoc style
* codeHere();
* ```
*
* @example TSDoc name
* ```ts
* // TSDoc style
* codeHere();
* ```
*
* @example Bad {@link} name
* ```ts
* oops();
* ```
*/
export const foo = 123;

0 comments on commit deae36a

Please sign in to comment.