Skip to content

Commit deae36a

Browse files
committed
Handle @example tag titles more reasonably
Resolves #2440
1 parent 2cae791 commit deae36a

File tree

5 files changed

+122
-29
lines changed

5 files changed

+122
-29
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Improved handling of function-modules created with `Object.assign`, #2436.
88
- TypeDoc will no longer warn about duplicate comments with warnings which point to a single comment, #2437
99
- Fixed an infinite loop when `skipLibCheck` is used to ignore some compiler errors, #2438.
10+
- `@example` tag titles will now be rendered in the example heading, #2440.
1011
- Correctly handle transient symbols in `@namespace`-created namespaces, #2444.
1112

1213
### Thanks!

src/lib/converter/comments/parser.ts

+60-13
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,8 @@ function blockTag(
206206
const tagName = aliasedTags.get(blockTag.text) || blockTag.text;
207207

208208
let content: CommentDisplayPart[];
209-
if (tagName === "@example" && config.jsDocCompatibility.exampleTag) {
210-
content = exampleBlockContent(comment, lexer, config, warning);
209+
if (tagName === "@example") {
210+
return exampleBlock(comment, lexer, config, warning);
211211
} else if (
212212
["@default", "@defaultValue"].includes(tagName) &&
213213
config.jsDocCompatibility.defaultTag
@@ -260,24 +260,73 @@ function defaultBlockContent(
260260
/**
261261
* The `@example` tag gets a special case because otherwise we will produce many warnings
262262
* about unescaped/mismatched/missing braces in legacy JSDoc comments.
263+
*
264+
* In TSDoc, we also want to treat the first line of the block as the example name.
263265
*/
264-
function exampleBlockContent(
266+
function exampleBlock(
265267
comment: Comment,
266268
lexer: LookaheadGenerator<Token>,
267269
config: CommentParserConfig,
268270
warning: (msg: string, token: Token) => void,
269-
): CommentDisplayPart[] {
271+
): CommentTag {
270272
lexer.mark();
271273
const content = blockContent(comment, lexer, config, () => {});
272274
const end = lexer.done() || lexer.peek();
273275
lexer.release();
274276

275277
if (
278+
!config.jsDocCompatibility.exampleTag ||
276279
content.some(
277280
(part) => part.kind === "code" && part.text.startsWith("```"),
278281
)
279282
) {
280-
return blockContent(comment, lexer, config, warning);
283+
let exampleName = "";
284+
285+
// First line of @example block is the example name.
286+
let warnedAboutRichNameContent = false;
287+
outer: while ((lexer.done() || lexer.peek()) !== end) {
288+
const next = lexer.peek();
289+
switch (next.kind) {
290+
case TokenSyntaxKind.NewLine:
291+
lexer.take();
292+
break outer;
293+
case TokenSyntaxKind.Text: {
294+
const newline = next.text.indexOf("\n");
295+
if (newline !== -1) {
296+
exampleName += next.text.substring(0, newline);
297+
next.pos += newline + 1;
298+
break outer;
299+
} else {
300+
exampleName += lexer.take().text;
301+
}
302+
break;
303+
}
304+
case TokenSyntaxKind.Code:
305+
case TokenSyntaxKind.Tag:
306+
case TokenSyntaxKind.TypeAnnotation:
307+
case TokenSyntaxKind.CloseBrace:
308+
case TokenSyntaxKind.OpenBrace:
309+
if (!warnedAboutRichNameContent) {
310+
warning(
311+
"The first line of an example tag will be taken literally as" +
312+
" the example name, and should only contain text.",
313+
lexer.peek(),
314+
);
315+
warnedAboutRichNameContent = true;
316+
}
317+
exampleName += lexer.take().text;
318+
break;
319+
default:
320+
assertNever(next.kind);
321+
}
322+
}
323+
324+
const content = blockContent(comment, lexer, config, warning);
325+
const tag = new CommentTag("@example", content);
326+
if (exampleName.trim()) {
327+
tag.name = exampleName.trim();
328+
}
329+
return tag;
281330
}
282331

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

295344
if (caption) {
296-
return [
297-
{
298-
kind: "text",
299-
text: caption[1] + "\n",
300-
},
345+
const tag = new CommentTag("@example", [
301346
{
302347
kind: "code",
303348
text: makeCodeBlock(blockText.slice(caption[0].length)),
304349
},
305-
];
350+
]);
351+
tag.name = caption[1];
352+
return tag;
306353
} else {
307-
return [
354+
return new CommentTag("@example", [
308355
{
309356
kind: "code",
310357
text: makeCodeBlock(blockText),
311358
},
312-
];
359+
]);
313360
}
314361
}
315362

src/lib/output/themes/default/partials/comment.tsx

+12-6
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,18 @@ export function commentTags({ markdown }: DefaultThemeRenderContext, props: Refl
2424

2525
return (
2626
<div class="tsd-comment tsd-typography">
27-
{tags.map((item) => (
28-
<>
29-
<h4>{camelToTitleCase(item.tag.substring(1))}</h4>
30-
<Raw html={markdown(item.content)} />
31-
</>
32-
))}
27+
{tags.map((item) => {
28+
const name = item.name
29+
? `${camelToTitleCase(item.tag.substring(1))}: ${item.name}`
30+
: camelToTitleCase(item.tag.substring(1));
31+
32+
return (
33+
<>
34+
<h4>{name}</h4>
35+
<Raw html={markdown(item.content)} />
36+
</>
37+
);
38+
})}
3339
</div>
3440
);
3541
}

src/test/behavior.c2.test.ts

+38-10
Original file line numberDiff line numberDiff line change
@@ -295,26 +295,39 @@ describe("Behavior Tests", () => {
295295
const project = convert("exampleTags");
296296
const foo = query(project, "foo");
297297
const tags = foo.comment?.blockTags.map((tag) => tag.content);
298+
const names = foo.comment?.blockTags.map((tag) => tag.name);
298299

299300
equal(tags, [
300301
[{ kind: "code", text: "```ts\n// JSDoc style\ncodeHere();\n```" }],
301302
[
302-
{ kind: "text", text: "JSDoc specialness\n" },
303303
{
304304
kind: "code",
305305
text: "```ts\n// JSDoc style\ncodeHere();\n```",
306306
},
307307
],
308308
[
309-
{ kind: "text", text: "JSDoc with braces\n" },
310309
{
311310
kind: "code",
312311
text: "```ts\nx.map(() => { return 1; })\n```",
313312
},
314313
],
315314
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
315+
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
316+
[{ kind: "code", text: "```ts\noops();\n```" }],
317+
]);
318+
319+
equal(names, [
320+
undefined,
321+
"JSDoc specialness",
322+
"JSDoc with braces",
323+
undefined,
324+
"TSDoc name",
325+
"Bad {@link} name",
316326
]);
317327

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

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

327341
equal(tags, [
328342
[{ kind: "text", text: "// JSDoc style\ncodeHere();" }],
329343
[
330344
{
331345
kind: "text",
332-
text: "<caption>JSDoc specialness</caption>\n// JSDoc style\ncodeHere();",
346+
text: "// JSDoc style\ncodeHere();",
333347
},
334348
],
335349
[
336350
{
337351
kind: "text",
338-
text: "<caption>JSDoc with braces</caption>\nx.map(() => { return 1; })",
352+
text: "x.map(() => { return 1; })",
339353
},
340354
],
341355
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
356+
[{ kind: "code", text: "```ts\n// TSDoc style\ncodeHere();\n```" }],
357+
[{ kind: "code", text: "```ts\noops();\n```" }],
358+
]);
359+
360+
equal(names, [
361+
undefined,
362+
"<caption>JSDoc specialness</caption>",
363+
"<caption>JSDoc with braces</caption>",
364+
undefined,
365+
"TSDoc name",
366+
"Bad {@link} name",
342367
]);
343368

344369
logger.expectMessage(
345370
"warn: Encountered an unescaped open brace without an inline tag",
346371
);
347372
logger.expectMessage("warn: Unmatched closing brace");
373+
logger.expectMessage(
374+
"warn: The first line of an example tag will be taken literally as the example name, and should only contain text.",
375+
);
348376
logger.expectNoOtherMessages();
349377
});
350378

@@ -472,14 +500,14 @@ describe("Behavior Tests", () => {
472500
);
473501

474502
const meth = query(project, "InterfaceTarget.someMethod");
503+
const example = new CommentTag("@example", [
504+
{ kind: "code", text: "```ts\nsomeMethod(123)\n```" },
505+
]);
506+
example.name = `This should still be present`;
507+
475508
const methodComment = new Comment(
476509
[{ kind: "text", text: "Method description" }],
477-
[
478-
new CommentTag("@example", [
479-
{ kind: "text", text: "This should still be present\n" },
480-
{ kind: "code", text: "```ts\nsomeMethod(123)\n```" },
481-
]),
482-
],
510+
[example],
483511
);
484512
equal(meth.signatures?.[0].comment, methodComment);
485513
});

src/test/converter2/behavior/exampleTags.ts

+11
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,16 @@
2020
* // TSDoc style
2121
* codeHere();
2222
* ```
23+
*
24+
* @example TSDoc name
25+
* ```ts
26+
* // TSDoc style
27+
* codeHere();
28+
* ```
29+
*
30+
* @example Bad {@link} name
31+
* ```ts
32+
* oops();
33+
* ```
2334
*/
2435
export const foo = 123;

0 commit comments

Comments
 (0)