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;