diff --git a/CHANGELOG.md b/CHANGELOG.md index 42a8aa9e5..e520d1c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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! diff --git a/src/lib/converter/comments/parser.ts b/src/lib/converter/comments/parser.ts index 0be20ee4f..df89e06ab 100644 --- a/src/lib/converter/comments/parser.ts +++ b/src/lib/converter/comments/parser.ts @@ -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 @@ -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, 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[] = []; @@ -293,23 +342,21 @@ function exampleBlockContent( const caption = blockText.match(/^\s*(.*?)<\/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), }, - ]; + ]); } } diff --git a/src/lib/output/themes/default/partials/comment.tsx b/src/lib/output/themes/default/partials/comment.tsx index 6cadeccde..768b70469 100644 --- a/src/lib/output/themes/default/partials/comment.tsx +++ b/src/lib/output/themes/default/partials/comment.tsx @@ -24,12 +24,18 @@ export function commentTags({ markdown }: DefaultThemeRenderContext, props: Refl return (
- {tags.map((item) => ( - <> -

{camelToTitleCase(item.tag.substring(1))}

- - - ))} + {tags.map((item) => { + const name = item.name + ? `${camelToTitleCase(item.tag.substring(1))}: ${item.name}` + : camelToTitleCase(item.tag.substring(1)); + + return ( + <> +

{name}

+ + + ); + })}
); } diff --git a/src/test/behavior.c2.test.ts b/src/test/behavior.c2.test.ts index 1deeda6c5..8af976d76 100644 --- a/src/test/behavior.c2.test.ts +++ b/src/test/behavior.c2.test.ts @@ -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(); }); @@ -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: "JSDoc specialness\n// JSDoc style\ncodeHere();", + text: "// JSDoc style\ncodeHere();", }, ], [ { kind: "text", - text: "JSDoc with braces\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, + "JSDoc specialness", + "JSDoc with braces", + 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(); }); @@ -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); }); diff --git a/src/test/converter2/behavior/exampleTags.ts b/src/test/converter2/behavior/exampleTags.ts index 6bb7654b6..15355b1ff 100644 --- a/src/test/converter2/behavior/exampleTags.ts +++ b/src/test/converter2/behavior/exampleTags.ts @@ -20,5 +20,16 @@ * // TSDoc style * codeHere(); * ``` + * + * @example TSDoc name + * ```ts + * // TSDoc style + * codeHere(); + * ``` + * + * @example Bad {@link} name + * ```ts + * oops(); + * ``` */ export const foo = 123;