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..3935c9c8 --- /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": "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" + } + ], + "packageName": "@microsoft/tsdoc" +} diff --git a/tsdoc/etc/tsdoc.api.md b/tsdoc/etc/tsdoc.api.md index 6c773003..df6dfe5f 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 bodyContent(): DocSection; get content(): DocSection; // @override (undocumented) get kind(): DocNodeKind | string; // @override (undocumented) protected onGetChildNodes(): ReadonlyArray; + get tagLineContent(): DocParagraph | undefined; } // @public diff --git a/tsdoc/src/emitters/TSDocEmitter.ts b/tsdoc/src/emitters/TSDocEmitter.ts index d812e4d1..50a4a2ea 100644 --- a/tsdoc/src/emitters/TSDocEmitter.ts +++ b/tsdoc/src/emitters/TSDocEmitter.ts @@ -104,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 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.tagLineContent !== undefined) ) { this._writeContent(' '); this._hangingParagraph = true; diff --git a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts index 055e9315..818a6e38 100644 --- a/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts +++ b/tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts @@ -124,6 +124,152 @@ Object { `); }); +// An example containing a title above a code sample. +test('02b Round-trip @example title above a code sample', () => { + const input: string = ` +/** + * @example Adding two numbers + * \`\`\`ts + * add(1, 2); + * \`\`\` + */ +`; + + expect(createSnapshot(input)).toMatchInlineSnapshot(` +Object { + "errors": Array [], + "output": " +/** + * @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. + */ +", +} +`); +}); + test('03 TSDocEmitter.renderHtmlTag()', () => { const configuration: TSDocConfiguration = new TSDocConfiguration(); const htmlTag: DocHtmlStartTag = new DocHtmlStartTag({ diff --git a/tsdoc/src/nodes/DocBlock.ts b/tsdoc/src/nodes/DocBlock.ts index f701cabc..f1eb0ef7 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,116 @@ export class DocBlock extends DocNode { return this._content; } + /** + * The rich text that appears on the same line as the block tag, or `undefined` if the tag line has + * no content. + * + * @remarks + * 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 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. + * + * 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 tagLineContent(): 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 block's content begins with a line break, so the tag line has no content. + return undefined; + } + + const tagLineNodes: DocNode[] = []; + for (const node of paragraphNodes) { + if (node.kind === DocNodeKind.SoftBreak) { + break; + } + tagLineNodes.push(node); + } + + const tagLineParagraph: DocParagraph = new DocParagraph( + { configuration: this.configuration }, + tagLineNodes + ); + const trimmedContent: DocParagraph = DocNodeTransforms.trimSpacesInParagraph(tagLineParagraph); + + // A tag line containing only whitespace has no content. + if (trimmedContent.nodes.length === 0) { + return undefined; + } + + return trimmedContent; + } + + /** + * The block's {@link DocBlock.content} excluding its {@link DocBlock.tagLineContent}. + * + * @remarks + * 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 bodyContent(): DocSection { + const bodySection: DocSection = new DocSection({ configuration: this.configuration }); + const contentNodes: ReadonlyArray = this._content.nodes; + + if (this.tagLineContent === undefined) { + bodySection.appendNodes(contentNodes); + return bodySection; + } + + // 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) { + if (paragraphNodes[i].kind === DocNodeKind.SoftBreak) { + softBreakIndex = i; + break; + } + } + if (softBreakIndex >= 0) { + // 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 && + 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/parser/__tests__/DocBlockTitle.test.ts b/tsdoc/src/parser/__tests__/DocBlockTitle.test.ts new file mode 100644 index 00000000..839e9e55 --- /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.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.bodyContent)).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.tagLineContent).toBeDefined(); + // The code span in the title is preserved as a DocCodeSpan node. + expect(getChildKinds(exampleBlock.tagLineContent!)).toEqual([DocNodeKind.CodeSpan]); + + // The body has no renderable content. + expect(exampleBlock.bodyContent.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.tagLineContent).toBeDefined(); + // The "{@link add}" markup is preserved as a real DocLinkTag rather than flattened to literal text. + expect(getChildKinds(exampleBlock.tagLineContent!)).toEqual([ + DocNodeKind.PlainText, + DocNodeKind.LinkTag, + DocNodeKind.PlainText + ]); + + expect(getPlainText(exampleBlock.bodyContent)).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.tagLineContent).toBeUndefined(); + expect(getPlainText(exampleBlock.bodyContent)).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.tagLineContent).toBeUndefined(); + expect(getPlainText(exampleBlock.bodyContent)).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.tagLineContent).toBeDefined(); + expect(getPlainText(exampleBlock.tagLineContent!)).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.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. +test('body separated from the title by a blank line', () => { + const exampleBlock: DocBlock = parseExampleBlock( + ['/**', ' * @example A title', ' *', ' * Body paragraph.', ' */'].join('\n') + ); + + 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. +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].tagLineContent!)).toEqual('First example'); + expect(getPlainText(exampleBlocks[0].bodyContent)).toContain('Content 1.'); + + 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 +// 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.tagLineContent!)).toEqual([ + DocNodeKind.PlainText, + DocNodeKind.LinkTag, + DocNodeKind.PlainText + ]); + 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.bodyContent)).toEqual('Body text.'); +}); 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..c7bc4b36 --- /dev/null +++ b/tsdoc/src/parser/__tests__/__snapshots__/NodeParserExampleBlock.test.ts.snap @@ -0,0 +1,462 @@ +// 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": "Block", + "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": "Block", + "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": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " Adding two numbers", + }, + ], + }, + 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": "Block", + "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": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " Basic usage", + }, + ], + }, + 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": "Block", + "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": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " First example", + }, + ], + }, + 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": "Block", + "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": "Block", + "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": "PlainText", + "nodes": Array [ + Object { + "kind": "Excerpt: PlainText", + "nodeExcerpt": " Trimmed title", + }, + ], + }, + 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]", + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, +} +`;