docs(docs-infra): highlight & parse api descriptions. (#63536)

The prevent future regression of this, this commit introduces additional tests.

PR Close #63536
This commit is contained in:
Matthieu Riegler
2025-09-01 23:21:21 +02:00
committed by Miles Malerba
parent e63608d4d2
commit 31c689a5f4
10 changed files with 96 additions and 16 deletions
@@ -5,6 +5,10 @@ package(default_visibility = ["//adev/shared-docs/pipeline/api-gen:__subpackages
esbuild(
name = "bin",
entry_point = ":index.mts",
external = [
"jsdom",
"playwright-core",
],
format = "esm",
output = "bin.mjs",
platform = "node",
@@ -21,6 +21,10 @@ export function setCurrentSymbol(symbol: string): void {
currentSymbol = symbol;
}
export function getSymbols() {
return symbols;
}
export function getCurrentSymbol(): string | undefined {
return currentSymbol;
}
@@ -79,12 +79,14 @@ describe('markdown to html', () => {
// In the description
const descriptionItem = entry.querySelector('.docs-reference-description')!;
expect(descriptionItem.innerHTML).toContain('<a href="/api/core/afterRender">afterRender</a>');
expect(descriptionItem.innerHTML).toContain(
'<a href="/api/core/afterRender"><code>afterRender</code></a>',
);
// In the card
const cardItem = entry.querySelectorAll('.docs-reference-card-item')[1];
expect(cardItem.innerHTML).toContain(
'<a href="/api/core/AfterRenderPhase#MixedReadWrite">AfterRenderPhase.MixedReadWrite</a>',
'<a href="/api/core/AfterRenderPhase#MixedReadWrite"><code>AfterRenderPhase.MixedReadWrite</code></a>',
);
});
});
@@ -6,8 +6,10 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {initHighlighter} from '../../../../shared/shiki.mjs';
import {setHighlighterInstance} from '../../shiki/shiki.mjs';
import {setCurrentSymbol, setSymbols} from '../../symbol-context.mjs';
import {addHtmlAdditionalLinks} from '../../transforms/jsdoc-transforms.mjs';
import {addHtmlAdditionalLinks, addHtmlDescription} from '../../transforms/jsdoc-transforms.mjs';
// @ts-ignore This compiles fine, but Webstorm doesn't like the ESM import in a CJS context.
describe('jsdoc transforms', () => {
@@ -151,4 +153,40 @@ describe('jsdoc transforms', () => {
expect(entryFn).toThrowError(/Forbidden relative link: cli\/build ng build/);
});
it('should parse markdown in descriptions', async () => {
setHighlighterInstance(await initHighlighter());
setSymbols(
Object.fromEntries([
['Route', 'test'],
['Router', 'angular/router'],
['Router.someMethod', 'test'],
['Router.someMethodWithParenthesis', 'test'],
['FormGroup', 'test'],
['FormGroup.someMethod', 'test'],
]),
);
const entry = addHtmlDescription({
description: `
\`\`\`angular-ts
import { Router } from '@angular/router';
function setupRouter() {
const router = inject(Router);
}
\`\`\`
`,
moduleName: 'test',
});
// Should have some shiki variables (meaning the description was highlighted).
expect(entry.htmlDescription).toContain('--shiki');
// Having docs-code means that the description was parsed and formatted correctly (by the shared marked renderer)
expect(entry.htmlDescription).toContain('class="docs-code"');
expect(entry.htmlDescription).toContain('/api/angular/router/Router');
});
});
@@ -14,13 +14,17 @@ import {
CliCommandRenderable,
CliOptionRenderable,
} from '../entities/renderables.mjs';
import {parseMarkdown} from '../../../shared/marked/parse.mjs';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
/** Given an unprocessed CLI entry, get the fully renderable CLI entry. */
export function getCliRenderable(command: CliCommand): CliCommandRenderable {
return {
...command,
subcommands: command.subcommands?.map((sub) => getCliRenderable(sub)),
htmlDescription: marked.parse(command.longDescription ?? command.shortDescription) as string,
htmlDescription: parseMarkdown(command.longDescription ?? command.shortDescription, {
highlighter: getHighlighterInstance(),
}),
cards: getCliCardsRenderable(command),
argumentsLabel: getArgumentsLabel(command),
hasOptions: getOptions(command).length > 0,
@@ -6,7 +6,6 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {marked} from 'marked';
import {JsDocTagEntry} from '../entities.mjs';
import {getDeprecatedEntry, getTagSinceVersion} from '../entities/categorization.mjs';
@@ -26,7 +25,14 @@ import {
} from '../entities/traits.mjs';
import {addApiLinksToHtml} from './code-transforms.mjs';
import {getCurrentSymbol, getSymbolUrl, unknownSymbolMessage} from '../symbol-context.mjs';
import {
getCurrentSymbol,
getSymbols,
getSymbolUrl,
unknownSymbolMessage,
} from '../symbol-context.mjs';
import {parseMarkdown} from '../../../shared/marked/parse.mjs';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
const JS_DOC_USAGE_NOTE_TAGS: Set<string> = new Set(['remarks', 'usageNotes', 'example']);
export const JS_DOC_SEE_TAG = 'see';
@@ -99,7 +105,11 @@ export function addHtmlUsageNotes<T extends HasJsDocTags>(entry: T): T & HasHtml
/** Given a markdown JsDoc text, gets the rendered HTML. */
function getHtmlForJsDocText(text: string): string {
const parsed = marked.parse(convertLinks(wrapExampleHtmlElementsWithCode(text))) as string;
const mdToParse = convertLinks(wrapExampleHtmlElementsWithCode(text));
const parsed = parseMarkdown(mdToParse, {
apiEntries: getSymbols(),
highlighter: getHighlighterInstance(),
});
return addApiLinksToHtml(parsed);
}
@@ -63,7 +63,7 @@ export function formatCode(token: CodeToken, context: RendererContext): string {
`).firstElementChild!;
applyContainerAttributesAndClasses(containerEl, token);
processForApiLinks(containerEl, context.apiEntries);
processForApiLinks(containerEl, context.apiEntries ?? {});
return containerEl.outerHTML;
}
@@ -13,12 +13,12 @@ import {listRender} from './transformations/list.mjs';
import {imageRender} from './transformations/image.mjs';
import {textRender} from './transformations/text.mjs';
import {headingRender} from './transformations/heading.mjs';
import {codespanRender} from './transformations/code.mjs';
import {codeRender, codespanRender} from './transformations/code.mjs';
import {HighlighterGeneric} from 'shiki';
export interface RendererContext {
markdownFilePath: string;
apiEntries: Record<string, string>;
markdownFilePath?: string;
apiEntries?: Record<string, string>;
highlighter: HighlighterGeneric<any, any>;
}
@@ -40,4 +40,5 @@ export class AdevDocsRenderer extends Renderer {
override text = textRender;
override heading = headingRender;
override codespan = codespanRender;
override code = codeRender;
}
@@ -9,9 +9,10 @@
import {Tokens} from 'marked';
import {AdevDocsRenderer} from '../renderer.mjs';
import {getSymbolUrl} from '../../linking.mjs';
import {codeToHtml} from '../../shiki.mjs';
export function codespanRender(this: AdevDocsRenderer, token: Tokens.Codespan) {
const apiLink = getSymbolUrl(token.text, this.context.apiEntries);
const apiLink = getSymbolUrl(token.text, this.context.apiEntries ?? {});
if (apiLink) {
const htmlToken: Tokens.HTML = {
type: 'html',
@@ -31,3 +32,15 @@ export function codespanRender(this: AdevDocsRenderer, token: Tokens.Codespan) {
}
return this.defaultRenderer.codespan(token);
}
export function codeRender(this: AdevDocsRenderer, {text, lang}: Tokens.Code): string {
const highlightResult = codeToHtml(this.context.highlighter, text, lang)
// remove spaces/line-breaks between elements to not mess-up `pre` style
.replace(/>\s+</g, '><');
return `
<div class="docs-code" role="group">
${highlightResult}
</div>
`;
}
@@ -17,7 +17,7 @@ export function headingRender(this: AdevDocsRenderer, {depth, tokens}: Tokens.He
export function formatHeading(
{text, depth}: {text: string; depth: number},
markdownFilePath: string,
markdownFilePath?: string,
): string {
if (depth === 1) {
return `
@@ -55,14 +55,18 @@ export function formatHeading(
const GITHUB_EDIT_CONTENT_URL = 'https://github.com/angular/angular/edit/main';
/** Get the page title with edit button to modify the page source. */
export function getPageTitle(text: string, filePath: string): string {
export function getPageTitle(text: string, filePath?: string): string {
return `
<!-- Page title -->
<div class="docs-page-title">
<h1 tabindex="-1">${text}</h1>
<a class="docs-github-links" target="_blank" href="${GITHUB_EDIT_CONTENT_URL}/${filePath}" title="Edit this page" aria-label="Edit this page">
${
filePath
? `<a class="docs-github-links" target="_blank" href="${GITHUB_EDIT_CONTENT_URL}/${filePath}" title="Edit this page" aria-label="Edit this page">
<!-- Pencil -->
<docs-icon role="presentation">edit</docs-icon>
</a>
</a>`
: ''
}
</div>`;
}