Skip to content

Commit 01030d5

Browse files
JeanMecheAndrewKushnir
authored andcommitted
docs(docs-infra): Add support for cross-links on API pages (#57346)
PR Close #57346
1 parent ea3d376 commit 01030d5

File tree

16 files changed

+190
-59
lines changed

16 files changed

+190
-59
lines changed

adev/shared-docs/pipeline/api-gen/extraction/index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ function main() {
6161
return result.concat(JSON.parse(readFileSync(path, {encoding: 'utf8'})) as DocEntry[]);
6262
}, []);
6363

64-
const extractedEntries = program.getApiDocumentation(entryPointExecRootRelativePath);
64+
const apiDoc = program.getApiDocumentation(entryPointExecRootRelativePath);
65+
const extractedEntries = apiDoc.entries;
6566
const combinedEntries = extractedEntries.concat(extraEntries);
6667

6768
const normalized = moduleName.replace('@', '').replace(/[\/]/g, '_');
@@ -71,7 +72,14 @@ function main() {
7172
moduleName: moduleName,
7273
normalizedModuleName: normalized,
7374
entries: combinedEntries,
74-
} satisfies EntryCollection);
75+
symbols: [
76+
// Symbols referenced, originating from other packages
77+
...apiDoc.symbols.entries(),
78+
79+
// Exported symbols from the current package
80+
...apiDoc.entries.map((entry) => [entry.name, moduleName]),
81+
],
82+
} as EntryCollection);
7583

7684
writeFileSync(outputFilenameExecRootRelativePath, output, {encoding: 'utf8'});
7785
}

adev/shared-docs/pipeline/api-gen/rendering/index.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,27 @@ import {configureMarkedGlobally} from './marked/configuration';
77
import {getRenderable} from './processing';
88
import {renderEntry} from './rendering';
99
import {initHighlighter} from './shiki/shiki';
10+
import {setSymbols} from './symbol-context';
1011

1112
/** The JSON data file format for extracted API reference info. */
1213
interface EntryCollection {
1314
moduleName: string;
1415
moduleLabel?: string;
1516
normalizedModuleName: string;
1617
entries: DocEntry[];
18+
symbols: Map<string, string>;
1719
}
1820

