Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four exhibits, and what I'm thinking each one should do.

Exhibit A: a title above a code sample.

/**
 * @example Adding two numbers
 * ```ts
 * add(1, 2);
 * ```
 */

Title is Adding two numbers. A documentation tool should typeset it as the heading for this example, in place of the generic Example 2 numbering. This is a clarification, not a change: tsdoc.org already says text on the tag line is the title. Nothing parses differently, we are just exposing what is already there.

Exhibit B: the whole example is on the tag line.

/**
 * The CPU architecture.
 * @example `"AMD64"`
 */

Read literally, the spec says `"AMD64"` is a title for an example with no body. That is clearly not what the author meant, and this idiom appears about a dozen times in rushstack alone (rush-lib/src/logic/Telemetry.ts, node-core-library/src/JsonSchema.ts, lockfile-explorer-web). Our call: parse it identically to Exhibit A, so no author has to change anything, and let the renderer notice the body is empty and render the line inline rather than as a heading. This is the one place we are adding to the spec, and we are adding a rendering convention rather than a parsing rule.

Exhibit C: markup in the title.

/**
 * @example Using {@link add} on negative numbers
 * ...
 */

The markup is markup, not literal text. {@link add} is a real DocLinkTag that API Extractor resolves and validates, and a code span in the same position is a DocCodeSpan. That is how it parses today and it must keep working. The spec is silent on the point, so we are filling a gap rather than changing anything.

This is the reason the title has to be a list of nodes rather than a string. A string title can only hold the literal characters Using {@link add} on negative numbers, which demotes the link to text, drops it out of API Extractor's reference validation, and leaves every renderer to either print the braces or re-parse the string itself. Same argument for a backslash escape on the title line.

Exhibit D: a modifier tag on the tag line.

/**
 * @example Adding two numbers @internal
 * ...
 */

@internal stays a modifier tag, exactly as it does today. The title is Adding two numbers and stops there, because the block itself ends at the second tag. This is not a new rule, it is a guarantee that the title feature does not quietly repeal an existing one. API Extractor derives every release tag from modifierTagSet, so getting this wrong changes whether an API is trimmed from a release build.

Deferred to a future revision. It would be nice to have a restricted grammar for titles, with a diagnostic when the title line contains something that cannot be a title. It would be nice to normalize or deprecate Exhibit B rather than living with a rendering convention. It would be nice to settle @throws, whose own page describes a next-line convention while the tag kinds page claims it behaves like @example. And it would be nice to have a general mechanism, such as a flag on TSDocTagDefinition, so that first-line-is-a-title is declared per tag rather than hardcoded in NodeParser. None of these should hold up this PR. All of them need a deprecation cycle and a spec pass that this change cannot carry.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Joshua Smithrud (@Josmithr) what do you think? Do you agree with these calls? Will they cause any migration trouble for your codebase?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all of those make sense to me. I'd noted the markup limitation in the PR description and suspected it might be something we wanted to tackle here. The other cases are not ones I considered, and your reasoning makes sense to me. I'll go ahead and make these updates (and clarify details in the documentation).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

