From ef8dac192072b30a1fc13509d6e0633e768086ca Mon Sep 17 00:00:00 2001 From: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:01:54 +0000 Subject: [PATCH 1/5] feat: Preserve `@example` titles --- .../example-block-title_2026-07-29-00-00.json | 10 + tsdoc/etc/tsdoc.api.md | 31 ++ tsdoc/src/emitters/TSDocEmitter.ts | 12 + .../emitters/__tests__/TSDocEmitter.test.ts | 35 ++ tsdoc/src/nodes/BuiltInDocNodes.ts | 1 + tsdoc/src/nodes/DocExampleBlock.ts | 104 ++++ tsdoc/src/nodes/DocExcerpt.ts | 6 + tsdoc/src/nodes/DocNode.ts | 1 + tsdoc/src/nodes/index.ts | 1 + tsdoc/src/parser/NodeParser.ts | 44 ++ .../__tests__/NodeParserExampleBlock.test.ts | 34 ++ .../NodeParserExampleBlock.test.ts.snap | 458 ++++++++++++++++++ 12 files changed, 737 insertions(+) create mode 100644 common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json create mode 100644 tsdoc/src/nodes/DocExampleBlock.ts create mode 100644 tsdoc/src/parser/__tests__/NodeParserExampleBlock.test.ts create mode 100644 tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap diff --git a/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json b/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json new file mode 100644 index 00000000..444d82fd --- /dev/null +++ b/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/tsdoc", + "comment": "Parse the text following an `@example` tag as an example title per documented spec, exposed via the new `DocExampleBlock` node; round-trip emission preserves the title on the tag line", + "type": "minor" + } + ], + "packageName": "@microsoft/tsdoc" +} diff --git a/tsdoc/etc/tsdoc.api.md b/tsdoc/etc/tsdoc.api.md index 6c773003..8b3f45df 100644 --- a/tsdoc/etc/tsdoc.api.md +++ b/tsdoc/etc/tsdoc.api.md @@ -109,6 +109,17 @@ export class DocEscapedText extends DocNode { protected onGetChildNodes(): ReadonlyArray; } +// @public +export class DocExampleBlock extends DocBlock { + // @internal + constructor(parameters: IDocExampleBlockParameters | IDocExampleBlockParsedParameters); + // @override (undocumented) + get kind(): DocNodeKind | string; + // @override (undocumented) + protected onGetChildNodes(): ReadonlyArray; + get title(): string; +} + // @public export class DocExcerpt extends DocNode { // @internal @@ -315,6 +326,8 @@ export enum DocNodeKind { // (undocumented) EscapedText = "EscapedText", // (undocumented) + ExampleBlock = "ExampleBlock", + // (undocumented) Excerpt = "Excerpt", // (undocumented) FencedCode = "FencedCode", @@ -463,6 +476,8 @@ export enum ExcerptKind { // (undocumented) EscapedText = "EscapedText", // (undocumented) + ExampleBlock_Title = "ExampleBlock_Title", + // (undocumented) FencedCode_ClosingFence = "FencedCode_ClosingFence", // (undocumented) FencedCode_Code = "FencedCode_Code", @@ -623,6 +638,22 @@ export interface IDocEscapedTextParsedParameters extends IDocNodeParsedParameter escapeStyle: EscapeStyle; } +// @public +export interface IDocExampleBlockParameters extends IDocBlockParameters { + // (undocumented) + title?: string; +} + +// @public +export interface IDocExampleBlockParsedParameters extends IDocBlockParsedParameters { + // (undocumented) + spacingAfterTagExcerpt?: TokenSequence; + // (undocumented) + title: string; + // (undocumented) + titleExcerpt?: TokenSequence; +} + // @public export interface IDocExcerptParameters extends IDocNodeParameters { // (undocumented) diff --git a/tsdoc/src/emitters/TSDocEmitter.ts b/tsdoc/src/emitters/TSDocEmitter.ts index d812e4d1..830ca52f 100644 --- a/tsdoc/src/emitters/TSDocEmitter.ts +++ b/tsdoc/src/emitters/TSDocEmitter.ts @@ -14,6 +14,7 @@ import type { DocDeclarationReference, DocErrorText, DocEscapedText, + DocExampleBlock, DocHtmlEndTag, DocHtmlStartTag, DocHtmlAttribute, @@ -171,6 +172,17 @@ export class TSDocEmitter { this._writeContent(docEscapedText.encodedText); break; + case DocNodeKind.ExampleBlock: + const docExampleBlock: DocExampleBlock = docNode as DocExampleBlock; + this._ensureLineSkipped(); + this._renderNode(docExampleBlock.blockTag); + if (docExampleBlock.title.length > 0) { + this._writeContent(' '); + this._writeContent(docExampleBlock.title); + } + this._renderNode(docExampleBlock.content); + break; + case DocNodeKind.FencedCode: const docFencedCode: DocFencedCode = docNode as DocFencedCode; diff --git a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts index 055e9315..030fe8df 100644 --- a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts +++ b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts @@ -124,6 +124,41 @@ Object { `); }); +test('02b Round-trip @example titles', () => { + const input: string = ` +/** + * Summary. + * + * @example Adding two numbers + * \`\`\`ts + * add(1, 2); + * \`\`\` + * @example + * An example without a title. + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** + * Summary. + * + * @example Adding two numbers + * \`\`\`ts + * add(1, 2); + * \`\`\` + * + * @example + * + * An example without a title. + */ +", +} +`); +}); + test('03 TSDocEmitter.renderHtmlTag()', () => { const configuration: TSDocConfiguration = new TSDocConfiguration(); const htmlTag: DocHtmlStartTag = new DocHtmlStartTag({ diff --git a/tsdoc/src/nodes/BuiltInDocNodes.ts b/tsdoc/src/nodes/BuiltInDocNodes.ts index 444399e5..7ea6cdc2 100644 --- a/tsdoc/src/nodes/BuiltInDocNodes.ts +++ b/tsdoc/src/nodes/BuiltInDocNodes.ts @@ -18,6 +18,7 @@ export class BuiltInDocNodes { { docNodeKind: DocNodeKind.DeclarationReference, constructor: nodes.DocDeclarationReference }, { docNodeKind: DocNodeKind.ErrorText, constructor: nodes.DocErrorText }, { docNodeKind: DocNodeKind.EscapedText, constructor: nodes.DocEscapedText }, + { docNodeKind: DocNodeKind.ExampleBlock, constructor: nodes.DocExampleBlock }, { docNodeKind: DocNodeKind.Excerpt, constructor: nodes.DocExcerpt }, { docNodeKind: DocNodeKind.FencedCode, constructor: nodes.DocFencedCode }, { docNodeKind: DocNodeKind.HtmlAttribute, constructor: nodes.DocHtmlAttribute }, diff --git a/tsdoc/src/nodes/DocExampleBlock.ts b/tsdoc/src/nodes/DocExampleBlock.ts new file mode 100644 index 00000000..65d965be --- /dev/null +++ b/tsdoc/src/nodes/DocExampleBlock.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DocNodeKind, DocNode } from './DocNode'; +import { DocBlock, type IDocBlockParameters, type IDocBlockParsedParameters } from './DocBlock'; +import type { TokenSequence } from '../parser/TokenSequence'; +import { DocExcerpt, ExcerptKind } from './DocExcerpt'; + +/** + * Constructor parameters for {@link DocExampleBlock}. + */ +export interface IDocExampleBlockParameters extends IDocBlockParameters { + /** + * The title for the example, i.e. the text that appears on the same line as the `@example` tag. + * If omitted, the example has no title. + */ + readonly title?: string; +} + +/** + * Constructor parameters for {@link DocExampleBlock}. + */ +export interface IDocExampleBlockParsedParameters extends IDocBlockParsedParameters { + /** + * The whitespace that separates the `@example` tag from the title text on the same line. + */ + readonly spacingAfterTagExcerpt?: TokenSequence; + + /** + * The parsed token sequence for the title text that appears on the same line as the `@example` tag, + * or undefined if the block has no title. + */ + readonly titleExcerpt?: TokenSequence; + + /** + * The title for the example, i.e. the text that appears on the same line as the `@example` tag. + * This is an empty string if the block has no title. + */ + readonly title: string; +} + +/** + * Represents a parsed `@example` block, which provides an example illustrating how to use an API. + * + * @remarks + * Any text that appears on the same line as the `@example` tag is interpreted as a title for the + * example. The remaining content of the block (for example a code sample) is stored in the + * {@link DocBlock.content} section. + */ +export class DocExampleBlock extends DocBlock { + private readonly _spacingAfterTagExcerpt: DocExcerpt | undefined; + + private readonly _title: string; + private readonly _titleExcerpt: DocExcerpt | undefined; + + /** + * Don't call this directly. Instead use {@link TSDocParser} + * @internal + */ + public constructor(parameters: IDocExampleBlockParameters | IDocExampleBlockParsedParameters) { + super(parameters); + + this._title = parameters.title ?? ''; + + if (DocNode.isParsedParameters(parameters)) { + if (parameters.spacingAfterTagExcerpt) { + this._spacingAfterTagExcerpt = new DocExcerpt({ + configuration: this.configuration, + excerptKind: ExcerptKind.Spacing, + content: parameters.spacingAfterTagExcerpt + }); + } + + if (parameters.titleExcerpt) { + this._titleExcerpt = new DocExcerpt({ + configuration: this.configuration, + excerptKind: ExcerptKind.ExampleBlock_Title, + content: parameters.titleExcerpt + }); + } + } + } + + /** @override */ + public get kind(): DocNodeKind | string { + return DocNodeKind.ExampleBlock; + } + + /** + * The title for the example, which is the text that appears on the same line as the `@example` tag. + * + * @remarks + * If no title was specified, then this returns an empty string. A documentation tool may in that case + * index the examples numerically instead. + */ + public get title(): string { + return this._title; + } + + /** @override */ + protected onGetChildNodes(): ReadonlyArray { + return [this.blockTag, this._spacingAfterTagExcerpt, this._titleExcerpt, this.content]; + } +} diff --git a/tsdoc/src/nodes/DocExcerpt.ts b/tsdoc/src/nodes/DocExcerpt.ts index f84e8486..efcb69a3 100644 --- a/tsdoc/src/nodes/DocExcerpt.ts +++ b/tsdoc/src/nodes/DocExcerpt.ts @@ -38,6 +38,12 @@ export enum ExcerptKind { EscapedText = 'EscapedText', + /** + * The title text that appears on the same line as an `@example` tag, which is parsed into a + * {@link DocExampleBlock}. + */ + ExampleBlock_Title = 'ExampleBlock_Title', + FencedCode_OpeningFence = 'FencedCode_OpeningFence', FencedCode_Language = 'FencedCode_Language', FencedCode_Code = 'FencedCode_Code', diff --git a/tsdoc/src/nodes/DocNode.ts b/tsdoc/src/nodes/DocNode.ts index 243cf618..e4cf37a2 100644 --- a/tsdoc/src/nodes/DocNode.ts +++ b/tsdoc/src/nodes/DocNode.ts @@ -21,6 +21,7 @@ export enum DocNodeKind { DeclarationReference = 'DeclarationReference', ErrorText = 'ErrorText', EscapedText = 'EscapedText', + ExampleBlock = 'ExampleBlock', HtmlAttribute = 'HtmlAttribute', HtmlEndTag = 'HtmlEndTag', HtmlStartTag = 'HtmlStartTag', diff --git a/tsdoc/src/nodes/index.ts b/tsdoc/src/nodes/index.ts index 364126a4..6c016ab2 100644 --- a/tsdoc/src/nodes/index.ts +++ b/tsdoc/src/nodes/index.ts @@ -8,6 +8,7 @@ export * from './DocComment'; export * from './DocDeclarationReference'; export * from './DocErrorText'; export * from './DocEscapedText'; +export * from './DocExampleBlock'; export * from './DocExcerpt'; export * from './DocFencedCode'; export * from './DocHtmlAttribute'; diff --git a/tsdoc/src/parser/NodeParser.ts b/tsdoc/src/parser/NodeParser.ts index 328553d3..8df64a04 100644 --- a/tsdoc/src/parser/NodeParser.ts +++ b/tsdoc/src/parser/NodeParser.ts @@ -24,6 +24,7 @@ import { DocNodeKind, type DocSection, DocParamBlock, + DocExampleBlock, DocFencedCode, DocLinkTag, type IDocLinkTagParameters, @@ -337,6 +338,13 @@ export class NodeParser { this._currentSection = docParamBlock.content; return; + } else if (docBlockTag.tagNameWithUpperCase === StandardTags.example.tagNameWithUpperCase) { + const docExampleBlock: DocExampleBlock = this._parseExampleBlock(tokenReader, docBlockTag); + + this._addBlockToDocComment(docExampleBlock); + + this._currentSection = docExampleBlock.content; + return; } else { const newBlock: DocBlock = new DocBlock({ configuration: this._configuration, @@ -654,6 +662,42 @@ export class NodeParser { }); } + private _parseExampleBlock(tokenReader: TokenReader, docBlockTag: DocBlockTag): DocExampleBlock { + // Read any spacing that appears between the "@example" tag and the title text. + while (tokenReader.peekTokenKind() === TokenKind.Spacing) { + tokenReader.readToken(); + } + const spacingAfterTagExcerpt: TokenSequence | undefined = tokenReader.tryExtractAccumulatedSequence(); + + // Everything else on the same line as the "@example" tag is interpreted as the title. + let title: string = ''; + let done: boolean = false; + while (!done) { + switch (tokenReader.peekTokenKind()) { + case TokenKind.Newline: + case TokenKind.EndOfInput: + done = true; + break; + default: + title += tokenReader.readToken().toString(); + break; + } + } + const titleExcerpt: TokenSequence | undefined = tokenReader.tryExtractAccumulatedSequence(); + + return new DocExampleBlock({ + parsed: true, + configuration: this._configuration, + + blockTag: docBlockTag, + + spacingAfterTagExcerpt, + + titleExcerpt, + title: title.trim() + }); + } + private _pushNode(docNode: DocNode): void { if (this._configuration.docNodeManager.isAllowedChild(DocNodeKind.Paragraph, docNode.kind)) { this._currentSection.appendNodeInParagraph(docNode); diff --git a/tsdoc/src/parser/__tests__/NodeParserExampleBlock.test.ts b/tsdoc/src/parser/__tests__/NodeParserExampleBlock.test.ts new file mode 100644 index 00000000..c2ecd459 --- /dev/null +++ b/tsdoc/src/parser/__tests__/NodeParserExampleBlock.test.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TestHelpers } from './TestHelpers'; + +test('00 Example block: no title', () => { + TestHelpers.parseAndMatchNodeParserSnapshot( + ['/**', ' * @example', ' * Some example content.', ' */'].join('\n') + ); +}); + +test('01 Example block: with title', () => { + TestHelpers.parseAndMatchNodeParserSnapshot( + ['/**', ' * @example Adding two numbers', ' * Some example content.', ' */'].join('\n') + ); +}); + +test('02 Example block: title with a code sample', () => { + TestHelpers.parseAndMatchNodeParserSnapshot( + ['/**', ' * @example Basic usage', ' * ```ts', ' * add(1, 2);', ' * ```', ' */'].join('\n') + ); +}); + +test('03 Example block: multiple blocks with and without titles', () => { + TestHelpers.parseAndMatchNodeParserSnapshot( + ['/**', ' * @example First example', ' * Content 1.', ' * @example', ' * Content 2.', ' */'].join('\n') + ); +}); + +test('04 Example block: title with surrounding whitespace', () => { + TestHelpers.parseAndMatchNodeParserSnapshot( + ['/**', ' * @example Trimmed title ', ' * Content.', ' */'].join('\n') + ); +}); diff --git a/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap new file mode 100644 index 00000000..5d28f2d4 --- /dev/null +++ b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap @@ -0,0 +1,458 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`00 Example block: no title 1`] = ` +Object { + "buffer": "/**[n] * @example[n] * Some example content.[n] */", + "gaps": Array [], + "lines": Array [ + "@example", + "Some example content.", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + }, + Object { + "kind": "ExampleBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@example", + }, + ], + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "Some example content.", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`; + +exports[`01 Example block: with title 1`] = ` +Object { + "buffer": "/**[n] * @example Adding two numbers[n] * Some example content.[n] */", + "gaps": Array [], + "lines": Array [ + "@example Adding two numbers", + "Some example content.", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + }, + Object { + "kind": "ExampleBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@example", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ExampleBlock_Title", + "nodeExcerpt": "Adding two numbers", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "Some example content.", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`; + +exports[`02 Example block: title with a code sample 1`] = ` +Object { + "buffer": "/**[n] * @example Basic usage[n] * [c][c][c]ts[n] * add(1, 2);[n] * [c][c][c][n] */", + "gaps": Array [], + "lines": Array [ + "@example Basic usage", + "[c][c][c]ts", + "add(1, 2);", + "[c][c][c]", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + }, + Object { + "kind": "ExampleBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@example", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ExampleBlock_Title", + "nodeExcerpt": "Basic usage", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + Object { + "kind": "FencedCode", + "nodes": Array [ + Object { + "kind": "Excerpt: FencedCode_OpeningFence", + "nodeExcerpt": "[c][c][c]", + }, + Object { + "kind": "Excerpt: FencedCode_Language", + "nodeExcerpt": "ts", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": "[n]", + }, + Object { + "kind": "Excerpt: FencedCode_Code", + "nodeExcerpt": "add(1, 2);[n]", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": "", + }, + Object { + "kind": "Excerpt: FencedCode_ClosingFence", + "nodeExcerpt": "[c][c][c]", + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`; + +exports[`03 Example block: multiple blocks with and without titles 1`] = ` +Object { + "buffer": "/**[n] * @example First example[n] * Content 1.[n] * @example[n] * Content 2.[n] */", + "gaps": Array [], + "lines": Array [ + "@example First example", + "Content 1.", + "@example", + "Content 2.", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + }, + Object { + "kind": "ExampleBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@example", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ExampleBlock_Title", + "nodeExcerpt": "First example", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "Content 1.", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + Object { + "kind": "ExampleBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@example", + }, + ], + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "Content 2.", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`; + +exports[`04 Example block: title with surrounding whitespace 1`] = ` +Object { + "buffer": "/**[n] * @example Trimmed title [n] * Content.[n] */", + "gaps": Array [], + "lines": Array [ + "@example Trimmed title", + "Content.", + ], + "logMessages": Array [], + "nodes": Object { + "kind": "Comment", + "nodes": Array [ + Object { + "kind": "Section", + }, + Object { + "kind": "ExampleBlock", + "nodes": Array [ + Object { + "kind": "BlockTag", + "nodes": Array [ + Object { + "kind": "Excerpt: BlockTag", + "nodeExcerpt": "@example", + }, + ], + }, + Object { + "kind": "Excerpt: Spacing", + "nodeExcerpt": " ", + }, + Object { + "kind": "Excerpt: ExampleBlock_Title", + "nodeExcerpt": "Trimmed title", + }, + Object { + "kind": "Section", + "nodes": Array [ + Object { + "kind": "Paragraph", + "nodes": Array [ + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": "Content.", + }, + ], + }, + Object { + "kind": "SoftBreak", + "nodes": Array [ + Object { + "kind": "Excerpt: SoftBreak", + "nodeExcerpt": "[n]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`; From 0e29d6acf0c83eb89fdee3455e49899436ba7305 Mon Sep 17 00:00:00 2001 From: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:19:35 +0000 Subject: [PATCH 2/5] docs: Update API report --- tsdoc/etc/tsdoc.api.md | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tsdoc/etc/tsdoc.api.md b/tsdoc/etc/tsdoc.api.md index 8b3f45df..c612e808 100644 --- a/tsdoc/etc/tsdoc.api.md +++ b/tsdoc/etc/tsdoc.api.md @@ -475,7 +475,6 @@ export enum ExcerptKind { ErrorText = "ErrorText", // (undocumented) EscapedText = "EscapedText", - // (undocumented) ExampleBlock_Title = "ExampleBlock_Title", // (undocumented) FencedCode_ClosingFence = "FencedCode_ClosingFence", @@ -640,18 +639,14 @@ export interface IDocEscapedTextParsedParameters extends IDocNodeParsedParameter // @public export interface IDocExampleBlockParameters extends IDocBlockParameters { - // (undocumented) - title?: string; + readonly title?: string; } // @public export interface IDocExampleBlockParsedParameters extends IDocBlockParsedParameters { - // (undocumented) - spacingAfterTagExcerpt?: TokenSequence; - // (undocumented) - title: string; - // (undocumented) - titleExcerpt?: TokenSequence; + readonly spacingAfterTagExcerpt?: TokenSequence; + readonly title: string; + readonly titleExcerpt?: TokenSequence; } // @public From 80f3f4a752df9a050451e6cb50ef2eda72f65697 Mon Sep 17 00:00:00 2001 From: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:52:52 +0000 Subject: [PATCH 3/5] refactor: Re-implement fix without parser changes --- .../example-block-title_2026-07-29-00-00.json | 2 +- tsdoc/etc/tsdoc.api.md | 28 +-- tsdoc/src/emitters/TSDocEmitter.ts | 19 +- .../emitters/__tests__/TSDocEmitter.test.ts | 123 +++++++++++- tsdoc/src/nodes/BuiltInDocNodes.ts | 1 - tsdoc/src/nodes/DocBlock.ts | 106 +++++++++++ tsdoc/src/nodes/DocExampleBlock.ts | 104 ---------- tsdoc/src/nodes/DocExcerpt.ts | 6 - tsdoc/src/nodes/DocNode.ts | 1 - tsdoc/src/nodes/index.ts | 1 - tsdoc/src/parser/NodeParser.ts | 44 ----- .../parser/__tests__/DocBlockTitle.test.ts | 180 ++++++++++++++++++ .../NodeParserExampleBlock.test.ts.snap | 80 ++++---- 13 files changed, 454 insertions(+), 241 deletions(-) delete mode 100644 tsdoc/src/nodes/DocExampleBlock.ts create mode 100644 tsdoc/src/parser/__tests__/DocBlockTitle.test.ts diff --git a/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json b/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json index 444d82fd..ac235fee 100644 --- a/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json +++ b/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/tsdoc", - "comment": "Parse the text following an `@example` tag as an example title per documented spec, exposed via the new `DocExampleBlock` node; round-trip emission preserves the title on the tag line", + "comment": "Fix round-trip emission of `@example` block titles so the title text is preserved on the tag line, and expose the tag-line title via new `DocBlock.title` and `DocBlock.body` accessors", "type": "minor" } ], diff --git a/tsdoc/etc/tsdoc.api.md b/tsdoc/etc/tsdoc.api.md index c612e808..109bb3fd 100644 --- a/tsdoc/etc/tsdoc.api.md +++ b/tsdoc/etc/tsdoc.api.md @@ -9,11 +9,13 @@ export class DocBlock extends DocNode { // @internal constructor(parameters: IDocBlockParameters | IDocBlockParsedParameters); get blockTag(): DocBlockTag; + get body(): DocSection; get content(): DocSection; // @override (undocumented) get kind(): DocNodeKind | string; // @override (undocumented) protected onGetChildNodes(): ReadonlyArray; + get title(): DocParagraph | undefined; } // @public @@ -109,17 +111,6 @@ export class DocEscapedText extends DocNode { protected onGetChildNodes(): ReadonlyArray; } -// @public -export class DocExampleBlock extends DocBlock { - // @internal - constructor(parameters: IDocExampleBlockParameters | IDocExampleBlockParsedParameters); - // @override (undocumented) - get kind(): DocNodeKind | string; - // @override (undocumented) - protected onGetChildNodes(): ReadonlyArray; - get title(): string; -} - // @public export class DocExcerpt extends DocNode { // @internal @@ -326,8 +317,6 @@ export enum DocNodeKind { // (undocumented) EscapedText = "EscapedText", // (undocumented) - ExampleBlock = "ExampleBlock", - // (undocumented) Excerpt = "Excerpt", // (undocumented) FencedCode = "FencedCode", @@ -475,7 +464,6 @@ export enum ExcerptKind { ErrorText = "ErrorText", // (undocumented) EscapedText = "EscapedText", - ExampleBlock_Title = "ExampleBlock_Title", // (undocumented) FencedCode_ClosingFence = "FencedCode_ClosingFence", // (undocumented) @@ -637,18 +625,6 @@ export interface IDocEscapedTextParsedParameters extends IDocNodeParsedParameter escapeStyle: EscapeStyle; } -// @public -export interface IDocExampleBlockParameters extends IDocBlockParameters { - readonly title?: string; -} - -// @public -export interface IDocExampleBlockParsedParameters extends IDocBlockParsedParameters { - readonly spacingAfterTagExcerpt?: TokenSequence; - readonly title: string; - readonly titleExcerpt?: TokenSequence; -} - // @public export interface IDocExcerptParameters extends IDocNodeParameters { // (undocumented) diff --git a/tsdoc/src/emitters/TSDocEmitter.ts b/tsdoc/src/emitters/TSDocEmitter.ts index 830ca52f..b69e1b38 100644 --- a/tsdoc/src/emitters/TSDocEmitter.ts +++ b/tsdoc/src/emitters/TSDocEmitter.ts @@ -14,7 +14,6 @@ import type { DocDeclarationReference, DocErrorText, DocEscapedText, - DocExampleBlock, DocHtmlEndTag, DocHtmlStartTag, DocHtmlAttribute, @@ -105,7 +104,12 @@ export class TSDocEmitter { if ( docBlock.blockTag.tagNameWithUpperCase === StandardTags.returns.tagNameWithUpperCase || - docBlock.blockTag.tagNameWithUpperCase === StandardTags.defaultValue.tagNameWithUpperCase + docBlock.blockTag.tagNameWithUpperCase === StandardTags.defaultValue.tagNameWithUpperCase || + // An "@example" title is the text on the tag line; attach it to the tag line like "@returns". + // Guard on the title actually being present so that an untitled "@example" is not emitted with + // a trailing space. + (docBlock.blockTag.tagNameWithUpperCase === StandardTags.example.tagNameWithUpperCase && + docBlock.title !== undefined) ) { this._writeContent(' '); this._hangingParagraph = true; @@ -172,17 +176,6 @@ export class TSDocEmitter { this._writeContent(docEscapedText.encodedText); break; - case DocNodeKind.ExampleBlock: - const docExampleBlock: DocExampleBlock = docNode as DocExampleBlock; - this._ensureLineSkipped(); - this._renderNode(docExampleBlock.blockTag); - if (docExampleBlock.title.length > 0) { - this._writeContent(' '); - this._writeContent(docExampleBlock.title); - } - this._renderNode(docExampleBlock.content); - break; - case DocNodeKind.FencedCode: const docFencedCode: DocFencedCode = docNode as DocFencedCode; diff --git a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts index 030fe8df..818a6e38 100644 --- a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts +++ b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts @@ -124,17 +124,14 @@ Object { `); }); -test('02b Round-trip @example titles', () => { +// An example containing a title above a code sample. +test('02b Round-trip @example title above a code sample', () => { const input: string = ` /** - * Summary. - * * @example Adding two numbers * \`\`\`ts * add(1, 2); * \`\`\` - * @example - * An example without a title. */ `; @@ -143,13 +140,127 @@ Object { "errors": Array [], "output": " /** - * Summary. + * @example Adding two numbers + * \`\`\`ts + * add(1, 2); + * \`\`\` * + */ +", +} +`); +}); + +// An example whose entire content is on the tag line. +test('02c Round-trip @example title with no body', () => { + const input: string = ` +/** + * The CPU architecture. + * @example \`"AMD64"\` + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** + * The CPU architecture. + * + * @example \`\\"AMD64\\"\` + */ +", +} +`); +}); + +// An example with inline markup in the title. +test('02d Round-trip @example title with inline markup', () => { + const input: string = ` +/** + * @example Using {@link add} on negative numbers + * Body text. + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** + * @example Using {@link add} on negative numbers + * Body text. + */ +", +} +`); +}); + +// An example with a modifier tag on the tag line ends the example block. +test('02e Round-trip @example title followed by a modifier tag', () => { + const input: string = ` +/** + * @example Adding two numbers @internal + * \`\`\`ts + * add(1, 2); + * \`\`\` + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** * @example Adding two numbers * \`\`\`ts * add(1, 2); * \`\`\` * + * @internal + */ +", +} +`); +}); + +// An example whose content begins on the next line has no title. +test('02f Round-trip @example with no title', () => { + const input: string = ` +/** + * @example + * An example without a title. + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** + * @example + * + * An example without a title. + */ +", +} +`); +}); + +// A tag line containing only whitespace must not emit a trailing space after the tag. +test('02g Round-trip @example with a whitespace-only tag line', () => { + const input: string = ` +/** + * @example${' '} + * An example without a title. + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** * @example * * An example without a title. diff --git a/tsdoc/src/nodes/BuiltInDocNodes.ts b/tsdoc/src/nodes/BuiltInDocNodes.ts index 7ea6cdc2..444399e5 100644 --- a/tsdoc/src/nodes/BuiltInDocNodes.ts +++ b/tsdoc/src/nodes/BuiltInDocNodes.ts @@ -18,7 +18,6 @@ export class BuiltInDocNodes { { docNodeKind: DocNodeKind.DeclarationReference, constructor: nodes.DocDeclarationReference }, { docNodeKind: DocNodeKind.ErrorText, constructor: nodes.DocErrorText }, { docNodeKind: DocNodeKind.EscapedText, constructor: nodes.DocEscapedText }, - { docNodeKind: DocNodeKind.ExampleBlock, constructor: nodes.DocExampleBlock }, { docNodeKind: DocNodeKind.Excerpt, constructor: nodes.DocExcerpt }, { docNodeKind: DocNodeKind.FencedCode, constructor: nodes.DocFencedCode }, { docNodeKind: DocNodeKind.HtmlAttribute, constructor: nodes.DocHtmlAttribute }, diff --git a/tsdoc/src/nodes/DocBlock.ts b/tsdoc/src/nodes/DocBlock.ts index f701cabc..b50f98ad 100644 --- a/tsdoc/src/nodes/DocBlock.ts +++ b/tsdoc/src/nodes/DocBlock.ts @@ -3,6 +3,8 @@ import { DocNodeKind, DocNode, type IDocNodeParameters, type IDocNodeParsedParameters } from './DocNode'; import { DocSection } from './DocSection'; +import { DocParagraph } from './DocParagraph'; +import { DocNodeTransforms } from '../transforms/DocNodeTransforms'; import type { DocBlockTag } from './DocBlockTag'; /** @@ -56,6 +58,110 @@ export class DocBlock extends DocNode { return this._content; } + /** + * The block's "title", i.e. the rich text that appears on the same line as the block tag. + * + * @remarks + * Per the TSDoc specification, the text that appears on the same line as certain block tags (such as + * `@example`) is interpreted as a title. This accessor is a derived view over {@link DocBlock.content}: + * it returns the leading nodes of the first paragraph, up to (but not including) the first line break, + * re-wrapped in a synthesized {@link DocParagraph} with surrounding spaces trimmed. + * + * The title supports the same inline content as any paragraph (for example `{@link}` tags or code + * spans). It is `undefined` when the block has no text on the tag line (for example when the content + * begins on the next line), which a documentation tool may use to fall back to numeric indexing. + * + * Because TSDoc inline tags may span multiple lines, an inline tag that opens on the tag line but + * closes on a later line is a single node with no intervening line break, so the title extends across + * those lines up to where the tag closes. + * + * The underlying nodes are shared with {@link DocBlock.content}; this view does not modify the block. + */ + public get title(): DocParagraph | undefined { + const contentNodes: ReadonlyArray = this._content.nodes; + if (contentNodes.length === 0) { + return undefined; + } + + const firstNode: DocNode = contentNodes[0]; + if (firstNode.kind !== DocNodeKind.Paragraph) { + return undefined; + } + + const paragraphNodes: ReadonlyArray = (firstNode as DocParagraph).nodes; + if (paragraphNodes.length === 0 || paragraphNodes[0].kind === DocNodeKind.SoftBreak) { + // The tag line has no text (the content begins with a line break), so there is no title. + return undefined; + } + + const titleNodes: DocNode[] = []; + for (const node of paragraphNodes) { + if (node.kind === DocNodeKind.SoftBreak) { + break; + } + titleNodes.push(node); + } + + const titleParagraph: DocParagraph = new DocParagraph({ configuration: this.configuration }, titleNodes); + const trimmedTitle: DocParagraph = DocNodeTransforms.trimSpacesInParagraph(titleParagraph); + + // A tag line containing only whitespace is not a title. + if (trimmedTitle.nodes.length === 0) { + return undefined; + } + + return trimmedTitle; + } + + /** + * The block's content excluding its {@link DocBlock.title}. + * + * @remarks + * This accessor is a derived view over {@link DocBlock.content}: when a {@link DocBlock.title} is present, + * the remainder of the first paragraph (the nodes after the first line break) is re-wrapped in a + * synthesized {@link DocParagraph}, followed by the remaining content nodes. When there is no title, this + * returns the full content. + * + * The underlying nodes are shared with {@link DocBlock.content}; this view does not modify the block. + */ + public get body(): DocSection { + const bodySection: DocSection = new DocSection({ configuration: this.configuration }); + const contentNodes: ReadonlyArray = this._content.nodes; + + if (this.title === undefined) { + bodySection.appendNodes(contentNodes); + return bodySection; + } + + // The title consumed the leading portion of the first paragraph; recover the remainder that follows + // its first line break and re-wrap it in a synthesized paragraph. + const paragraphNodes: ReadonlyArray = (contentNodes[0] as DocParagraph).nodes; + let softBreakIndex: number = -1; + for (let i: number = 0; i < paragraphNodes.length; ++i) { + if (paragraphNodes[i].kind === DocNodeKind.SoftBreak) { + softBreakIndex = i; + break; + } + } + if (softBreakIndex >= 0) { + // Skip the line breaks that separated the title from the body before re-wrapping the remainder. + let remainderStart: number = softBreakIndex + 1; + while ( + remainderStart < paragraphNodes.length && + paragraphNodes[remainderStart].kind === DocNodeKind.SoftBreak + ) { + ++remainderStart; + } + const remainderNodes: ReadonlyArray = paragraphNodes.slice(remainderStart); + if (remainderNodes.length > 0) { + bodySection.appendNode(new DocParagraph({ configuration: this.configuration }, remainderNodes)); + } + } + + bodySection.appendNodes(contentNodes.slice(1)); + return bodySection; + } + /** @override */ protected onGetChildNodes(): ReadonlyArray { return [this.blockTag, this._content]; diff --git a/tsdoc/src/nodes/DocExampleBlock.ts b/tsdoc/src/nodes/DocExampleBlock.ts deleted file mode 100644 index 65d965be..00000000 --- a/tsdoc/src/nodes/DocExampleBlock.ts +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. -// See LICENSE in the project root for license information. - -import { DocNodeKind, DocNode } from './DocNode'; -import { DocBlock, type IDocBlockParameters, type IDocBlockParsedParameters } from './DocBlock'; -import type { TokenSequence } from '../parser/TokenSequence'; -import { DocExcerpt, ExcerptKind } from './DocExcerpt'; - -/** - * Constructor parameters for {@link DocExampleBlock}. - */ -export interface IDocExampleBlockParameters extends IDocBlockParameters { - /** - * The title for the example, i.e. the text that appears on the same line as the `@example` tag. - * If omitted, the example has no title. - */ - readonly title?: string; -} - -/** - * Constructor parameters for {@link DocExampleBlock}. - */ -export interface IDocExampleBlockParsedParameters extends IDocBlockParsedParameters { - /** - * The whitespace that separates the `@example` tag from the title text on the same line. - */ - readonly spacingAfterTagExcerpt?: TokenSequence; - - /** - * The parsed token sequence for the title text that appears on the same line as the `@example` tag, - * or undefined if the block has no title. - */ - readonly titleExcerpt?: TokenSequence; - - /** - * The title for the example, i.e. the text that appears on the same line as the `@example` tag. - * This is an empty string if the block has no title. - */ - readonly title: string; -} - -/** - * Represents a parsed `@example` block, which provides an example illustrating how to use an API. - * - * @remarks - * Any text that appears on the same line as the `@example` tag is interpreted as a title for the - * example. The remaining content of the block (for example a code sample) is stored in the - * {@link DocBlock.content} section. - */ -export class DocExampleBlock extends DocBlock { - private readonly _spacingAfterTagExcerpt: DocExcerpt | undefined; - - private readonly _title: string; - private readonly _titleExcerpt: DocExcerpt | undefined; - - /** - * Don't call this directly. Instead use {@link TSDocParser} - * @internal - */ - public constructor(parameters: IDocExampleBlockParameters | IDocExampleBlockParsedParameters) { - super(parameters); - - this._title = parameters.title ?? ''; - - if (DocNode.isParsedParameters(parameters)) { - if (parameters.spacingAfterTagExcerpt) { - this._spacingAfterTagExcerpt = new DocExcerpt({ - configuration: this.configuration, - excerptKind: ExcerptKind.Spacing, - content: parameters.spacingAfterTagExcerpt - }); - } - - if (parameters.titleExcerpt) { - this._titleExcerpt = new DocExcerpt({ - configuration: this.configuration, - excerptKind: ExcerptKind.ExampleBlock_Title, - content: parameters.titleExcerpt - }); - } - } - } - - /** @override */ - public get kind(): DocNodeKind | string { - return DocNodeKind.ExampleBlock; - } - - /** - * The title for the example, which is the text that appears on the same line as the `@example` tag. - * - * @remarks - * If no title was specified, then this returns an empty string. A documentation tool may in that case - * index the examples numerically instead. - */ - public get title(): string { - return this._title; - } - - /** @override */ - protected onGetChildNodes(): ReadonlyArray { - return [this.blockTag, this._spacingAfterTagExcerpt, this._titleExcerpt, this.content]; - } -} diff --git a/tsdoc/src/nodes/DocExcerpt.ts b/tsdoc/src/nodes/DocExcerpt.ts index efcb69a3..f84e8486 100644 --- a/tsdoc/src/nodes/DocExcerpt.ts +++ b/tsdoc/src/nodes/DocExcerpt.ts @@ -38,12 +38,6 @@ export enum ExcerptKind { EscapedText = 'EscapedText', - /** - * The title text that appears on the same line as an `@example` tag, which is parsed into a - * {@link DocExampleBlock}. - */ - ExampleBlock_Title = 'ExampleBlock_Title', - FencedCode_OpeningFence = 'FencedCode_OpeningFence', FencedCode_Language = 'FencedCode_Language', FencedCode_Code = 'FencedCode_Code', diff --git a/tsdoc/src/nodes/DocNode.ts b/tsdoc/src/nodes/DocNode.ts index e4cf37a2..243cf618 100644 --- a/tsdoc/src/nodes/DocNode.ts +++ b/tsdoc/src/nodes/DocNode.ts @@ -21,7 +21,6 @@ export enum DocNodeKind { DeclarationReference = 'DeclarationReference', ErrorText = 'ErrorText', EscapedText = 'EscapedText', - ExampleBlock = 'ExampleBlock', HtmlAttribute = 'HtmlAttribute', HtmlEndTag = 'HtmlEndTag', HtmlStartTag = 'HtmlStartTag', diff --git a/tsdoc/src/nodes/index.ts b/tsdoc/src/nodes/index.ts index 6c016ab2..364126a4 100644 --- a/tsdoc/src/nodes/index.ts +++ b/tsdoc/src/nodes/index.ts @@ -8,7 +8,6 @@ export * from './DocComment'; export * from './DocDeclarationReference'; export * from './DocErrorText'; export * from './DocEscapedText'; -export * from './DocExampleBlock'; export * from './DocExcerpt'; export * from './DocFencedCode'; export * from './DocHtmlAttribute'; diff --git a/tsdoc/src/parser/NodeParser.ts b/tsdoc/src/parser/NodeParser.ts index 8df64a04..328553d3 100644 --- a/tsdoc/src/parser/NodeParser.ts +++ b/tsdoc/src/parser/NodeParser.ts @@ -24,7 +24,6 @@ import { DocNodeKind, type DocSection, DocParamBlock, - DocExampleBlock, DocFencedCode, DocLinkTag, type IDocLinkTagParameters, @@ -338,13 +337,6 @@ export class NodeParser { this._currentSection = docParamBlock.content; return; - } else if (docBlockTag.tagNameWithUpperCase === StandardTags.example.tagNameWithUpperCase) { - const docExampleBlock: DocExampleBlock = this._parseExampleBlock(tokenReader, docBlockTag); - - this._addBlockToDocComment(docExampleBlock); - - this._currentSection = docExampleBlock.content; - return; } else { const newBlock: DocBlock = new DocBlock({ configuration: this._configuration, @@ -662,42 +654,6 @@ export class NodeParser { }); } - private _parseExampleBlock(tokenReader: TokenReader, docBlockTag: DocBlockTag): DocExampleBlock { - // Read any spacing that appears between the "@example" tag and the title text. - while (tokenReader.peekTokenKind() === TokenKind.Spacing) { - tokenReader.readToken(); - } - const spacingAfterTagExcerpt: TokenSequence | undefined = tokenReader.tryExtractAccumulatedSequence(); - - // Everything else on the same line as the "@example" tag is interpreted as the title. - let title: string = ''; - let done: boolean = false; - while (!done) { - switch (tokenReader.peekTokenKind()) { - case TokenKind.Newline: - case TokenKind.EndOfInput: - done = true; - break; - default: - title += tokenReader.readToken().toString(); - break; - } - } - const titleExcerpt: TokenSequence | undefined = tokenReader.tryExtractAccumulatedSequence(); - - return new DocExampleBlock({ - parsed: true, - configuration: this._configuration, - - blockTag: docBlockTag, - - spacingAfterTagExcerpt, - - titleExcerpt, - title: title.trim() - }); - } - private _pushNode(docNode: DocNode): void { if (this._configuration.docNodeManager.isAllowedChild(DocNodeKind.Paragraph, docNode.kind)) { this._currentSection.appendNodeInParagraph(docNode); diff --git a/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts b/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts new file mode 100644 index 00000000..6877df05 --- /dev/null +++ b/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DocNodeKind, type DocBlock, type DocNode, DocPlainText } from '../../nodes'; +import type { ParserContext } from '../ParserContext'; +import { TSDocParser } from '../TSDocParser'; + +function parseExampleBlock(buffer: string): DocBlock { + const parserContext: ParserContext = new TSDocParser().parseString(buffer); + let exampleBlock: DocBlock | undefined; + for (const block of parserContext.docComment.customBlocks) { + if (block.blockTag.tagNameWithUpperCase === '@EXAMPLE') { + exampleBlock = block; + break; + } + } + if (exampleBlock === undefined) { + throw new Error('The comment did not contain an @example block'); + } + return exampleBlock; +} + +/** + * Concatenates the plain text contained by a node subtree, so that a title's textual content can be + * asserted without depending on the full excerpt structure. + */ +function getPlainText(node: DocNode): string { + let result: string = ''; + if (node instanceof DocPlainText) { + result += node.text; + } + for (const child of node.getChildNodes()) { + result += getPlainText(child); + } + return result; +} + +function getChildKinds(node: DocNode): ReadonlyArray { + return node.getChildNodes().map((child) => child.kind); +} + +// An example with a title above a code sample. +test('title above a code sample', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example Adding two numbers', ' * ```ts', ' * add(1, 2);', ' * ```', ' */'].join('\n') + ); + + expect(exampleBlock.title).toBeDefined(); + expect(getPlainText(exampleBlock.title!)).toEqual('Adding two numbers'); + + // The body is everything after the title; here that is the fenced code sample. + expect(getChildKinds(exampleBlock.body)).toEqual([DocNodeKind.FencedCode]); +}); + +// An example with the whole content on the tag line. +test('title only, with no body', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * The CPU architecture.', ' * @example `"AMD64"`', ' */'].join('\n') + ); + + expect(exampleBlock.title).toBeDefined(); + // The code span in the title is preserved as a DocCodeSpan node. + expect(getChildKinds(exampleBlock.title!)).toEqual([DocNodeKind.CodeSpan]); + + // The body has no renderable content. + expect(exampleBlock.body.nodes).toHaveLength(0); +}); + +// An example with markup in the title. +test('title containing an inline tag', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example Using {@link add} on negative numbers', ' * Body text.', ' */'].join('\n') + ); + + expect(exampleBlock.title).toBeDefined(); + // The "{@link add}" markup is preserved as a real DocLinkTag rather than flattened to literal text. + expect(getChildKinds(exampleBlock.title!)).toEqual([ + DocNodeKind.PlainText, + DocNodeKind.LinkTag, + DocNodeKind.PlainText + ]); + + expect(getPlainText(exampleBlock.body)).toContain('Body text.'); +}); + +// An example with content beginning on the next line has no title. +test('no title when content begins on the next line', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example', ' * Some example content.', ' */'].join('\n') + ); + + expect(exampleBlock.title).toBeUndefined(); + expect(getPlainText(exampleBlock.body)).toContain('Some example content.'); +}); + +// A tag line containing only whitespace is not a title. +test('no title when the tag line is only whitespace', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example ', ' * Some example content.', ' */'].join('\n') + ); + + expect(exampleBlock.title).toBeUndefined(); + expect(getPlainText(exampleBlock.body)).toContain('Some example content.'); +}); + +// The title text is trimmed of surrounding whitespace. +test('title is trimmed of surrounding whitespace', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example Trimmed title ', ' * Content.', ' */'].join('\n') + ); + + expect(exampleBlock.title).toBeDefined(); + expect(getPlainText(exampleBlock.title!)).toEqual('Trimmed title'); +}); + +// A title with body prose on the immediately following line (no blank line). +test('body prose on the next line is re-wrapped into a paragraph', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example A title', ' * Body prose here.', ' */'].join('\n') + ); + + expect(getPlainText(exampleBlock.title!)).toEqual('A title'); + expect(getChildKinds(exampleBlock.body)).toEqual([DocNodeKind.Paragraph]); + expect(getPlainText(exampleBlock.body)).toEqual('Body prose here.'); +}); + +// A title separated from the body by a blank line. +test('body separated from the title by a blank line', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example A title', ' *', ' * Body paragraph.', ' */'].join('\n') + ); + + expect(getPlainText(exampleBlock.title!)).toEqual('A title'); + expect(getChildKinds(exampleBlock.body)).toEqual([DocNodeKind.Paragraph]); + expect(getPlainText(exampleBlock.body)).toEqual('Body paragraph.'); +}); + +// Multiple @example blocks are parsed independently. +test('multiple example blocks each expose their own title and body', () => { + const parserContext: ParserContext = new TSDocParser().parseString( + ['/**', ' * @example First example', ' * Content 1.', ' * @example', ' * Content 2.', ' */'].join('\n') + ); + const exampleBlocks: DocBlock[] = []; + for (const block of parserContext.docComment.customBlocks) { + if (block.blockTag.tagNameWithUpperCase === '@EXAMPLE') { + exampleBlocks.push(block); + } + } + + expect(exampleBlocks).toHaveLength(2); + + expect(getPlainText(exampleBlocks[0].title!)).toEqual('First example'); + expect(getPlainText(exampleBlocks[0].body)).toContain('Content 1.'); + + expect(exampleBlocks[1].title).toBeUndefined(); + expect(getPlainText(exampleBlocks[1].body)).toContain('Content 2.'); +}); + +// Policy: the title is "the first paragraph up to its first line break". TSDoc inline tags are +// permitted to span multiple lines (see the multi-line "{@link}" fixtures in NodeParserLinkTag.test.ts), +// and the newlines inside a tag are absorbed as the tag's own spacing rather than emitted as paragraph +// SoftBreaks. Therefore an inline tag that opens on the tag line but closes on a later line is a single +// node with no intervening SoftBreak, so the title legitimately extends across those lines up to where +// the tag closes. This matches the author's intent of writing one continuous tag, so we treat it as +// title content rather than truncating the tag mid-way. +test('title with an inline tag that spans multiple lines', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example Using {@link', ' * Foo} directly', ' * Body text.', ' */'].join('\n') + ); + + expect(getChildKinds(exampleBlock.title!)).toEqual([ + DocNodeKind.PlainText, + DocNodeKind.LinkTag, + DocNodeKind.PlainText + ]); + expect(getPlainText(exampleBlock.title!)).toEqual('Using directly'); + + // The body begins only after the tag closes and the first paragraph-level line break is reached. + expect(getPlainText(exampleBlock.body)).toEqual('Body text.'); +}); diff --git a/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap index 5d28f2d4..c7bc4b36 100644 --- a/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap +++ b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap @@ -16,7 +16,7 @@ Object { "kind": "Section", }, Object { - "kind": "ExampleBlock", + "kind": "Block", "nodes": Array [ Object { "kind": "BlockTag", @@ -87,7 +87,7 @@ Object { "kind": "Section", }, Object { - "kind": "ExampleBlock", + "kind": "Block", "nodes": Array [ Object { "kind": "BlockTag", @@ -98,20 +98,21 @@ Object { }, ], }, - Object { - "kind": "Excerpt: Spacing", - "nodeExcerpt": " ", - }, - Object { - "kind": "Excerpt: ExampleBlock_Title", - "nodeExcerpt": "Adding two numbers", - }, Object { "kind": "Section", "nodes": Array [ Object { "kind": "Paragraph", "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " Adding two numbers", + }, + ], + }, Object { "kind": "SoftBreak", "nodes": Array [ @@ -168,7 +169,7 @@ Object { "kind": "Section", }, Object { - "kind": "ExampleBlock", + "kind": "Block", "nodes": Array [ Object { "kind": "BlockTag", @@ -179,20 +180,21 @@ Object { }, ], }, - Object { - "kind": "Excerpt: Spacing", - "nodeExcerpt": " ", - }, - Object { - "kind": "Excerpt: ExampleBlock_Title", - "nodeExcerpt": "Basic usage", - }, Object { "kind": "Section", "nodes": Array [ Object { "kind": "Paragraph", "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " Basic usage", + }, + ], + }, Object { "kind": "SoftBreak", "nodes": Array [ @@ -264,7 +266,7 @@ Object { "kind": "Section", }, Object { - "kind": "ExampleBlock", + "kind": "Block", "nodes": Array [ Object { "kind": "BlockTag", @@ -275,20 +277,21 @@ Object { }, ], }, - Object { - "kind": "Excerpt: Spacing", - "nodeExcerpt": " ", - }, - Object { - "kind": "Excerpt: ExampleBlock_Title", - "nodeExcerpt": "First example", - }, Object { "kind": "Section", "nodes": Array [ Object { "kind": "Paragraph", "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " First example", + }, + ], + }, Object { "kind": "SoftBreak", "nodes": Array [ @@ -323,7 +326,7 @@ Object { ], }, Object { - "kind": "ExampleBlock", + "kind": "Block", "nodes": Array [ Object { "kind": "BlockTag", @@ -394,7 +397,7 @@ Object { "kind": "Section", }, Object { - "kind": "ExampleBlock", + "kind": "Block", "nodes": Array [ Object { "kind": "BlockTag", @@ -405,20 +408,21 @@ Object { }, ], }, - Object { - "kind": "Excerpt: Spacing", - "nodeExcerpt": " ", - }, - Object { - "kind": "Excerpt: ExampleBlock_Title", - "nodeExcerpt": "Trimmed title", - }, Object { "kind": "Section", "nodes": Array [ Object { "kind": "Paragraph", "nodes": Array [ + Object { + "kind": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " Trimmed title", + }, + ], + }, Object { "kind": "SoftBreak", "nodes": Array [ From 66f2b8a34d24c04ae900b947453eb1c363c060a9 Mon Sep 17 00:00:00 2001 From: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:58:16 +0000 Subject: [PATCH 4/5] docs: Update comment --- tsdoc/src/nodes/DocBlock.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsdoc/src/nodes/DocBlock.ts b/tsdoc/src/nodes/DocBlock.ts index b50f98ad..7c1f5598 100644 --- a/tsdoc/src/nodes/DocBlock.ts +++ b/tsdoc/src/nodes/DocBlock.ts @@ -68,8 +68,8 @@ export class DocBlock extends DocNode { * re-wrapped in a synthesized {@link DocParagraph} with surrounding spaces trimmed. * * The title supports the same inline content as any paragraph (for example `{@link}` tags or code - * spans). It is `undefined` when the block has no text on the tag line (for example when the content - * begins on the next line), which a documentation tool may use to fall back to numeric indexing. + * spans). It is `undefined` when the block has no non-whitespace text on the tag line (for example when + * the content begins on the next line), which a documentation tool may use to fall back to numeric indexing. * * Because TSDoc inline tags may span multiple lines, an inline tag that opens on the tag line but * closes on a later line is a single node with no intervening line break, so the title extends across From 7bbc4364c646192a161e56cf22ce70de0946a51b Mon Sep 17 00:00:00 2001 From: Joshua Smithrud <54606601+Josmithr@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:09:51 +0000 Subject: [PATCH 5/5] refactor: Rename properties --- .../example-block-title_2026-07-29-00-00.json | 2 +- tsdoc/etc/tsdoc.api.md | 4 +- tsdoc/src/emitters/TSDocEmitter.ts | 8 +-- tsdoc/src/nodes/DocBlock.ts | 66 ++++++++++--------- .../parser/__tests__/DocBlockTitle.test.ts | 56 ++++++++-------- 5 files changed, 71 insertions(+), 65 deletions(-) diff --git a/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json b/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json index ac235fee..3935c9c8 100644 --- a/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json +++ b/common/changes/@microsoft/tsdoc/example-block-title_2026-07-29-00-00.json @@ -2,7 +2,7 @@ "changes": [ { "packageName": "@microsoft/tsdoc", - "comment": "Fix round-trip emission of `@example` block titles so the title text is preserved on the tag line, and expose the tag-line title via new `DocBlock.title` and `DocBlock.body` accessors", + "comment": "Fix round-trip emission of `@example` block titles so the title text is preserved on the tag line, and expose the text on a block's tag line via new `DocBlock.tagLineContent` and `DocBlock.bodyContent` accessors", "type": "minor" } ], diff --git a/tsdoc/etc/tsdoc.api.md b/tsdoc/etc/tsdoc.api.md index 109bb3fd..df6dfe5f 100644 --- a/tsdoc/etc/tsdoc.api.md +++ b/tsdoc/etc/tsdoc.api.md @@ -9,13 +9,13 @@ export class DocBlock extends DocNode { // @internal constructor(parameters: IDocBlockParameters | IDocBlockParsedParameters); get blockTag(): DocBlockTag; - get body(): DocSection; + get bodyContent(): DocSection; get content(): DocSection; // @override (undocumented) get kind(): DocNodeKind | string; // @override (undocumented) protected onGetChildNodes(): ReadonlyArray; - get title(): DocParagraph | undefined; + get tagLineContent(): DocParagraph | undefined; } // @public diff --git a/tsdoc/src/emitters/TSDocEmitter.ts b/tsdoc/src/emitters/TSDocEmitter.ts index b69e1b38..50a4a2ea 100644 --- a/tsdoc/src/emitters/TSDocEmitter.ts +++ b/tsdoc/src/emitters/TSDocEmitter.ts @@ -105,11 +105,11 @@ export class TSDocEmitter { if ( docBlock.blockTag.tagNameWithUpperCase === StandardTags.returns.tagNameWithUpperCase || docBlock.blockTag.tagNameWithUpperCase === StandardTags.defaultValue.tagNameWithUpperCase || - // An "@example" title is the text on the tag line; attach it to the tag line like "@returns". - // Guard on the title actually being present so that an untitled "@example" is not emitted with - // a trailing space. + // An "@example" title is the text on its tag line; attach it to the tag line like "@returns". + // Guard on the tag line content actually being present so that an untitled "@example" is not + // emitted with a trailing space. (docBlock.blockTag.tagNameWithUpperCase === StandardTags.example.tagNameWithUpperCase && - docBlock.title !== undefined) + docBlock.tagLineContent !== undefined) ) { this._writeContent(' '); this._hangingParagraph = true; diff --git a/tsdoc/src/nodes/DocBlock.ts b/tsdoc/src/nodes/DocBlock.ts index 7c1f5598..f1eb0ef7 100644 --- a/tsdoc/src/nodes/DocBlock.ts +++ b/tsdoc/src/nodes/DocBlock.ts @@ -59,25 +59,27 @@ export class DocBlock extends DocNode { } /** - * The block's "title", i.e. the rich text that appears on the same line as the block tag. + * The rich text that appears on the same line as the block tag, or `undefined` if the tag line has + * no content. * * @remarks - * Per the TSDoc specification, the text that appears on the same line as certain block tags (such as - * `@example`) is interpreted as a title. This accessor is a derived view over {@link DocBlock.content}: - * it returns the leading nodes of the first paragraph, up to (but not including) the first line break, - * re-wrapped in a synthesized {@link DocParagraph} with surrounding spaces trimmed. + * This is a derived view over {@link DocBlock.content}: it returns the leading nodes of the first + * paragraph, up to (but not including) the first line break, re-wrapped in a synthesized + * {@link DocParagraph} with surrounding spaces trimmed. It is `undefined` when the tag line has no + * non-whitespace content (for example when the block's content begins on the next line). * - * The title supports the same inline content as any paragraph (for example `{@link}` tags or code - * spans). It is `undefined` when the block has no non-whitespace text on the tag line (for example when - * the content begins on the next line), which a documentation tool may use to fall back to numeric indexing. + * The tag line content supports the same inline content as any paragraph (for example `{@link}` + * tags or code spans). Because TSDoc inline tags may span multiple lines, an inline tag that opens + * on the tag line but closes on a later line is a single node with no intervening line break, so the + * tag line content extends across those lines up to where the tag closes. * - * Because TSDoc inline tags may span multiple lines, an inline tag that opens on the tag line but - * closes on a later line is a single node with no intervening line break, so the title extends across - * those lines up to where the tag closes. + * Individual tags assign their own meaning to this content. For example, an `@example` block + * interprets its tag line content as the title of the example; a documentation tool may fall back to + * numeric indexing when it is `undefined`. * * The underlying nodes are shared with {@link DocBlock.content}; this view does not modify the block. */ - public get title(): DocParagraph | undefined { + public get tagLineContent(): DocParagraph | undefined { const contentNodes: ReadonlyArray = this._content.nodes; if (contentNodes.length === 0) { return undefined; @@ -90,51 +92,54 @@ export class DocBlock extends DocNode { const paragraphNodes: ReadonlyArray = (firstNode as DocParagraph).nodes; if (paragraphNodes.length === 0 || paragraphNodes[0].kind === DocNodeKind.SoftBreak) { - // The tag line has no text (the content begins with a line break), so there is no title. + // The block's content begins with a line break, so the tag line has no content. return undefined; } - const titleNodes: DocNode[] = []; + const tagLineNodes: DocNode[] = []; for (const node of paragraphNodes) { if (node.kind === DocNodeKind.SoftBreak) { break; } - titleNodes.push(node); + tagLineNodes.push(node); } - const titleParagraph: DocParagraph = new DocParagraph({ configuration: this.configuration }, titleNodes); - const trimmedTitle: DocParagraph = DocNodeTransforms.trimSpacesInParagraph(titleParagraph); + const tagLineParagraph: DocParagraph = new DocParagraph( + { configuration: this.configuration }, + tagLineNodes + ); + const trimmedContent: DocParagraph = DocNodeTransforms.trimSpacesInParagraph(tagLineParagraph); - // A tag line containing only whitespace is not a title. - if (trimmedTitle.nodes.length === 0) { + // A tag line containing only whitespace has no content. + if (trimmedContent.nodes.length === 0) { return undefined; } - return trimmedTitle; + return trimmedContent; } /** - * The block's content excluding its {@link DocBlock.title}. + * The block's {@link DocBlock.content} excluding its {@link DocBlock.tagLineContent}. * * @remarks - * This accessor is a derived view over {@link DocBlock.content}: when a {@link DocBlock.title} is present, - * the remainder of the first paragraph (the nodes after the first line break) is re-wrapped in a - * synthesized {@link DocParagraph}, followed by the remaining content nodes. When there is no title, this - * returns the full content. + * This is a derived view over {@link DocBlock.content}: when {@link DocBlock.tagLineContent} is + * present, the remainder of the first paragraph (the nodes after the first line break) is re-wrapped + * in a synthesized {@link DocParagraph}, followed by the remaining content nodes. When there is no + * tag line content, this returns the full content. * * The underlying nodes are shared with {@link DocBlock.content}; this view does not modify the block. */ - public get body(): DocSection { + public get bodyContent(): DocSection { const bodySection: DocSection = new DocSection({ configuration: this.configuration }); const contentNodes: ReadonlyArray = this._content.nodes; - if (this.title === undefined) { + if (this.tagLineContent === undefined) { bodySection.appendNodes(contentNodes); return bodySection; } - // The title consumed the leading portion of the first paragraph; recover the remainder that follows - // its first line break and re-wrap it in a synthesized paragraph. + // The tag line content consumed the leading portion of the first paragraph; recover the remainder + // that follows its first line break and re-wrap it in a synthesized paragraph. const paragraphNodes: ReadonlyArray = (contentNodes[0] as DocParagraph).nodes; let softBreakIndex: number = -1; for (let i: number = 0; i < paragraphNodes.length; ++i) { @@ -144,7 +149,8 @@ export class DocBlock extends DocNode { } } if (softBreakIndex >= 0) { - // Skip the line breaks that separated the title from the body before re-wrapping the remainder. + // Skip the line breaks that separated the tag line content from the body before re-wrapping the + // remainder. let remainderStart: number = softBreakIndex + 1; while ( remainderStart < paragraphNodes.length && diff --git a/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts b/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts index 6877df05..839e9e55 100644 --- a/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts +++ b/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts @@ -45,11 +45,11 @@ test('title above a code sample', () => { ['/**', ' * @example Adding two numbers', ' * ```ts', ' * add(1, 2);', ' * ```', ' */'].join('\n') ); - expect(exampleBlock.title).toBeDefined(); - expect(getPlainText(exampleBlock.title!)).toEqual('Adding two numbers'); + expect(exampleBlock.tagLineContent).toBeDefined(); + expect(getPlainText(exampleBlock.tagLineContent!)).toEqual('Adding two numbers'); // The body is everything after the title; here that is the fenced code sample. - expect(getChildKinds(exampleBlock.body)).toEqual([DocNodeKind.FencedCode]); + expect(getChildKinds(exampleBlock.bodyContent)).toEqual([DocNodeKind.FencedCode]); }); // An example with the whole content on the tag line. @@ -58,12 +58,12 @@ test('title only, with no body', () => { ['/**', ' * The CPU architecture.', ' * @example `"AMD64"`', ' */'].join('\n') ); - expect(exampleBlock.title).toBeDefined(); + expect(exampleBlock.tagLineContent).toBeDefined(); // The code span in the title is preserved as a DocCodeSpan node. - expect(getChildKinds(exampleBlock.title!)).toEqual([DocNodeKind.CodeSpan]); + expect(getChildKinds(exampleBlock.tagLineContent!)).toEqual([DocNodeKind.CodeSpan]); // The body has no renderable content. - expect(exampleBlock.body.nodes).toHaveLength(0); + expect(exampleBlock.bodyContent.nodes).toHaveLength(0); }); // An example with markup in the title. @@ -72,15 +72,15 @@ test('title containing an inline tag', () => { ['/**', ' * @example Using {@link add} on negative numbers', ' * Body text.', ' */'].join('\n') ); - expect(exampleBlock.title).toBeDefined(); + expect(exampleBlock.tagLineContent).toBeDefined(); // The "{@link add}" markup is preserved as a real DocLinkTag rather than flattened to literal text. - expect(getChildKinds(exampleBlock.title!)).toEqual([ + expect(getChildKinds(exampleBlock.tagLineContent!)).toEqual([ DocNodeKind.PlainText, DocNodeKind.LinkTag, DocNodeKind.PlainText ]); - expect(getPlainText(exampleBlock.body)).toContain('Body text.'); + expect(getPlainText(exampleBlock.bodyContent)).toContain('Body text.'); }); // An example with content beginning on the next line has no title. @@ -89,8 +89,8 @@ test('no title when content begins on the next line', () => { ['/**', ' * @example', ' * Some example content.', ' */'].join('\n') ); - expect(exampleBlock.title).toBeUndefined(); - expect(getPlainText(exampleBlock.body)).toContain('Some example content.'); + expect(exampleBlock.tagLineContent).toBeUndefined(); + expect(getPlainText(exampleBlock.bodyContent)).toContain('Some example content.'); }); // A tag line containing only whitespace is not a title. @@ -99,8 +99,8 @@ test('no title when the tag line is only whitespace', () => { ['/**', ' * @example ', ' * Some example content.', ' */'].join('\n') ); - expect(exampleBlock.title).toBeUndefined(); - expect(getPlainText(exampleBlock.body)).toContain('Some example content.'); + expect(exampleBlock.tagLineContent).toBeUndefined(); + expect(getPlainText(exampleBlock.bodyContent)).toContain('Some example content.'); }); // The title text is trimmed of surrounding whitespace. @@ -109,8 +109,8 @@ test('title is trimmed of surrounding whitespace', () => { ['/**', ' * @example Trimmed title ', ' * Content.', ' */'].join('\n') ); - expect(exampleBlock.title).toBeDefined(); - expect(getPlainText(exampleBlock.title!)).toEqual('Trimmed title'); + expect(exampleBlock.tagLineContent).toBeDefined(); + expect(getPlainText(exampleBlock.tagLineContent!)).toEqual('Trimmed title'); }); // A title with body prose on the immediately following line (no blank line). @@ -119,9 +119,9 @@ test('body prose on the next line is re-wrapped into a paragraph', () => { ['/**', ' * @example A title', ' * Body prose here.', ' */'].join('\n') ); - expect(getPlainText(exampleBlock.title!)).toEqual('A title'); - expect(getChildKinds(exampleBlock.body)).toEqual([DocNodeKind.Paragraph]); - expect(getPlainText(exampleBlock.body)).toEqual('Body prose here.'); + expect(getPlainText(exampleBlock.tagLineContent!)).toEqual('A title'); + expect(getChildKinds(exampleBlock.bodyContent)).toEqual([DocNodeKind.Paragraph]); + expect(getPlainText(exampleBlock.bodyContent)).toEqual('Body prose here.'); }); // A title separated from the body by a blank line. @@ -130,9 +130,9 @@ test('body separated from the title by a blank line', () => { ['/**', ' * @example A title', ' *', ' * Body paragraph.', ' */'].join('\n') ); - expect(getPlainText(exampleBlock.title!)).toEqual('A title'); - expect(getChildKinds(exampleBlock.body)).toEqual([DocNodeKind.Paragraph]); - expect(getPlainText(exampleBlock.body)).toEqual('Body paragraph.'); + expect(getPlainText(exampleBlock.tagLineContent!)).toEqual('A title'); + expect(getChildKinds(exampleBlock.bodyContent)).toEqual([DocNodeKind.Paragraph]); + expect(getPlainText(exampleBlock.bodyContent)).toEqual('Body paragraph.'); }); // Multiple @example blocks are parsed independently. @@ -149,11 +149,11 @@ test('multiple example blocks each expose their own title and body', () => { expect(exampleBlocks).toHaveLength(2); - expect(getPlainText(exampleBlocks[0].title!)).toEqual('First example'); - expect(getPlainText(exampleBlocks[0].body)).toContain('Content 1.'); + expect(getPlainText(exampleBlocks[0].tagLineContent!)).toEqual('First example'); + expect(getPlainText(exampleBlocks[0].bodyContent)).toContain('Content 1.'); - expect(exampleBlocks[1].title).toBeUndefined(); - expect(getPlainText(exampleBlocks[1].body)).toContain('Content 2.'); + expect(exampleBlocks[1].tagLineContent).toBeUndefined(); + expect(getPlainText(exampleBlocks[1].bodyContent)).toContain('Content 2.'); }); // Policy: the title is "the first paragraph up to its first line break". TSDoc inline tags are @@ -168,13 +168,13 @@ test('title with an inline tag that spans multiple lines', () => { ['/**', ' * @example Using {@link', ' * Foo} directly', ' * Body text.', ' */'].join('\n') ); - expect(getChildKinds(exampleBlock.title!)).toEqual([ + expect(getChildKinds(exampleBlock.tagLineContent!)).toEqual([ DocNodeKind.PlainText, DocNodeKind.LinkTag, DocNodeKind.PlainText ]); - expect(getPlainText(exampleBlock.title!)).toEqual('Using directly'); + expect(getPlainText(exampleBlock.tagLineContent!)).toEqual('Using directly'); // The body begins only after the tag closes and the first paragraph-level line break is reached. - expect(getPlainText(exampleBlock.body)).toEqual('Body text.'); + expect(getPlainText(exampleBlock.bodyContent)).toEqual('Body text.'); });