docs(docs-infra): Improves symbol linking for Angular Aria selectors

Improves the symbol linking logic to handle Angular component selectors (e.g., ngCombobox). It attempts to convert Angular selector patterns to their corresponding class names, improving navigation to Angular API documentation.

(cherry picked from commit 2d854e01bc)
This commit is contained in:
SkyZeroZx 2025-11-25 16:04:26 -05:00 committed by Pawel Kozlowski
parent bc6cb6830e
commit b7ccb308b8
20 changed files with 159 additions and 18 deletions

View file

@ -11,6 +11,7 @@ import type {DocEntry, EntryCollection, FunctionEntry, JsDocTagEntry} from '@ang
export interface ManifestEntry {
name: string;
aliases?: string[];
type: string;
category: string | undefined;
deprecated: {version: string | undefined} | undefined;
@ -81,6 +82,7 @@ export function generateManifest(apiCollections: EntryCollection[]): Manifest {
.filter((entry) => !isHiddenEntry(entry))
.map((entry: DocEntry) => ({
name: entry.name,
...(entry.aliases && {aliases: entry.aliases}),
type: entry.entryType,
deprecated: getTagSinceVersion(entry, 'deprecated', true),
developerPreview: getTagSinceVersion(entry, 'developerPreview'),

View file

@ -21,8 +21,15 @@ export function setCurrentSymbol(symbol: string): void {
currentSymbol = symbol;
}
export function getSymbols() {
return symbols;
/** Convert Record<string, string> to ApiEntries format */
export function getSymbolsAsApiEntries(): Record<string, {moduleName: string}> {
const result: Record<string, {moduleName: string}> = {};
for (const symbol in symbols) {
result[symbol] = {moduleName: symbols[symbol]};
}
return result;
}
export function getCurrentSymbol(): string | undefined {
@ -34,7 +41,7 @@ export function setSymbols(newSymbols: Record<string, string>): void {
}
export function getSymbolUrl(symbol: string): string | undefined {
return sharedGetSymbolUrl(symbol, symbols);
return sharedGetSymbolUrl(symbol, getSymbolsAsApiEntries());
}
export function unknownSymbolMessage(link: string, symbol: string): string {

View file

@ -28,7 +28,7 @@ import {parseMarkdown} from '../../../shared/marked/parse.mjs';
import {getHighlighterInstance} from '../shiki/shiki.mjs';
import {
getCurrentSymbol,
getSymbols,
getSymbolsAsApiEntries,
getSymbolUrl,
unknownSymbolMessage,
} from '../symbol-context.mjs';
@ -107,7 +107,7 @@ export function addHtmlUsageNotes<T extends HasJsDocTags>(entry: T): T & HasHtml
function getHtmlForJsDocText(text: string): string {
const mdToParse = convertLinks(wrapExampleHtmlElementsWithCode(text));
const parsed = parseMarkdown(mdToParse, {
apiEntries: getSymbols(),
apiEntries: getSymbolsAsApiEntries(),
highlighter: getHighlighterInstance(),
});
return addApiLinksToHtml(parsed);

View file

@ -15,7 +15,7 @@ import {hasUnknownAnchors} from './helpers.mjs';
type ApiManifest = ApiManifestPackage[];
interface ApiManifestPackage {
moduleName: string;
entries: {name: string}[];
entries: {name: string; aliases?: string[]}[];
}
async function main() {
@ -66,10 +66,12 @@ async function main() {
main();
function mapManifestToEntries(apiManifest: ApiManifest): Record<string, string> {
function mapManifestToEntries(
apiManifest: ApiManifest,
): Record<string, {moduleName: string; targetSymbol?: string}> {
const duplicateEntries = new Set<string>();
const entryToModuleMap: Record<string, string> = {};
const entryToModuleMap: Record<string, {moduleName: string; targetSymbol?: string}> = {};
for (const pkg of apiManifest) {
for (const entry of pkg.entries) {
if (duplicateEntries.has(entry.name)) {
@ -78,7 +80,19 @@ function mapManifestToEntries(apiManifest: ApiManifest): Record<string, string>
delete entryToModuleMap[entry.name];
duplicateEntries.add(entry.name);
} else {
entryToModuleMap[entry.name] = pkg.moduleName.replace(/^@angular\//, '');
const normalizedModuleName = pkg.moduleName.replace(/^@angular\//, '');
entryToModuleMap[entry.name] = {moduleName: normalizedModuleName};
// If there are aliases, create entries for each alias
if (entry.aliases) {
for (const alias of entry.aliases) {
entryToModuleMap[alias] = {
moduleName: normalizedModuleName,
targetSymbol: entry.name,
};
}
}
}
}
}

View file

@ -1,4 +1,4 @@
load("//adev/shared-docs:defaults.bzl", "ts_project")
load("//adev/shared-docs:defaults.bzl", "ts_project", "zoneless_jasmine_test")
package(default_visibility = ["//visibility:public"])
@ -20,3 +20,17 @@ ts_project(
"//adev:node_modules/shiki",
],
)
ts_project(
name = "linking_test_lib",
testonly = True,
srcs = ["test/linking.spec.mts"],
deps = [
":linking",
],
)
zoneless_jasmine_test(
name = "linking_test",
data = [":linking_test_lib"],
)

View file

@ -16,7 +16,8 @@ export function shouldLinkSymbol(symbol: string): boolean {
return !LINK_EXEMPT.has(symbol);
}
export type ApiEntries = Record<string, string>; // symbolName -> moduleName (without @angular/ prefix)
// symbolName -> symbol info with moduleName and optional targetSymbol for aliases
export type ApiEntries = Record<string, {moduleName: string; targetSymbol?: string}>;
/**
* Extracts the symbol name and property name from a symbol string.
@ -53,9 +54,15 @@ export function getSymbolUrl(symbol: string, apiEntries: ApiEntries): string | u
const {symbolName, propName} = extractFromSymbol(symbol);
// We don't want to match entries like "constructor"
const apiEntry = Object.hasOwn(apiEntries, symbolName) && apiEntries[symbolName];
if (!Object.hasOwn(apiEntries, symbolName)) {
return undefined;
}
return apiEntry ? `/api/${apiEntry}/${symbolName}${propName ? `#${propName}` : ''}` : undefined;
const apiEntry = apiEntries[symbolName];
const moduleName = apiEntry.moduleName;
const targetSymbol = apiEntry.targetSymbol ?? symbolName;
return `/api/${moduleName}/${targetSymbol}${propName ? `#${propName}` : ''}`;
}
function hasMoreThanOneDot(str: string) {

View file

@ -18,7 +18,7 @@ import {HighlighterGeneric} from 'shiki';
export interface RendererContext {
markdownFilePath?: string;
apiEntries?: Record<string, string>;
apiEntries?: Record<string, {moduleName: string; aliases?: string[]}>;
highlighter: HighlighterGeneric<any, any>;
}

View file

@ -19,10 +19,10 @@ export const rendererContext: RendererContext = {
markdownFilePath: '',
highlighter: null!,
apiEntries: {
CommonModule: 'angular/common',
bootstrapApplication: 'angular/platform-browser',
ApplicationRef: 'angular/core',
Router: 'angular/router',
CommonModule: {moduleName: 'angular/common'},
bootstrapApplication: {moduleName: 'angular/platform-browser'},
ApplicationRef: {moduleName: 'angular/core'},
Router: {moduleName: 'angular/router'},
},
};

View file

@ -0,0 +1,65 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {getSymbolUrl} from '../linking.mjs';
describe('getSymbolUrl', () => {
it('should resolve class names to API URLs', () => {
const apiEntries = {
Combobox: {moduleName: 'aria/combobox'},
AccordionPanel: {moduleName: 'aria/accordion'},
Grid: {moduleName: 'aria/grid'},
};
expect(getSymbolUrl('Combobox', apiEntries)).toBe('/api/aria/combobox/Combobox');
expect(getSymbolUrl('AccordionPanel', apiEntries)).toBe('/api/aria/accordion/AccordionPanel');
expect(getSymbolUrl('Grid', apiEntries)).toBe('/api/aria/grid/Grid');
});
it('should resolve selector aliases to class names', () => {
const apiEntries = {
Combobox: {moduleName: 'aria/combobox'},
ngCombobox: {moduleName: 'aria/combobox', targetSymbol: 'Combobox'},
AccordionPanel: {moduleName: 'aria/accordion'},
ngAccordionPanel: {moduleName: 'aria/accordion', targetSymbol: 'AccordionPanel'},
GridCell: {moduleName: 'aria/grid'},
ngGridCell: {moduleName: 'aria/grid', targetSymbol: 'GridCell'},
};
expect(getSymbolUrl('ngCombobox', apiEntries)).toBe('/api/aria/combobox/Combobox');
expect(getSymbolUrl('ngAccordionPanel', apiEntries)).toBe('/api/aria/accordion/AccordionPanel');
expect(getSymbolUrl('ngGridCell', apiEntries)).toBe('/api/aria/grid/GridCell');
});
it('should handle selector aliases with properties', () => {
const apiEntries = {
AccordionPanel: {moduleName: 'aria/accordion'},
ngAccordionPanel: {moduleName: 'aria/accordion', targetSymbol: 'AccordionPanel'},
ComboboxInput: {moduleName: 'aria/combobox'},
ngComboboxInput: {moduleName: 'aria/combobox', targetSymbol: 'ComboboxInput'},
};
expect(getSymbolUrl('ngAccordionPanel.visible', apiEntries)).toBe(
'/api/aria/accordion/AccordionPanel#visible',
);
expect(getSymbolUrl('ngComboboxInput.value', apiEntries)).toBe(
'/api/aria/combobox/ComboboxInput#value',
);
});
it('should return undefined for unknown symbols', () => {
const apiEntries = {
AccordionPanel: {moduleName: 'aria/accordion'},
ngAccordionPanel: {moduleName: 'aria/accordion', targetSymbol: 'AccordionPanel'},
};
expect(getSymbolUrl('UnknownSymbol', apiEntries)).toBeUndefined();
expect(getSymbolUrl('unknownSelector', apiEntries)).toBeUndefined();
expect(getSymbolUrl('ngUnknownDirective', apiEntries)).toBeUndefined();
});
});

View file

@ -10,6 +10,7 @@ import {ApiItemType} from './api-item-type';
export interface ApiManifestEntry {
name: string;
aliases?: string[];
type: ApiItemType;
category: string | undefined;
deprecated: {version: string | undefined} | undefined;

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "AccordionPanel",
"aliases": ["ngAccordionPanel"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [
@ -157,6 +158,7 @@
},
{
"name": "AccordionTrigger",
"aliases": ["ngAccordionTrigger"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -357,6 +359,7 @@
},
{
"name": "AccordionGroup",
"aliases": ["ngAccordionGroup"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -522,6 +525,7 @@
},
{
"name": "AccordionContent",
"aliases": ["ngAccordionContent"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [],

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "Combobox",
"aliases": ["ngCombobox"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [
@ -200,6 +201,7 @@
},
{
"name": "ComboboxInput",
"aliases": ["ngComboboxInput"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -261,6 +263,7 @@
},
{
"name": "ComboboxPopupContainer",
"aliases": ["ngComboboxPopupContainer"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [],
@ -282,6 +285,7 @@
},
{
"name": "ComboboxPopup",
"aliases": ["ngComboboxPopup"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -323,6 +327,7 @@
},
{
"name": "ComboboxDialog",
"aliases": ["ngComboboxDialog"],
"isAbstract": false,
"entryType": "directive",
"members": [

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "Grid",
"aliases" : ["ngGrid"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -178,6 +179,7 @@
},
{
"name": "GridRow",
"aliases" : ["ngGridRow"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -228,6 +230,7 @@
},
{
"name": "GridCell",
"aliases" : ["ngGridCell"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -443,6 +446,7 @@
},
{
"name": "GridCellWidget",
"aliases" : ["ngGridCellWidget"],
"isAbstract": false,
"entryType": "directive",
"members": [

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "Listbox",
"aliases" : ["ngListbox"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [
@ -238,6 +239,7 @@
},
{
"name": "Option",
"aliases" : ["ngOption"],
"isAbstract": false,
"entryType": "directive",
"members": [

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "MenuTrigger",
"aliases" : ["ngMenuTrigger"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -181,6 +182,7 @@
},
{
"name": "Menu",
"aliases" : ["ngMenu"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [
@ -348,6 +350,7 @@
},
{
"name": "MenuBar",
"aliases" : ["ngMenuBar"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -510,6 +513,7 @@
},
{
"name": "MenuItem",
"aliases" : ["ngMenuItem"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -724,6 +728,7 @@
},
{
"name": "MenuContent",
"aliases" : ["ngMenuContent"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [],

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "Tabs",
"aliases" : ["ngTabs"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -43,6 +44,7 @@
},
{
"name": "TabList",
"aliases" : ["ngTabList"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -301,6 +303,7 @@
},
{
"name": "Tab",
"aliases" : ["ngTab"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -500,6 +503,7 @@
},
{
"name": "TabPanel",
"aliases" : ["ngTabPanel"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [
@ -631,6 +635,7 @@
},
{
"name": "TabContent",
"aliases" : ["ngTabContent"],
"isAbstract": false,
"entryType": "undecorated_class",
"members": [],

View file

@ -6,6 +6,7 @@
"entries": [
{
"name": "Toolbar",
"aliases" : ["ngToolbar"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -123,6 +124,7 @@
},
{
"name": "ToolbarWidget",
"aliases" : ["ngToolbarWidget"],
"isAbstract": false,
"entryType": "directive",
"members": [
@ -302,6 +304,7 @@
},
{
"name": "ToolbarWidgetGroup",
"aliases" : ["ngToolbarWidgetGroup"],
"isAbstract": false,
"entryType": "directive",
"members": [

View file

@ -229,6 +229,7 @@
},
{
"name": "TreeItem",
"aliases" : ["ngTreeItem"],
"isAbstract": false,
"entryType": "directive",
"members": [

View file

@ -6,6 +6,7 @@ generate_guides(
srcs = glob([
"*.md",
]),
api_manifest = "//adev/src/assets:docs_api_manifest",
data = [
"//adev/src/content/examples",
],

View file

@ -95,6 +95,7 @@ export interface DocEntryWithSourceInfo extends DocEntry {
/** Base type for all documentation entities. */
export interface DocEntry {
entryType: EntryType;
aliases?: string[];
name: string;
description: string;
rawComment: string;