"changes": [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bug being fixed, restated

Parse a comment containing @example Adding two numbers and emit it again, and the title moves off the tag line with a blank line inserted after it.

Input:

/**
 * @example Adding two numbers
 * ```ts
 * add(1, 2);
 * ```
 */

Emitted output:

/**
 * @example
 *
 * Adding two numbers
 * ```ts
 * add(1, 2);
 * ```
 *
 */

That is an emitter layout bug, not a parser bug. The text was never lost, it just came back in the wrong place. The fix for it is much smaller than the fix for "expose the title on the AST", and the two should not be entangled.

TSDocEmitter already handles this shape for @returns and @defaultValue at the DocNodeKind.Block case: write a space after the tag, set _hangingParagraph = true, and the following paragraph attaches to the tag line. Extending that to @example round-trips all four shapes byte for byte: title plus fence, title plus blank line plus prose, title plus prose on the next line, and title with no body.

One caveat if you do this. It needs a guard for the untitled case. Naively adding @example to that tag name list also changes untitled examples from @example / blank line / text to @example / text, with a trailing space, which churns every committed api.json containing an untitled @example and inherits a pre-existing wart from @returns. Condition the space and the hanging paragraph on the block actually having tag-line text.

Compatibility: keep the title in content, expose it as a view

This is the main change I want to ask for.

Problem. The PR moves the title out of DocBlock.content and into a sibling field. For Exhibit A, content goes from

Section
  Paragraph
    PlainText " Adding two numbers"
    SoftBreak
  FencedCode

to

Section
  Paragraph
    SoftBreak
  FencedCode

Every existing consumer renders content. Both of API Documenter's documenters do exactly that. So on release day, titles stop appearing in generated docs for every project that uses them, and every other downstream tool has the same problem until it is updated. The AST gains a property and the output loses text.

Solution. Leave content alone. Add derived views over it:

  • title: DocParagraph | undefined is the first paragraph's nodes up to its first SoftBreak, space-trimmed. It is undefined when that paragraph begins with a SoftBreak, which is exactly the untitled case.
  • body: DocSection is everything else, with the remainder of the first paragraph re-wrapped in a synthesized DocParagraph.

Exclude both from onGetChildNodes(), which keeps returning [blockTag, content]. Traversal, excerpt reconstruction, and emission stay untouched. DocParagraph's constructor already takes a childNodes array and DocNode has no parent pointers, so the views share nodes safely.

Put them on DocBlock, not on a new node type. The split is structural, so it does not need DocExampleBlock or DocNodeKind.ExampleBlock. Skipping the new kind avoids the two ways it breaks consumers: anyone with a default: throw on a node-kind switch now hits it, and anyone who already registered a custom node kind named ExampleBlock now throws at registerDocNodes. It also gives @throws and custom block tags the same accessors for free, so we are not setting up a DocThrowsBlock next quarter, and it keeps NodeParser free of per-tag branching.

Net effect: no parser change at all, one guarded emitter change, two accessors on DocBlock. That is a much smaller cut than the current PR and it delivers the same capability.

API Documenter consequences

The title is not useful until something renders it. Please plan a companion rushstack PR.

Two call sites to update:

  • MarkdownDocumenter._writeRemarksSection emits Example or Example 1 / Example 2 headings and then renders exampleBlock.content. The numbered heading is precisely the spec's stated fallback for an untitled example, so this is where title belongs.
  • YamlDocumenter does the same into yamlItem.example and wants the same treatment. Note its if (example) guard drops empty entries, so Exhibit B under the PR's current design would vanish from YAML output entirely.

Build tests that need attention:

  • build-tests/api-documenter-test/src/DocClass1.ts is the only fixture using @example and both instances are untitled. Add a titled one and an Exhibit B one. This will move the docComment string in build-tests/api-documenter-test/etc/api-documenter-test.api.json, which is emitter output, so that snapshot needs regenerating and the diff is worth reading closely.
  • build-tests/api-documenter-scenarios is the only place that commits generated Markdown (etc/inheritedMembers/markdown/*.md). It has no @example coverage at all. A new scenario there is the only build test that would actually catch a rendering regression, since api-documenter-test commits only api.json and api.md.
  • API Extractor itself needs no change. It filters blocks by tag name rather than node kind, DocCommentEnhancer._copyInheritedDocs does not touch custom blocks, and the api.json doc comment round-trip is text based. Worth confirming the api-extractor-scenarios snapshots come back clean.

@microsoft/tsdoc is pinned at ~0.16.0 in api-extractor, api-documenter, and api-extractor-model, so the bump moves all three together.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR has been updated. The follow-up fix in API-Documenter should be straightforward, and I am happy to tackle that.

{
"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"
}
2 changes: 2 additions & 0 deletions tsdoc/etc/tsdoc.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<DocNode | undefined>;
get tagLineContent(): DocParagraph | undefined;
}

// @public
Expand Down
7 changes: 6 additions & 1 deletion tsdoc/src/emitters/TSDocEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 146 additions & 0 deletions tsdoc/src/emitters/__tests__/TSDocEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
112 changes: 112 additions & 0 deletions tsdoc/src/nodes/DocBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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<DocNode> = this._content.nodes;
if (contentNodes.length === 0) {
return undefined;
}

const firstNode: DocNode = contentNodes[0];
if (firstNode.kind !== DocNodeKind.Paragraph) {
return undefined;
}

const paragraphNodes: ReadonlyArray<DocNode> = (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<DocNode> = 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<DocNode> = (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<DocNode> = 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<DocNode | undefined> {
return [this.blockTag, this._content];
Expand Down
Loading
Loading