1921
/** Parse all JSON data source files into an array of collections. */
2022
function parseEntryData(srcs: string[]): EntryCollection[] {
21-
return srcs.flatMap((jsonDataFilePath) => {
23+
return srcs.flatMap((jsonDataFilePath): EntryCollection | EntryCollection[] => {
2224
const fileContent = readFileSync(jsonDataFilePath, {encoding: 'utf8'});
2325
const fileContentJson = JSON.parse(fileContent) as unknown;
2426
if ((fileContentJson as EntryCollection).entries) {
25-
return fileContentJson as EntryCollection;
27+
return {
28+
...(fileContentJson as EntryCollection),
29+
symbols: new Map((fileContentJson as any).symbols ?? []),
30+
};
2631
}
2732

2833
// CLI subcommands should generate a separate file for each subcommand.
@@ -34,12 +39,14 @@ function parseEntryData(srcs: string[]): EntryCollection[] {
3439
moduleName: 'unknown',
3540
normalizedModuleName: 'unknown',
3641
entries: [fileContentJson as DocEntry],
42+
symbols: new Map(),
3743
},
3844
...command.subcommands!.map((subCommand) => {
3945
return {
4046
moduleName: 'unknown',
4147
normalizedModuleName: 'unknown',
4248
entries: [{...subCommand, parentCommand: command} as any],
49+
symbols: new Map(),
4350
};
4451
}),
4552
];
@@ -49,6 +56,7 @@ function parseEntryData(srcs: string[]): EntryCollection[] {
4956
moduleName: 'unknown',
5057
normalizedModuleName: 'unknown',
5158
entries: [fileContentJson as DocEntry], // TODO: fix the typing cli entries aren't DocEntry
59+
symbols: new Map(),
5260
};
5361
});
5462
}
@@ -98,6 +106,10 @@ async function main() {
98106

99107
for (const collection of entryCollections) {
100108
const extractedEntries = collection.entries;
109+
110+
// Setting the symbols are a global context for the rendering templates of this entry
111+
setSymbols(collection.symbols);
112+
101113
const renderableEntries = extractedEntries.map((entry) =>
102114
getRenderable(entry, collection.moduleName),
103115
);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* API pages are generated each package at a time.
3+
* This allows to use a global context to store the symbols and their corresponding module names.
4+
*/
5+
6+
let symbols = new Map<string, string>();
7+
8+
export function setSymbols(newSymbols: Map<string, string>): void {
9+
symbols = newSymbols;
10+
}
11+
12+
/**
13+
* Returns the module name of a symbol.
14+
* eg: 'ApplicationRef' => 'core', 'FormControl' => 'forms'
15+
*/
16+
export function getModuleName(symbol: string): string | undefined {
17+
return symbols.get(symbol)?.replace('@angular/', '');
18+
}

adev/shared-docs/pipeline/api-gen/rendering/templates/class-member.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {ClassMethodInfo} from './class-method-info';
2828
import {DeprecatedLabel} from './deprecated-label';
2929
import {RawHtml} from './raw-html';
3030
import {getFunctionMetadataRenderable} from '../transforms/function-transforms';
31+
import {CodeSymbol} from './code-symbols';
3132

3233
export function ClassMember(props: {member: MemberEntryRenderable}) {
3334
const body = (
@@ -61,7 +62,7 @@ export function ClassMember(props: {member: MemberEntryRenderable}) {
6162
{isClassMethodEntry(props.member) && props.member.signatures.length > 1 ? (
6263
<span>{props.member.signatures.length} overloads</span>
6364
) : returnType ? (
64-
<code>{returnType}</code>
65+
<CodeSymbol code={returnType} />
6566
) : (
6667
<></>
6768
)}

adev/shared-docs/pipeline/api-gen/rendering/templates/class-method-info.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ import {PARAM_KEYWORD_CLASS_NAME, REFERENCE_MEMBER_CARD_ITEM} from '../styling/c
1717
import {DeprecatedLabel} from './deprecated-label';
1818
import {Parameter} from './parameter';
1919
import {RawHtml} from './raw-html';
20-
import { EntryType } from '../entities';
20+
import {CodeSymbol} from './code-symbols';
2121

2222
/**
2323
* Component to render the method-specific parts of a class's API reference.
2424
*/
2525
export function ClassMethodInfo(props: {
2626
entry: FunctionSignatureMetadataRenderable;
2727
options?: {
28-
showUsageNotes?: boolean,
29-
}
28+
showUsageNotes?: boolean;
29+
};
3030
}) {
3131
const entry = props.entry;
3232

@@ -48,7 +48,7 @@ export function ClassMethodInfo(props: {
4848
))}
4949
<div className={'docs-return-type'}>
5050
<span className={PARAM_KEYWORD_CLASS_NAME}>@returns</span>
51-
<code>{entry.returnType}</code>
51+
<CodeSymbol code={entry.returnType} />
5252
</div>
5353
{entry.htmlUsageNotes && props.options?.showUsageNotes ? (
5454
<div className={'docs-usage-notes'}>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import {h} from 'preact';
2+
import {getModuleName} from '../symbol-context';
3+
import {getLinkToModule} from '../transforms/url-transforms';
4+
5+
const symbolRegex = /([a-zA-Z_$][a-zA-Z_$0-9\.]*)/;
6+
7+
/**
8+
* Component that generates a code block with a link to a Symbol if it's known,
9+
* else generates a string code block
10+
*/
11+
export function CodeSymbol(props: {code: string}) {
12+
return (
13+
<code>
14+
{props.code.split(symbolRegex).map((rawSymbol, index) => {
15+
// Every even index is a non-match when the regex has 1 capturing group
16+
if (index % 2 === 0) return rawSymbol;
17+
18+
let [symbol, subSymbol] = rawSymbol.split('.'); // Also takes care of methods, enum value etc.
19+
const moduleName = getModuleName(symbol);
20+
21+
if (moduleName) {
22+
const url = getLinkToModule(moduleName, symbol, subSymbol);
23+
return <a href={url}>{rawSymbol}</a>;
24+
}
25+
26+
return rawSymbol;
27+
})}
28+
</code>
29+
);
30+
}

adev/shared-docs/pipeline/api-gen/rendering/templates/function-reference.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {TabUsageNotes} from './tab-usage-notes';
2323
import {HighlightTypeScript} from './highlight-ts';
2424
import {printInitializerFunctionSignatureLine} from '../transforms/code-transforms';
2525
import {getFunctionMetadataRenderable} from '../transforms/function-transforms';
26+
import {CodeSymbol} from './code-symbols';
2627

2728
export const signatureCard = (
2829
name: string,
@@ -49,7 +50,7 @@ export const signatureCard = (
4950
<div className={REFERENCE_MEMBER_CARD_HEADER}>
5051
<h3>{name}</h3>
5152
<div>
52-
<code>{signature.returnType}</code>
53+
<CodeSymbol code={signature.returnType} />
5354
</div>
5455
</div>
5556
)}

adev/shared-docs/pipeline/api-gen/rendering/templates/parameter.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {h} from 'preact';
1010
import {ParameterEntryRenderable} from '../entities/renderables';
1111
import {RawHtml} from './raw-html';
1212
import {PARAM_GROUP_CLASS_NAME} from '../styling/css-classes';
13-
13+
import {CodeSymbol} from './code-symbols';
1414

1515
/** Component to render a function or method parameter reference doc fragment. */
1616
export function Parameter(props: {param: ParameterEntryRenderable}) {
@@ -21,7 +21,7 @@ export function Parameter(props: {param: ParameterEntryRenderable}) {
2121
{/*TODO: isOptional, isRestParam*/}
2222
<span class="docs-param-keyword">@param</span>
2323
<span class="docs-param-name">{param.name}</span>
24-
<code>{param.type}</code>
24+
<CodeSymbol code={param.type} />
2525
<RawHtml value={param.htmlDescription} className="docs-parameter-description" />
2626
</div>
2727
);

adev/shared-docs/pipeline/api-gen/rendering/transforms/code-transforms.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,10 @@ import {
3131
import {CodeLineRenderable} from '../entities/renderables';
3232
import {HasModuleName, HasRenderableToc} from '../entities/traits';
3333
import {codeToHtml} from '../shiki/shiki';
34+
import {getModuleName} from '../symbol-context';
3435

3536
import {filterLifecycleMethods, mergeGettersAndSetters} from './member-transforms';
37+
import {getLinkToModule} from './url-transforms';
3638

3739
// Allows to generate links for code lines.
3840
interface CodeTableOfContentsData {
@@ -78,7 +80,12 @@ export function addRenderableCodeToc<T extends DocEntry & HasModuleName>(
7880
const insideCode = match[2];
7981
const afterCode = match[3];
8082

81-
const lines = splitLines(insideCode);
83+
// Note: Don't expect enum value in signatures to be linked correctly
84+
// as skihi already splits them into separate span blocks.
85+
// Only the enum itself will recieve a link
86+
const codeWithLinks = addApiLinksToHtml(insideCode);
87+
88+
const lines = splitLines(codeWithLinks);
8289
const groups = groupCodeLines(lines, metadata, entry);
8390

8491
return {
@@ -428,3 +435,31 @@ function appendPrefixAndSuffix(entry: DocEntry, codeTocData: CodeTableOfContents
428435
appendFirstAndLastLines(codeTocData, `interface ${entry.name} {`, `}`);
429436
}
430437
}
438+
439+
/**
440+
* Replaces any code block that isn't already wrapped by an anchor element
441+
* by a link if the symbol is known
442+
*/
443+
export function addApiLinksToHtml(htmlString: string): string {
444+
const result = htmlString.replace(
445+
// This regex looks for span/code blocks not wrapped by an anchor block.
446+
// Their content are then replaced with a link if the symbol is known
447+
// The captured content ==> vvvvvvvv
448+
/(?<!<a[^>]*>)(<(?:(?:span)|(?:code))[^>]*>\s*)([^<]*?)(\s*<\/(?:span|code)>)/g,
449+
(type: string, span1: string, potentialSymbolName: string, span2: string) => {
450+
let [symbol, subSymbol] = potentialSymbolName.split(/(?:#|\.)/) as [string, string?];
451+
452+
// mySymbol() => mySymbol
453+
const symbolWithoutInvocation = symbol.replace(/\([^)]*\);?/g, '');
454+
const moduleName = getModuleName(symbolWithoutInvocation)!;
455+
456+
if (moduleName) {
457+
return `${span1}<a href="${getLinkToModule(moduleName, symbol, subSymbol)}">${potentialSymbolName}</a>${span2}`;
458+
}
459+
460+
return type;
461+
},
462+
);
463+
464+
return result;
465+
}

adev/shared-docs/pipeline/api-gen/rendering/transforms/jsdoc-transforms.ts

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,16 @@ import {
3030
} from '../entities/traits';
3131

3232
import {getLinkToModule} from './url-transforms';
33+
import {addApiLinksToHtml} from './code-transforms';
34+
import {getModuleName} from '../symbol-context';
3335

3436
export const JS_DOC_USAGE_NOTES_TAG = 'usageNotes';
3537
export const JS_DOC_SEE_TAG = 'see';
3638
export const JS_DOC_DESCRIPTION_TAG = 'description';
3739

3840
// Some links are written in the following format: {@link Route}
3941
const jsDoclinkRegex = /\{\s*@link\s+([^}]+)\s*\}/;
40-
const jsDoclinkRegexGlobal = /\{\s*@link\s+([^}]+)\s*\}/g;
42+
const jsDoclinkRegexGlobal = new RegExp(jsDoclinkRegex.source, 'g');
4143

4244
/** Given an entity with a description, gets the entity augmented with an `htmlDescription`. */
4345
export function addHtmlDescription<T extends HasDescription & HasModuleName>(
@@ -101,15 +103,18 @@ export function addHtmlUsageNotes<T extends HasJsDocTags>(entry: T): T & HasHtml
101103
) as string)
102104
: '';
103105

106+
const transformedHtml = addApiLinksToHtml(htmlUsageNotes);
107+
104108
return {
105109
...entry,
106-
htmlUsageNotes,
110+
htmlUsageNotes: transformedHtml,
107111
};
108112
}
109113

110114
/** Given a markdown JsDoc text, gets the rendered HTML. */
111115
function getHtmlForJsDocText<T extends HasModuleName>(text: string, entry: T): string {
112-
return marked.parse(convertLinks(wrapExampleHtmlElementsWithCode(text), entry)) as string;
116+
const parsed = marked.parse(convertLinks(wrapExampleHtmlElementsWithCode(text))) as string;
117+
return addApiLinksToHtml(parsed);
113118
}
114119

115120
export function setEntryFlags<T extends HasJsDocTags & HasModuleName>(
@@ -127,9 +132,7 @@ export function setEntryFlags<T extends HasJsDocTags & HasModuleName>(
127132
};
128133
}
129134

130-
function getHtmlAdditionalLinks<T extends HasJsDocTags & HasModuleName>(
131-
entry: T,
132-
): LinkEntryRenderable[] {
135+
function getHtmlAdditionalLinks<T extends HasJsDocTags>(entry: T): LinkEntryRenderable[] {
133136
const markdownLinkRule = /\[(.*?)\]\((.*?)(?: "(.*?)")?\)/;
134137

135138
const seeAlsoLinks = entry.jsdocTags
@@ -150,21 +153,8 @@ function getHtmlAdditionalLinks<T extends HasJsDocTags & HasModuleName>(
150153

151154
if (linkMatch) {
152155
const link = linkMatch[1];
153-
154-
// handling links like {@link Route Some route with description}
155-
const [symbol, description] = link.split(/\s(.+)/);
156-
if (entry && description) {
157-
return {
158-
label: description.trim(),
159-
url: `${getLinkToModule(entry.moduleName)}/${symbol}`,
160-
};
161-
}
162-
163-
// handling links like {@link Route}
164-
return {
165-
label: linkMatch[1].trim(),
166-
url: `${getLinkToModule(entry.moduleName)}/${linkMatch[1].trim()}`,
167-
};
156+
const {url, label} = parseAtLink(link);
157+
return {label, url};
168158
}
169159

170160
return undefined;
@@ -196,15 +186,38 @@ function convertJsDocExampleToHtmlExample(text: string): string {
196186
);
197187
}
198188

199-
function convertLinks(text: string, entry: HasModuleName) {
189+
/**
190+
* Converts {@link } tags into html anchor elements
191+
*/
192+
function convertLinks(text: string) {
200193
return text.replace(jsDoclinkRegexGlobal, (_, link) => {
201-
const [symbol, description] = link.split(/\s(.+)/);
202-
if (symbol && description) {
203-
// {@link Route Some route with description}
204-
return `<a href="${getLinkToModule(entry.moduleName)}/${symbol}"><code>${description}</code></a>`;
205-
} else {
206-
// {@link Route}
207-
return `<a href="${getLinkToModule(entry.moduleName)}/${symbol}"><code>${symbol}</code></a>`;
208-
}
194+
const {label, url} = parseAtLink(link);
195+
196+
return `<a href="${url}"><code>${label}</code></a>`;
209197
});
210198
}
199+
200+
function parseAtLink(link: string) {
201+
// Because of microsoft/TypeScript/issues/59679
202+
// getTextOfJSDocComment introduces an extra space between the symbol and a trailing ()
203+
link = link.replace(/ \(\)$/, '');
204+
205+
let [rawSymbol, description] = link.split(/\s(.+)/);
206+
let [symbol, subSymbol] = rawSymbol.split(/(?:#|\.)/);
207+
208+
const moduleName = getModuleName(symbol)!;
209+
if (!moduleName) {
210+
logWarning(link, symbol);
211+
}
212+
213+
return {
214+
label: description ?? rawSymbol,
215+
url: getLinkToModule(moduleName, symbol, subSymbol),
216+
};
217+
}
218+
219+
function logWarning(link: string, symbol: string) {
220+
// TODO: remove the links that generate this error
221+
// TODO: throw an error when there are no more warning generated
222+
console.warn(`WARNING: {@link ${link}} is invalid, ${symbol} is unknown in this context`);
223+
}

0 commit comments

Comments
 (0)