docs(docs-infra): Handle additional description format

Ex: https://angular.dev/api/router/withExperimentalPlatformNavigation
This commit is contained in:
Matthieu Riegler 2026-01-20 22:16:53 +01:00 committed by Alon Mishne
parent b885851cbe
commit f0b1061791
8 changed files with 268 additions and 222 deletions

View file

@ -22,6 +22,10 @@ export interface HasJsDocTags {
jsdocTags: JsDocTagEntry[];
}
export interface MaybeJsDocTags {
jsdocTags?: JsDocTagEntry[];
}
export interface HasAdditionalLinks {
additionalLinks: LinkEntryRenderable[];
}

View file

@ -73,13 +73,14 @@ export function FunctionReference(entry: FunctionEntryRenderable) {
<DeprecationWarning entry={entry} />
<SectionApi entry={entry} />
<div className={REFERENCE_MEMBERS}>
{entry.signatures.map((s, i) =>
signatureCard(s.name, getFunctionMetadataRenderable(s, entry.moduleName, entry.repo), {
id: `${s.name}_${i}`,
printSignaturesAsHeader,
hideUsageNotes: true,
}),
)}
{entry.signatures.length > 1 &&
entry.signatures.map((s, i) =>
signatureCard(s.name, getFunctionMetadataRenderable(s, entry.moduleName, entry.repo), {
id: `${s.name}_${i}`,
printSignaturesAsHeader,
hideUsageNotes: true,
}),
)}
</div>
<SectionDescription entry={entry} />

View file

@ -8,12 +8,12 @@
import {readFile} from 'fs/promises';
import {JSDOM} from 'jsdom';
import {getRenderable} from '../processing.mjs';
import {renderEntry} from '../rendering.mjs';
import {setSymbols} from '../symbol-context.mjs';
import {resolve} from 'path';
import {initHighlighter} from '../../../shared/shiki.mjs';
import {getRenderable} from '../processing.mjs';
import {renderEntry} from '../rendering.mjs';
import {setHighlighterInstance} from '../shiki/shiki.mjs';
import {setSymbols} from '../symbol-context.mjs';
// Note: The tests will probably break if the schema of the api extraction changes.
// All entries in the fake-entries are extracted from Angular's api.
@ -69,7 +69,7 @@ describe('markdown to html', () => {
it('should render multiple {@link} blocks', () => {
const provideClientHydrationEntry = entries.get('provideClientHydration')!;
expect(provideClientHydrationEntry).toBeDefined();
const cardItem = provideClientHydrationEntry.querySelector('.docs-reference-card-item')!;
const cardItem = provideClientHydrationEntry.querySelector('.docs-api')!;
expect(cardItem.innerHTML).not.toContain('@link');
});

View file

@ -17,204 +17,207 @@ import {
// @ts-ignore This compiles fine, but Webstorm doesn't like the ESM import in a CJS context.
describe('jsdoc transforms', () => {
it('should transform links', () => {
setCurrentSymbol('Router');
setSymbols(
Object.fromEntries([
['Route', 'test'],
['Router', 'test'],
['Router.someMethod', 'test'],
['Router.someMethodWithParenthesis', 'test'],
['FormGroup', 'test'],
['FormGroup.someMethod', 'test'],
]),
);
describe('addHtmlAdditionalLinks', () => {
it('should transform links', () => {
setCurrentSymbol('Router');
setSymbols(
Object.fromEntries([
['Route', 'test'],
['Router', 'test'],
['Router.someMethod', 'test'],
['Router.someMethodWithParenthesis', 'test'],
['FormGroup', 'test'],
['FormGroup.someMethod', 'test'],
]),
);
const entry = addHtmlAdditionalLinks({
jsdocTags: [
{
name: 'see',
comment: '[Angular](https://angular.io)',
},
{
name: 'see',
comment: '[Angular](https://angular.io "Angular")',
},
{
name: 'see',
comment: '{@link Route}',
},
{
name: 'see',
comment: '{@link Route Something else}',
},
{
name: 'see',
comment: '{@link #someMethod}',
},
{
name: 'see',
comment: '{@link #someMethodWithParenthesis()}',
},
{
name: 'see',
comment: '{@link someMethod()}',
},
{
name: 'see',
comment: '{@link FormGroup.someMethod()}',
},
{
name: 'see',
comment: '{@link https://angular.dev/api/core/ApplicationRef}',
},
{
name: 'see',
comment: '{@link https://angular.dev}',
},
{
name: 'see',
comment: '{@link /cli/build ng build}',
},
{
name: 'see',
comment: '{@link /ecosystem/rxjs-interop/output-interop Output Interop}',
},
],
moduleName: 'test',
});
expect(entry.additionalLinks[0]).toEqual({
label: 'Angular',
url: 'https://angular.io',
title: undefined,
});
expect(entry.additionalLinks[1]).toEqual({
label: 'Angular',
url: 'https://angular.io',
title: 'Angular',
});
expect(entry.additionalLinks[2]).toEqual({
label: 'Route',
url: '/api/test/Route',
});
expect(entry.additionalLinks[3]).toEqual({
label: 'Something else',
url: '/api/test/Route',
});
expect(entry.additionalLinks[4]).toEqual({
label: 'someMethod',
url: '/api/test/Router#someMethod',
});
expect(entry.additionalLinks[5]).toEqual({
label: 'someMethodWithParenthesis()',
url: '/api/test/Router#someMethodWithParenthesis',
});
expect(entry.additionalLinks[6]).toEqual({
label: 'someMethod()',
url: '/api/test/Router#someMethod',
});
expect(entry.additionalLinks[7]).toEqual({
label: 'FormGroup.someMethod()',
url: '/api/test/FormGroup#someMethod',
});
expect(entry.additionalLinks[8]).toEqual({
label: 'ApplicationRef',
url: 'https://angular.dev/api/core/ApplicationRef',
});
expect(entry.additionalLinks[9]).toEqual({
label: 'angular.dev',
url: 'https://angular.dev',
});
expect(entry.additionalLinks[10]).toEqual({
label: 'ng build',
url: '/cli/build',
});
expect(entry.additionalLinks[11]).toEqual({
label: 'Output Interop',
url: '/ecosystem/rxjs-interop/output-interop',
});
});
it('should convert backticks to code tags in markdown links', () => {
const entry = addHtmlAdditionalLinks({
jsdocTags: [
{
name: 'see',
comment:
'[Host view using `ViewContainerRef.createComponent`](guide/components/programmatic-rendering#host-view-using-viewcontainerrefcreatecomponent)',
},
{
name: 'see',
comment:
'[Popup attached to `document.body` with `createComponent` + `hostElement`](guide/components/programmatic-rendering#popup-attached-to-documentbody-with-createcomponent--hostelement)',
},
{
name: 'see',
comment: '[Method with `backticks` in title](https://example.com "Title with `code`")',
},
],
moduleName: 'test',
});
expect(entry.additionalLinks[0]).toEqual({
label: 'Host view using <code>ViewContainerRef.createComponent</code>',
url: 'guide/components/programmatic-rendering#host-view-using-viewcontainerrefcreatecomponent',
title: undefined,
});
expect(entry.additionalLinks[1]).toEqual({
label:
'Popup attached to <code>document.body</code> with <code>createComponent</code> + <code>hostElement</code>',
url: 'guide/components/programmatic-rendering#popup-attached-to-documentbody-with-createcomponent--hostelement',
title: undefined,
});
expect(entry.additionalLinks[2]).toEqual({
label: 'Method with <code>backticks</code> in title',
url: 'https://example.com',
title: 'Title with `code`',
});
});
it('should throw on invalid relatie @link', () => {
const entryFn = () =>
addHtmlAdditionalLinks({
const entry = addHtmlAdditionalLinks({
jsdocTags: [
{
name: 'see',
comment: '{@link cli/build ng build}',
comment: '[Angular](https://angular.io)',
},
{
name: 'see',
comment: '[Angular](https://angular.io "Angular")',
},
{
name: 'see',
comment: '{@link Route}',
},
{
name: 'see',
comment: '{@link Route Something else}',
},
{
name: 'see',
comment: '{@link #someMethod}',
},
{
name: 'see',
comment: '{@link #someMethodWithParenthesis()}',
},
{
name: 'see',
comment: '{@link someMethod()}',
},
{
name: 'see',
comment: '{@link FormGroup.someMethod()}',
},
{
name: 'see',
comment: '{@link https://angular.dev/api/core/ApplicationRef}',
},
{
name: 'see',
comment: '{@link https://angular.dev}',
},
{
name: 'see',
comment: '{@link /cli/build ng build}',
},
{
name: 'see',
comment: '{@link /ecosystem/rxjs-interop/output-interop Output Interop}',
},
],
moduleName: 'test',
});
expect(entryFn).toThrowError(/Forbidden relative link: cli\/build ng build/);
expect(entry.additionalLinks[0]).toEqual({
label: 'Angular',
url: 'https://angular.io',
title: undefined,
});
expect(entry.additionalLinks[1]).toEqual({
label: 'Angular',
url: 'https://angular.io',
title: 'Angular',
});
expect(entry.additionalLinks[2]).toEqual({
label: 'Route',
url: '/api/test/Route',
});
expect(entry.additionalLinks[3]).toEqual({
label: 'Something else',
url: '/api/test/Route',
});
expect(entry.additionalLinks[4]).toEqual({
label: 'someMethod',
url: '/api/test/Router#someMethod',
});
expect(entry.additionalLinks[5]).toEqual({
label: 'someMethodWithParenthesis()',
url: '/api/test/Router#someMethodWithParenthesis',
});
expect(entry.additionalLinks[6]).toEqual({
label: 'someMethod()',
url: '/api/test/Router#someMethod',
});
expect(entry.additionalLinks[7]).toEqual({
label: 'FormGroup.someMethod()',
url: '/api/test/FormGroup#someMethod',
});
expect(entry.additionalLinks[8]).toEqual({
label: 'ApplicationRef',
url: 'https://angular.dev/api/core/ApplicationRef',
});
expect(entry.additionalLinks[9]).toEqual({
label: 'angular.dev',
url: 'https://angular.dev',
});
expect(entry.additionalLinks[10]).toEqual({
label: 'ng build',
url: '/cli/build',
});
expect(entry.additionalLinks[11]).toEqual({
label: 'Output Interop',
url: '/ecosystem/rxjs-interop/output-interop',
});
});
it('should convert backticks to code tags in markdown links', () => {
const entry = addHtmlAdditionalLinks({
jsdocTags: [
{
name: 'see',
comment:
'[Host view using `ViewContainerRef.createComponent`](guide/components/programmatic-rendering#host-view-using-viewcontainerrefcreatecomponent)',
},
{
name: 'see',
comment:
'[Popup attached to `document.body` with `createComponent` + `hostElement`](guide/components/programmatic-rendering#popup-attached-to-documentbody-with-createcomponent--hostelement)',
},
{
name: 'see',
comment: '[Method with `backticks` in title](https://example.com "Title with `code`")',
},
],
moduleName: 'test',
});
expect(entry.additionalLinks[0]).toEqual({
label: 'Host view using <code>ViewContainerRef.createComponent</code>',
url: 'guide/components/programmatic-rendering#host-view-using-viewcontainerrefcreatecomponent',
title: undefined,
});
expect(entry.additionalLinks[1]).toEqual({
label:
'Popup attached to <code>document.body</code> with <code>createComponent</code> + <code>hostElement</code>',
url: 'guide/components/programmatic-rendering#popup-attached-to-documentbody-with-createcomponent--hostelement',
title: undefined,
});
expect(entry.additionalLinks[2]).toEqual({
label: 'Method with <code>backticks</code> in title',
url: 'https://example.com',
title: 'Title with `code`',
});
});
it('should throw on invalid relatie @link', () => {
const entryFn = () =>
addHtmlAdditionalLinks({
jsdocTags: [
{
name: 'see',
comment: '{@link cli/build ng build}',
},
],
moduleName: 'test',
});
expect(entryFn).toThrowError(/Forbidden relative link: cli\/build ng build/);
});
});
it('should parse markdown in descriptions', async () => {
setHighlighterInstance(await initHighlighter());
describe('addHtmlDescription', () => {
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'],
]),
);
setSymbols(
Object.fromEntries([
['Route', 'test'],
['Router', 'angular/router'],
['Router.someMethod', 'test'],
['Router.someMethodWithParenthesis', 'test'],
['FormGroup', 'test'],
['FormGroup.someMethod', 'test'],
]),
);
const entry = addHtmlDescription({
description: `
const entry = addHtmlDescription({
description: `
\`\`\`angular-ts
import { Router } from '@angular/router';
@ -223,16 +226,36 @@ function setupRouter() {
}
\`\`\`
`,
moduleName: 'test',
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');
});
// Should have some shiki variables (meaning the description was highlighted).
expect(entry.htmlDescription).toContain('--shiki');
it('should transform entry with different description & description tag', () => {
const entry = addHtmlDescription({
'description':
"Enables interop with the browser's `Navigation` API for router navigations.",
'jsdocTags': [
{
'name': 'description',
'comment': 'This feature is _highly_ experimental ...',
},
],
'moduleName': 'platform-browser',
});
// 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');
expect(entry.htmlDescription).toBe(`<p>This feature is <em>highly</em> experimental ...</p>`);
expect(entry.shortHtmlDescription).toBe(
`<p>Enables interop with the browser's <code>Navigation</code> API for router navigations.</p>`,
);
});
});
it('should only mark as deprecated if all overloads are deprecated', () => {

View file

@ -29,7 +29,7 @@ export async function getFunctionRenderable(
moduleName: string,
repo: string,
): Promise<FunctionEntryRenderable> {
return setEntryFlags(
const a = setEntryFlags(
await addRenderableCodeToc(
addHtmlAdditionalLinks(
addHtmlUsageNotes(
@ -42,6 +42,11 @@ export async function getFunctionRenderable(
),
),
);
if (entry.name === 'withExperimentalPlatformNavigation') {
console.warn('**************', a);
}
return a;
}
export function getFunctionMetadataRenderable(

View file

@ -22,6 +22,7 @@ import {
HasModuleName,
HasRenderableJsDocTags,
HasStableFlag,
MaybeJsDocTags,
} from '../entities/traits.mjs';
import {parseMarkdown} from '../../../shared/marked/parse.mjs';
@ -43,7 +44,7 @@ const jsDoclinkRegex = /\{\s*@link\s+([^}]+)\s*\}/;
const jsDoclinkRegexGlobal = new RegExp(jsDoclinkRegex.source, 'g');
/** Given an entity with a description, gets the entity augmented with an `htmlDescription`. */
export function addHtmlDescription<T extends HasDescription & HasModuleName>(
export function addHtmlDescription<T extends HasDescription & HasModuleName & MaybeJsDocTags>(
entry: T,
): T & HasHtmlDescription {
const firstParagraphRule = /(.*?)(?:\n\n|$)/s;
@ -56,10 +57,17 @@ export function addHtmlDescription<T extends HasDescription & HasModuleName>(
?.comment ?? '';
}
const description = !!entry.description ? entry.description : jsDocDescription;
const shortTextMatch = description.match(firstParagraphRule);
let description = entry.description || jsDocDescription;
let shortDescription = description.match(firstParagraphRule)?.[0] ?? '';
// For the cases where the @description tag is after a short description
if (jsDocDescription && description !== jsDocDescription) {
shortDescription = entry.description;
description = jsDocDescription;
}
const htmlDescription = getHtmlForJsDocText(description).trim();
const shortHtmlDescription = getHtmlForJsDocText(shortTextMatch ? shortTextMatch[0] : '').trim();
const shortHtmlDescription = getHtmlForJsDocText(shortDescription).trim();
return {...entry, htmlDescription, shortHtmlDescription};
}
@ -137,6 +145,9 @@ function getHtmlAdditionalLinks<T extends HasJsDocTags>(entry: T): LinkEntryRend
.filter((tag) => tag.name === JS_DOC_SEE_TAG)
.map((tag) => tag.comment)
.map((comment) => {
// TODO: Throw when the comment is an absolute link.
// With TS 5.9 this is not possible as the ts api that extracts comments from tags strips the "http" part of links.
const markdownLinkMatch = comment.match(markdownLinkRule);
if (markdownLinkMatch) {

View file

@ -24,6 +24,8 @@ export function extractJsDocTags(node: ts.HasJSDoc): JsDocTagEntry[] {
return ts.getJSDocTags(escapedNode).map((t) => {
return {
name: t.tagName.getText(),
// In TS 5.9, ts.getTextOfJSDocComment still strips "http" from comments breaking any absolute links in @see blocks.
// eg: @see https://angular.dev
comment: unescapeAngularDecorators(ts.getTextOfJSDocComment(t.comment) ?? ''),
};
});

View file

@ -8,10 +8,10 @@
import {
HashLocationStrategy,
Location,
LOCATION_INITIALIZED,
LocationStrategy,
ViewportScroller,
Location,
ɵNavigationAdapterForLocation,
} from '@angular/common';
import {
@ -23,15 +23,15 @@ import {
inject,
InjectionToken,
Injector,
ɵIS_ENABLED_BLOCKING_INITIAL_NAVIGATION as IS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
makeEnvironmentProviders,
ɵperformanceMarkFeature as performanceMarkFeature,
provideAppInitializer,
provideEnvironmentInitializer,
Provider,
runInInjectionContext,
ɵperformanceMarkFeature as performanceMarkFeature,
ɵIS_ENABLED_BLOCKING_INITIAL_NAVIGATION as IS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
ɵpublishExternalGlobalUtil,
provideEnvironmentInitializer,
Type,
ɵpublishExternalGlobalUtil,
} from '@angular/core';
import {of, Subject} from 'rxjs';
@ -39,15 +39,18 @@ import {INPUT_BINDER, RoutedComponentInputBinder} from './directives/router_outl
import {Event, NavigationError, stringifyEvent} from './events';
import {RedirectCommand, Routes} from './models';
import {NAVIGATION_ERROR_HANDLER, NavigationTransitions} from './navigation_transition';
import {ROUTE_INJECTOR_CLEANUP, routeInjectorCleanup} from './route_injector_cleanup';
import {Router} from './router';
import {InMemoryScrollingOptions, ROUTER_CONFIGURATION, RouterConfigOptions} from './router_config';
import {ROUTES} from './router_config_loader';
import {PreloadingStrategy, RouterPreloader} from './router_preloader';
import {routeInjectorCleanup, ROUTE_INJECTOR_CLEANUP} from './route_injector_cleanup';
import {ROUTER_SCROLLER, RouterScroller} from './router_scroller';
import {getLoadedRoutes, getRouterInstance, navigateByUrl} from './router_devtools';
import {ActivatedRoute} from './router_state';
import {NavigationStateManager} from './statemanager/navigation_state_manager';
import {StateManager} from './statemanager/state_manager';
import {afterNextNavigation} from './utils/navigations';
import {
CREATE_VIEW_TRANSITION,
@ -55,9 +58,6 @@ import {
VIEW_TRANSITION_OPTIONS,
ViewTransitionsFeatureOptions,
} from './utils/view_transition';
import {getLoadedRoutes, getRouterInstance, navigateByUrl} from './router_devtools';
import {StateManager} from './statemanager/state_manager';
import {NavigationStateManager} from './statemanager/navigation_state_manager';
/**
* Sets up providers necessary to enable `Router` functionality for the application.
@ -282,9 +282,9 @@ export type ExperimentalPlatformNavigationFeature =
* });
* ```
*
* @see https://github.com/WICG/navigation-api?tab=readme-ov-file#problem-statement
* @see https://developer.chrome.com/docs/web-platform/navigation-api/
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
* @see [Navigation API on WICG](https://github.com/WICG/navigation-api?tab=readme-ov-file#problem-statement)
* @see [Navigation API on Chrome from developers](https://developer.chrome.com/docs/web-platform/navigation-api/)
* @see [Navigation API on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API)
*
* @experimental 21.1
* @returns A `RouterFeature` that enables the platform navigation.
@ -899,8 +899,8 @@ export function withComponentInputBinding(): ComponentInputBindingFeature {
* ```
*
* @returns A set of providers for use with `provideRouter`.
* @see https://developer.chrome.com/docs/web-platform/view-transitions/
* @see https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API
* @see [View Transitions on MDN](https://developer.chrome.com/docs/web-platform/view-transitions/)
* @see [View Transitions API on MDN](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API)
* @see [Route transition animations](guide/routing/route-transition-animations)
* @developerPreview 19.0
*/