From b7ccb308b890163c102d46af93d60ffd6ced9ea6 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:04:26 -0500 Subject: [PATCH] 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 2d854e01bc92722a9b6c08551cd8402d210aa832) --- .../api-gen/manifest/generate_manifest.mts | 2 + .../api-gen/rendering/symbol-context.mts | 13 +++- .../rendering/transforms/jsdoc-transforms.mts | 4 +- adev/shared-docs/pipeline/guides/index.mts | 22 +++++-- adev/shared-docs/pipeline/shared/BUILD.bazel | 16 ++++- adev/shared-docs/pipeline/shared/linking.mts | 13 +++- .../pipeline/shared/marked/renderer.mts | 2 +- .../shared/marked/test/renderer-context.mts | 8 +-- .../pipeline/shared/test/linking.spec.mts | 65 +++++++++++++++++++ .../references/interfaces/api-manifest.ts | 1 + adev/src/content/aria/aria-accordion.json | 4 ++ adev/src/content/aria/aria-combobox.json | 5 ++ adev/src/content/aria/aria-grid.json | 4 ++ adev/src/content/aria/aria-listbox.json | 2 + adev/src/content/aria/aria-menu.json | 5 ++ adev/src/content/aria/aria-tabs.json | 5 ++ adev/src/content/aria/aria-toolbar.json | 3 + adev/src/content/aria/aria-tree.json | 1 + adev/src/content/guide/aria/BUILD.bazel | 1 + .../src/ngtsc/docs/src/entities.ts | 1 + 20 files changed, 159 insertions(+), 18 deletions(-) create mode 100644 adev/shared-docs/pipeline/shared/test/linking.spec.mts diff --git a/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.mts b/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.mts index 9f41f144394..c2e820dc43e 100644 --- a/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.mts +++ b/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.mts @@ -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'), diff --git a/adev/shared-docs/pipeline/api-gen/rendering/symbol-context.mts b/adev/shared-docs/pipeline/api-gen/rendering/symbol-context.mts index 3477eef3067..2882a4b1733 100644 --- a/adev/shared-docs/pipeline/api-gen/rendering/symbol-context.mts +++ b/adev/shared-docs/pipeline/api-gen/rendering/symbol-context.mts @@ -21,8 +21,15 @@ export function setCurrentSymbol(symbol: string): void { currentSymbol = symbol; } -export function getSymbols() { - return symbols; +/** Convert Record to ApiEntries format */ +export function getSymbolsAsApiEntries(): Record { + const result: Record = {}; + + 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): void { } export function getSymbolUrl(symbol: string): string | undefined { - return sharedGetSymbolUrl(symbol, symbols); + return sharedGetSymbolUrl(symbol, getSymbolsAsApiEntries()); } export function unknownSymbolMessage(link: string, symbol: string): string { diff --git a/adev/shared-docs/pipeline/api-gen/rendering/transforms/jsdoc-transforms.mts b/adev/shared-docs/pipeline/api-gen/rendering/transforms/jsdoc-transforms.mts index 350d36b4709..a5d92be6bd1 100644 --- a/adev/shared-docs/pipeline/api-gen/rendering/transforms/jsdoc-transforms.mts +++ b/adev/shared-docs/pipeline/api-gen/rendering/transforms/jsdoc-transforms.mts @@ -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(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); diff --git a/adev/shared-docs/pipeline/guides/index.mts b/adev/shared-docs/pipeline/guides/index.mts index 303f4a2e9ea..39442ac438d 100644 --- a/adev/shared-docs/pipeline/guides/index.mts +++ b/adev/shared-docs/pipeline/guides/index.mts @@ -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 { +function mapManifestToEntries( + apiManifest: ApiManifest, +): Record { const duplicateEntries = new Set(); - const entryToModuleMap: Record = {}; + const entryToModuleMap: Record = {}; 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 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, + }; + } + } } } } diff --git a/adev/shared-docs/pipeline/shared/BUILD.bazel b/adev/shared-docs/pipeline/shared/BUILD.bazel index 4edd36322ea..851ca4f99e6 100644 --- a/adev/shared-docs/pipeline/shared/BUILD.bazel +++ b/adev/shared-docs/pipeline/shared/BUILD.bazel @@ -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"], +) diff --git a/adev/shared-docs/pipeline/shared/linking.mts b/adev/shared-docs/pipeline/shared/linking.mts index 5a1a0074d86..034907a85b1 100644 --- a/adev/shared-docs/pipeline/shared/linking.mts +++ b/adev/shared-docs/pipeline/shared/linking.mts @@ -16,7 +16,8 @@ export function shouldLinkSymbol(symbol: string): boolean { return !LINK_EXEMPT.has(symbol); } -export type ApiEntries = Record; // symbolName -> moduleName (without @angular/ prefix) +// symbolName -> symbol info with moduleName and optional targetSymbol for aliases +export type ApiEntries = Record; /** * 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) { diff --git a/adev/shared-docs/pipeline/shared/marked/renderer.mts b/adev/shared-docs/pipeline/shared/marked/renderer.mts index fa86c2346bc..2edd78ef559 100644 --- a/adev/shared-docs/pipeline/shared/marked/renderer.mts +++ b/adev/shared-docs/pipeline/shared/marked/renderer.mts @@ -18,7 +18,7 @@ import {HighlighterGeneric} from 'shiki'; export interface RendererContext { markdownFilePath?: string; - apiEntries?: Record; + apiEntries?: Record; highlighter: HighlighterGeneric; } diff --git a/adev/shared-docs/pipeline/shared/marked/test/renderer-context.mts b/adev/shared-docs/pipeline/shared/marked/test/renderer-context.mts index 2d1c2870f3b..8f8b6224ab4 100644 --- a/adev/shared-docs/pipeline/shared/marked/test/renderer-context.mts +++ b/adev/shared-docs/pipeline/shared/marked/test/renderer-context.mts @@ -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'}, }, }; diff --git a/adev/shared-docs/pipeline/shared/test/linking.spec.mts b/adev/shared-docs/pipeline/shared/test/linking.spec.mts new file mode 100644 index 00000000000..f0fd0b02536 --- /dev/null +++ b/adev/shared-docs/pipeline/shared/test/linking.spec.mts @@ -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(); + }); +}); diff --git a/adev/src/app/features/references/interfaces/api-manifest.ts b/adev/src/app/features/references/interfaces/api-manifest.ts index e422ea3cf8d..aec3d834f95 100644 --- a/adev/src/app/features/references/interfaces/api-manifest.ts +++ b/adev/src/app/features/references/interfaces/api-manifest.ts @@ -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; diff --git a/adev/src/content/aria/aria-accordion.json b/adev/src/content/aria/aria-accordion.json index c20aa8aecf8..2449e4cb49b 100755 --- a/adev/src/content/aria/aria-accordion.json +++ b/adev/src/content/aria/aria-accordion.json @@ -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": [], diff --git a/adev/src/content/aria/aria-combobox.json b/adev/src/content/aria/aria-combobox.json index 1ac7e96b497..4595a18d4ea 100755 --- a/adev/src/content/aria/aria-combobox.json +++ b/adev/src/content/aria/aria-combobox.json @@ -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": [ diff --git a/adev/src/content/aria/aria-grid.json b/adev/src/content/aria/aria-grid.json index b25f38175aa..3f167e67af9 100755 --- a/adev/src/content/aria/aria-grid.json +++ b/adev/src/content/aria/aria-grid.json @@ -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": [ diff --git a/adev/src/content/aria/aria-listbox.json b/adev/src/content/aria/aria-listbox.json index 2385e25650e..e7513a93130 100755 --- a/adev/src/content/aria/aria-listbox.json +++ b/adev/src/content/aria/aria-listbox.json @@ -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": [ diff --git a/adev/src/content/aria/aria-menu.json b/adev/src/content/aria/aria-menu.json index dfc356ca30e..dc7b2c856b7 100755 --- a/adev/src/content/aria/aria-menu.json +++ b/adev/src/content/aria/aria-menu.json @@ -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": [], diff --git a/adev/src/content/aria/aria-tabs.json b/adev/src/content/aria/aria-tabs.json index 12688a1af30..6b59bd2666a 100755 --- a/adev/src/content/aria/aria-tabs.json +++ b/adev/src/content/aria/aria-tabs.json @@ -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": [], diff --git a/adev/src/content/aria/aria-toolbar.json b/adev/src/content/aria/aria-toolbar.json index 5b4c9c62b58..c00de6b2277 100755 --- a/adev/src/content/aria/aria-toolbar.json +++ b/adev/src/content/aria/aria-toolbar.json @@ -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": [ diff --git a/adev/src/content/aria/aria-tree.json b/adev/src/content/aria/aria-tree.json index ab7a9ac6f7e..384415dfbcf 100755 --- a/adev/src/content/aria/aria-tree.json +++ b/adev/src/content/aria/aria-tree.json @@ -229,6 +229,7 @@ }, { "name": "TreeItem", + "aliases" : ["ngTreeItem"], "isAbstract": false, "entryType": "directive", "members": [ diff --git a/adev/src/content/guide/aria/BUILD.bazel b/adev/src/content/guide/aria/BUILD.bazel index 2aa462faedd..0404e4f1150 100644 --- a/adev/src/content/guide/aria/BUILD.bazel +++ b/adev/src/content/guide/aria/BUILD.bazel @@ -6,6 +6,7 @@ generate_guides( srcs = glob([ "*.md", ]), + api_manifest = "//adev/src/assets:docs_api_manifest", data = [ "//adev/src/content/examples", ], diff --git a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts index a2b40bb5e4b..9ee082606b9 100644 --- a/packages/compiler-cli/src/ngtsc/docs/src/entities.ts +++ b/packages/compiler-cli/src/ngtsc/docs/src/entities.ts @@ -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;