docs(docs-infra): API doc content rendering fixes (#60116)

The PR introduces a few doc content rendering fixes:
- Fix highlighted section heading styles (regression from #59965).
- Convert JSDoc links within 'Usage Notes' sections to HTML and render them.
- Add IDs to doc content headings. This, by itself, makes these headings available in the page ToC.

PR Close #60116
This commit is contained in:
hawkgs 2025-02-26 17:27:37 +02:00 committed by Miles Malerba
parent ffb19e64f1
commit 2b114e784d
9 changed files with 52 additions and 37 deletions

View file

@ -8,6 +8,7 @@
import {Renderer, Tokens} from 'marked';
import {codeToHtml} from '../shiki/shiki';
import {SECTION_HEADING, SECTION_SUB_HEADING} from '../styling/css-classes';
/**
* Custom renderer for marked that will be used to transform markdown files to HTML
@ -55,21 +56,39 @@ export const renderer: Partial<Renderer> = {
<div class="docs-table docs-scroll-track-transparent">
<table>
<thead>
${this.tablerow({
text: header.map((cell) => this.tablecell(cell)).join(''),
})}
${this.tablerow({text: header.map((cell) => this.tablecell(cell)).join('')})}
</thead>
<tbody>
${rows
.map((row) =>
this.tablerow({
text: row.map((cell) => this.tablecell(cell)).join(''),
}),
)
.map((row) => this.tablerow({text: row.map((cell) => this.tablecell(cell)).join('')}))
.join('')}
</tbody>
</table>
</div>
`;
},
heading(this: Renderer, {text, depth, tokens}: Tokens.Heading) {
const id = text
.toLowerCase()
.replaceAll(' ', '-')
.replace(/[^a-z0-9-]/g, '');
// Since we have a code transformer `addApiLinksToHtml` which adds anchors
// to code blocks of known symbols, we add an additional `data-skip-anchor`
// attribute that prevents the transformation. This is needed since nested
// anchor tags are illegal and break the HTML.
const textRenderer = new Renderer();
textRenderer.codespan = ({text}) => `<code data-skip-anchor>${text}</code>`;
const parsedText = this.parser.parseInline(tokens, textRenderer);
// The template matches templates/section-heading.tsx
return `
<h${depth} id="${id}" class="${SECTION_HEADING} ${SECTION_SUB_HEADING}">
<a href="#${id}" aria-label="Link to ${text} section" tabIndex="-1">
${parsedText}
</a>
</h${depth}>
`;
},
};

View file

@ -27,3 +27,4 @@ export const HEADER_ENTRY_LABEL = 'docs-api-item-label';
export const SECTION_CONTAINER = 'docs-reference-section';
export const SECTION_HEADING = 'docs-reference-section-heading';
export const SECTION_SUB_HEADING = 'docs-reference-section-heading--sub';

View file

@ -476,10 +476,11 @@ function appendPrefixAndSuffix(entry: DocEntry, codeTocData: CodeTableOfContents
*/
export function addApiLinksToHtml(htmlString: string): string {
const result = htmlString.replace(
// This regex looks for span/code blocks not wrapped by an anchor block.
// This regex looks for span/code blocks not wrapped by an anchor block
// or the tag doesn't contain `data-skip-anchor` attribute.
// Their content are then replaced with a link if the symbol is known
// The captured content ==> vvvvvvvv
/(?<!<a[^>]*>)(<(?:(?:span)|(?:code))[^>]*>\s*)([^<]*?)(\s*<\/(?:span|code)>)/g,
// The captured content ==> vvvvvvvv
/(?<!<a[^>]*>)(<(?:(?:span)|(?:code))(?!\sdata-skip-anchor)[^>]*>\s*)([^<]*?)(\s*<\/(?:span|code)>)/g,
(type: string, span1: string, potentialSymbolName: string, span2: string) => {
let [symbol, subSymbol] = potentialSymbolName.split(/(?:#|\.)/) as [string, string?];

View file

@ -58,16 +58,10 @@ export function addHtmlDescription<T extends HasDescription & HasModuleName>(
const description = !!entry.description ? entry.description : jsDocDescription;
const shortTextMatch = description.match(firstParagraphRule);
const htmlDescription = getHtmlForJsDocText(description, entry).trim();
const shortHtmlDescription = getHtmlForJsDocText(
shortTextMatch ? shortTextMatch[0] : '',
entry,
).trim();
return {
...entry,
htmlDescription,
shortHtmlDescription,
};
const htmlDescription = getHtmlForJsDocText(description).trim();
const shortHtmlDescription = getHtmlForJsDocText(shortTextMatch ? shortTextMatch[0] : '').trim();
return {...entry, htmlDescription, shortHtmlDescription};
}
/**
@ -81,7 +75,7 @@ export function addHtmlJsDocTagComments<T extends HasJsDocTags & HasModuleName>(
...entry,
jsdocTags: entry.jsdocTags.map((tag) => ({
...tag,
htmlComment: getHtmlForJsDocText(tag.comment, entry),
htmlComment: getHtmlForJsDocText(tag.comment),
})),
};
}
@ -100,20 +94,16 @@ export function addHtmlUsageNotes<T extends HasJsDocTags>(entry: T): T & HasHtml
const usageNotesTag = entry.jsdocTags.find(
({name}) => name === JS_DOC_USAGE_NOTES_TAG || name === JS_DOC_REMARKS_TAG,
);
const htmlUsageNotes = usageNotesTag
? (marked.parse(wrapExampleHtmlElementsWithCode(usageNotesTag.comment)) as string)
: '';
const transformedHtml = addApiLinksToHtml(htmlUsageNotes);
const htmlUsageNotes = usageNotesTag ? getHtmlForJsDocText(usageNotesTag.comment) : '';
return {
...entry,
htmlUsageNotes: transformedHtml,
htmlUsageNotes,
};
}
/** Given a markdown JsDoc text, gets the rendered HTML. */
function getHtmlForJsDocText<T extends HasModuleName>(text: string, entry: T): string {
function getHtmlForJsDocText(text: string): string {
const parsed = marked.parse(convertLinks(wrapExampleHtmlElementsWithCode(text))) as string;
return addApiLinksToHtml(parsed);
}
@ -126,7 +116,7 @@ export function setEntryFlags<T extends HasJsDocTags & HasModuleName>(
...entry,
isDeprecated: isDeprecatedEntry(entry),
deprecationMessage: deprecationMessage
? getHtmlForJsDocText(deprecationMessage, entry)
? getHtmlForJsDocText(deprecationMessage)
: deprecationMessage,
isDeveloperPreview: isDeveloperPreview(entry),
isExperimental: isExperimental(entry),

View file

@ -66,7 +66,11 @@
.docs-reference-section-heading {
padding-block-start: 3rem;
a {
&--sub {
padding-block-start: 1rem;
}
& > a {
@include anchor.docs-anchor();
color: inherit;
}
@ -98,7 +102,7 @@
z-index: 0;
}
&.highlighted {
&.docs-highlighted-card {
box-shadow: 10px 4px 40px 0 rgba(0, 0, 0, 0.01);
&::before {

View file

@ -79,6 +79,7 @@
}
p > a,
p > em > a,
td > a,
div > a:not(.docs-card),
code > a,
@ -93,6 +94,7 @@
}
p > a,
p > em > a,
.docs-list a,
.docs-card a {
margin-block: 0;

View file

@ -14,7 +14,7 @@ import {DOCUMENT} from '@angular/common';
import {ReferenceScrollHandler} from '../services/reference-scroll-handler.service';
import {API_SECTION_CLASS_NAME} from '../constants/api-reference-prerender.constants';
const HIGHLIGHTED_CARD_CLASS = 'highlighted';
const HIGHLIGHTED_CARD_CLASS = 'docs-highlighted-card';
@Component({
selector: 'adev-reference-page',

View file

@ -188,7 +188,6 @@ export function optionsReducer<T extends Object>(dst: T, objs: T | T[]): T {
* A reference to an Angular application running on a page.
*
* @usageNotes
* {@a is-stable-examples}
* ### isStable examples and caveats
*
* Note two important points about `isStable`, demonstrated in the examples below:

View file

@ -36,7 +36,7 @@ import {NgAdapterInjector} from './util';
* {@link UpgradeModule#upgrading-an-angular-1-service Upgrading an AngularJS service} below.
* 4. Creation of an AngularJS service that wraps and exposes an Angular injectable
* so that it can be injected into an AngularJS context. See `downgradeInjectable`.
* 3. Bootstrapping of a hybrid Angular application which contains both of the frameworks
* 5. Bootstrapping of a hybrid Angular application which contains both of the frameworks
* coexisting in a single application.
*
* @usageNotes
@ -102,7 +102,7 @@ import {NgAdapterInjector} from './util';
*
* ### Examples
*
* Import the `UpgradeModule` into your top level {@link NgModule Angular `NgModule`}.
* Import the `UpgradeModule` into your top level Angular {@link NgModule NgModule}.
*
* {@example upgrade/static/ts/full/module.ts region='ng2-module'}
*
@ -116,7 +116,6 @@ import {NgAdapterInjector} from './util';
*
* {@example upgrade/static/ts/full/module.ts region='bootstrap-ng2'}
*
* {@a upgrading-an-angular-1-service}
* ### Upgrading an AngularJS service
*
* There is no specific API for upgrading an AngularJS service. Instead you should just follow the