diff --git a/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.ts b/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.ts index ce46e92e5fe..0c3ee9d1582 100644 --- a/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.ts +++ b/adev/shared-docs/pipeline/api-gen/manifest/generate_manifest.ts @@ -1,5 +1,5 @@ // @ts-ignore This compiles fine, but Webstorm doesn't like the ESM import in a CJS context. -import type {DocEntry, EntryCollection, JsDocTagEntry} from '@angular/compiler-cli'; +import type {DocEntry, EntryCollection, JsDocTagEntry, FunctionEntry} from '@angular/compiler-cli'; export interface ManifestEntry { name: string; @@ -17,78 +17,37 @@ export type Manifest = { entries: ManifestEntry[]; }[]; -/** Gets a unique lookup key for an API, e.g. "@angular/core/ElementRef". */ -function getApiLookupKey(moduleName: string, name: string) { - return `${moduleName}/${name}`; -} +/** Gets whether the given entry has a given JsDoc tag. */ +function hasTag(entry: DocEntry | FunctionEntry, tag: string, every = false) { + const hasTagName = (t: JsDocTagEntry) => t.name === tag; -/** Gets whether the given entry has the "@deprecated" JsDoc tag. */ -function hasDeprecatedTag(entry: DocEntry) { - return entry.jsdocTags.some((t: JsDocTagEntry) => t.name === 'deprecated'); -} + if (every && 'signatures' in entry && entry.signatures.length > 1) { + // For overloads we need to check all signatures. + return entry.signatures.every((s) => s.jsdocTags.some(hasTagName)); + } -/** Gets whether the given entry has the "@developerPreview" JsDoc tag. */ -function hasDeveloperPreviewTag(entry: DocEntry) { - return entry.jsdocTags.some((t: JsDocTagEntry) => t.name === 'developerPreview'); -} + const jsdocTags = [ + ...entry.jsdocTags, + ...((entry as FunctionEntry).signatures?.flatMap((s) => s.jsdocTags) ?? []), + ...((entry as FunctionEntry).implementation?.jsdocTags ?? []), + ]; -/** Gets whether the given entry has the "@experimental" JsDoc tag. */ -function hasExperimentalTag(entry: DocEntry) { - return entry.jsdocTags.some((t: JsDocTagEntry) => t.name === 'experimental'); + return jsdocTags.some(hasTagName); } /** Gets whether the given entry is deprecated in the manifest. */ -function isDeprecated( - lookup: Map, - moduleName: string, - entry: DocEntry, -): boolean { - const entriesWithSameName = lookup.get(getApiLookupKey(moduleName, entry.name)); - - // If there are multiple entries with the same name in the same module, only - // mark them as deprecated if *all* of the entries with the same name are - // deprecated (e.g. function overloads). - if (entriesWithSameName && entriesWithSameName.length > 1) { - return entriesWithSameName.every((entry) => hasDeprecatedTag(entry)); - } - - return hasDeprecatedTag(entry); +function isDeprecated(entry: DocEntry): boolean { + return hasTag(entry, 'deprecated', /* every */ true); } /** Gets whether the given entry is hasDeveloperPreviewTag in the manifest. */ -function isDeveloperPreview( - lookup: Map, - moduleName: string, - entry: DocEntry, -): boolean { - const entriesWithSameName = lookup.get(getApiLookupKey(moduleName, entry.name)); - - // If there are multiple entries with the same name in the same module, only - // mark them as developer preview if *all* of the entries with the same name - // are hasDeveloperPreviewTag (e.g. function overloads). - if (entriesWithSameName && entriesWithSameName.length > 1) { - return entriesWithSameName.every((entry) => hasDeveloperPreviewTag(entry)); - } - - return hasDeveloperPreviewTag(entry); +function isDeveloperPreview(entry: DocEntry): boolean { + return hasTag(entry, 'developerPreview'); } /** Gets whether the given entry is hasExperimentalTag in the manifest. */ -function isExperimental( - lookup: Map, - moduleName: string, - entry: DocEntry, -): boolean { - const entriesWithSameName = lookup.get(getApiLookupKey(moduleName, entry.name)); - - // If there are multiple entries with the same name in the same module, only - // mark them as developer preview if *all* of the entries with the same name - // are hasExperimentalTag (e.g. function overloads). - if (entriesWithSameName && entriesWithSameName.length > 1) { - return entriesWithSameName.every((entry) => hasExperimentalTag(entry)); - } - - return hasExperimentalTag(entry); +function isExperimental(entry: DocEntry): boolean { + return hasTag(entry, 'experimental'); } /** @@ -96,31 +55,14 @@ function isExperimental( * extract_api_to_json. */ export function generateManifest(apiCollections: EntryCollection[]): Manifest { - // Filter out repeated entries for function overloads, but also keep track of - // all symbols keyed to their lookup key. We need this lookup later for - // determining whether to mark an entry as deprecated. - const entryLookup = new Map(); - for (const collection of apiCollections) { - collection.entries = collection.entries.filter((entry) => { - const lookupKey = getApiLookupKey(collection.moduleName, entry.name); - if (entryLookup.has(lookupKey)) { - entryLookup.get(lookupKey)!.push(entry); - return false; - } - - entryLookup.set(lookupKey, [entry]); - return true; - }); - } - const manifest: Manifest = []; for (const collection of apiCollections) { - const entries = collection.entries.map((entry) => ({ + const entries = collection.entries.map((entry: DocEntry) => ({ name: entry.name, type: entry.entryType, - isDeprecated: isDeprecated(entryLookup, collection.moduleName, entry), - isDeveloperPreview: isDeveloperPreview(entryLookup, collection.moduleName, entry), - isExperimental: isExperimental(entryLookup, collection.moduleName, entry), + isDeprecated: isDeprecated(entry), + isDeveloperPreview: isDeveloperPreview(entry), + isExperimental: isExperimental(entry), })); const existingEntry = manifest.find((entry) => entry.moduleName === collection.moduleName); diff --git a/adev/shared-docs/pipeline/api-gen/manifest/test/manifest.spec.ts b/adev/shared-docs/pipeline/api-gen/manifest/test/manifest.spec.ts index b6638a0f65b..5ed95649160 100644 --- a/adev/shared-docs/pipeline/api-gen/manifest/test/manifest.spec.ts +++ b/adev/shared-docs/pipeline/api-gen/manifest/test/manifest.spec.ts @@ -1,5 +1,5 @@ // @ts-ignore This compiles fine, but Webstorm doesn't like the ESM import in a CJS context. -import {DocEntry, EntryType, JsDocTagEntry} from '@angular/compiler-cli'; +import {DocEntry, EntryType, FunctionEntry, JsDocTagEntry} from '@angular/compiler-cli'; import {generateManifest, Manifest} from '../generate_manifest'; describe('api manifest generation', () => { @@ -178,44 +178,40 @@ describe('api manifest generation', () => { ]); }); - it('should deduplicate function overloads', () => { - const manifest = generateManifest([ - { - moduleName: '@angular/core', - entries: [ - entry({name: 'save', entryType: EntryType.Function}), - entry({name: 'save', entryType: EntryType.Function}), - ], - normalizedModuleName: 'angular_core', - moduleLabel: 'core', - }, - ]); - - expect(manifest).toEqual([ - { - moduleName: '@angular/core', - moduleLabel: 'core', - normalizedModuleName: 'angular_core', - entries: [ - { - name: 'save', - type: EntryType.Function, - isDeprecated: false, - isDeveloperPreview: false, - isExperimental: false, - }, - ], - }, - ]); - }); - it('should not mark a function as deprecated if only one overload is deprecated', () => { const manifest = generateManifest([ { moduleName: '@angular/core', entries: [ - entry({name: 'save', entryType: EntryType.Function}), - entry({name: 'save', entryType: EntryType.Function, jsdocTags: jsdocTags('deprecated')}), + functionEntry({ + name: 'save', + entryType: EntryType.Function, + jsdocTags: [], + signatures: [ + { + name: 'save', + returnType: 'void', + jsdocTags: [], + description: '', + entryType: EntryType.Function, + params: [], + generics: [], + isNewType: false, + rawComment: '', + }, + { + name: 'save', + returnType: 'void', + jsdocTags: jsdocTags('deprecated'), + description: '', + entryType: EntryType.Function, + params: [], + generics: [], + isNewType: false, + rawComment: '', + }, + ], + }), ], normalizedModuleName: 'angular_core', moduleLabel: 'core', @@ -245,8 +241,35 @@ describe('api manifest generation', () => { { moduleName: '@angular/core', entries: [ - entry({name: 'save', entryType: EntryType.Function, jsdocTags: jsdocTags('deprecated')}), - entry({name: 'save', entryType: EntryType.Function, jsdocTags: jsdocTags('deprecated')}), + functionEntry({ + name: 'save', + entryType: EntryType.Function, + jsdocTags: [], + signatures: [ + { + name: 'save', + returnType: 'void', + jsdocTags: jsdocTags('deprecated'), + description: '', + entryType: EntryType.Function, + params: [], + generics: [], + isNewType: false, + rawComment: '', + }, + { + name: 'save', + returnType: 'void', + jsdocTags: jsdocTags('deprecated'), + description: '', + entryType: EntryType.Function, + params: [], + generics: [], + isNewType: false, + rawComment: '', + }, + ], + }), ], normalizedModuleName: 'angular_core', moduleLabel: 'core', @@ -376,6 +399,15 @@ function entry(patch: Partial): DocEntry { }; } +function functionEntry(patch: Partial): FunctionEntry { + return entry({ + entryType: EntryType.Function, + implementation: [], + signatures: [], + ...patch, + } as FunctionEntry) as FunctionEntry; +} + /** Creates a fake jsdoc tag entry list that contains a tag with the given name */ function jsdocTags(name: string): JsDocTagEntry[] { return [{name, comment: ''}]; diff --git a/adev/shared-docs/pipeline/api-gen/rendering/entities/categorization.ts b/adev/shared-docs/pipeline/api-gen/rendering/entities/categorization.ts index 722b88cc677..48f9d201ef9 100644 --- a/adev/shared-docs/pipeline/api-gen/rendering/entities/categorization.ts +++ b/adev/shared-docs/pipeline/api-gen/rendering/entities/categorization.ts @@ -8,6 +8,7 @@ import { FunctionEntry, InitializerApiFunctionEntry, InterfaceEntry, + JsDocTagEntry, MemberEntry, MemberType, MethodEntry, @@ -125,7 +126,7 @@ export function isSetterEntry(entry: MemberEntry): entry is PropertyEntry { /** Gets whether the given entry is deprecated. */ export function isDeprecatedEntry(entry: T) { - return entry.jsdocTags.some((tag) => tag.name === 'deprecated'); + return hasTag(entry, 'deprecated', /* every */ true); } export function getDeprecatedEntry(entry: T) { @@ -133,13 +134,30 @@ export function getDeprecatedEntry(entry: T) { } /** Gets whether the given entry is developer preview. */ -export function isDeveloperPreview(entry: T) { - return entry.jsdocTags.some((tag) => tag.name === 'developerPreview'); +export function isDeveloperPreview(entry: T) { + return hasTag(entry, 'developerPreview', false); } /** Gets whether the given entry is is experimental. */ export function isExperimental(entry: T) { - return entry.jsdocTags.some((tag) => tag.name === 'experimental'); + return hasTag(entry, 'experimental'); +} +/** Gets whether the given entry has a given JsDoc tag. */ +function hasTag(entry: T, tag: string, every = false) { + const hasTagName = (t: JsDocTagEntry) => t.name === tag; + + if (every && 'signatures' in entry && entry.signatures.length > 1) { + // For overloads we need to check all signatures. + return entry.signatures.every((s) => s.jsdocTags.some(hasTagName)); + } + + const jsdocTags = [ + ...entry.jsdocTags, + ...((entry as FunctionEntry).signatures?.flatMap((s) => s.jsdocTags) ?? []), + ...((entry as FunctionEntry).implementation?.jsdocTags ?? []), + ]; + + return jsdocTags.some(hasTagName); } /** Gets whether the given entry is a cli entry. */ diff --git a/adev/shared-docs/pipeline/api-gen/rendering/test/fake-entries.json b/adev/shared-docs/pipeline/api-gen/rendering/test/fake-entries.json index 019e790144d..9484c361701 100644 --- a/adev/shared-docs/pipeline/api-gen/rendering/test/fake-entries.json +++ b/adev/shared-docs/pipeline/api-gen/rendering/test/fake-entries.json @@ -669,6 +669,91 @@ "startLine": 103, "endLine": 125 } + }, + { + "name": "linkedSignal", + "signatures": [ + { + "name": "linkedSignal", + "entryType": "function", + "description": "Creates a writable signals whose value is initialized and reset by the linked, reactive computation.", + "generics": [{"name": "D"}], + "isNewType": false, + "jsdocTags": [{"name": "developerPreview", "comment": ""}], + "params": [ + { + "name": "computation", + "description": "", + "type": "() => D", + "isOptional": false, + "isRestParam": false + }, + { + "name": "options", + "description": "", + "type": "{ equal?: ValueEqualityFn> | undefined; } | undefined", + "isOptional": true, + "isRestParam": false + } + ], + "rawComment": "/**\n * Creates a writable signals whose value is initialized and reset by the linked, reactive computation.\n *\n * @developerPreview\n */", + "returnType": "WritableSignal" + }, + { + "name": "linkedSignal", + "entryType": "function", + "description": "Creates a writable signals whose value is initialized and reset by the linked, reactive computation.\nThis is an advanced API form where the computation has access to the previous value of the signal and the computation result.", + "generics": [{"name": "S"}, {"name": "D"}], + "isNewType": false, + "jsdocTags": [{"name": "developerPreview", "comment": ""}], + "params": [ + { + "name": "options", + "description": "", + "type": "{ source: () => S; computation: (source: NoInfer, previous?: { source: NoInfer; value: NoInfer; } | undefined) => D; equal?: ValueEqualityFn> | undefined; }", + "isOptional": false, + "isRestParam": false + } + ], + "rawComment": "/**\n * Creates a writable signals whose value is initialized and reset by the linked, reactive computation.\n * This is an advanced API form where the computation has access to the previous value of the signal and the computation result.\n *\n * @developerPreview\n */", + "returnType": "WritableSignal" + } + ], + "implementation": { + "params": [ + { + "name": "optionsOrComputation", + "description": "", + "type": "{ source: () => S; computation: ComputationFn; equal?: ValueEqualityFn | undefined; } | (() => D)", + "isOptional": false, + "isRestParam": false + }, + { + "name": "options", + "description": "", + "type": "{ equal?: ValueEqualityFn | undefined; } | undefined", + "isOptional": true, + "isRestParam": false + } + ], + "isNewType": false, + "returnType": "WritableSignal", + "generics": [{"name": "S"}, {"name": "D"}], + "name": "linkedSignal", + "description": "", + "entryType": "function", + "jsdocTags": [], + "rawComment": "" + }, + "entryType": "function", + "description": "", + "jsdocTags": [], + "rawComment": "", + "source": { + "filePath": "/packages/core/src/render3/reactivity/linked_signal.ts", + "startLine": 112, + "endLine": 115 + } } ] } diff --git a/adev/shared-docs/pipeline/api-gen/rendering/test/renderable.spec.ts b/adev/shared-docs/pipeline/api-gen/rendering/test/renderable.spec.ts new file mode 100644 index 00000000000..5b7196dfbb9 --- /dev/null +++ b/adev/shared-docs/pipeline/api-gen/rendering/test/renderable.spec.ts @@ -0,0 +1,37 @@ +import {runfiles} from '@bazel/runfiles'; +import {readFile} from 'fs/promises'; +import {getRenderable} from '../processing'; +import {DocEntryRenderable} from '../entities/renderables'; +import {initHighlighter} from '../shiki/shiki'; +import {configureMarkedGlobally} from '../marked/configuration'; + +// 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. +// You can just generate them an copy/replace the items in the fake-entries file. + +describe('renderable', () => { + const entries = new Map(); + + beforeAll(async () => { + await initHighlighter(); + await configureMarkedGlobally(); + + const entryContent = await readFile(runfiles.resolvePackageRelative('fake-entries.json'), { + encoding: 'utf-8', + }); + const entryJson = JSON.parse(entryContent) as any; + for (const entry of entryJson.entries) { + const renderableJson = getRenderable(entry, '@angular/fakeentry') as DocEntryRenderable; + entries.set(entry['name'], renderableJson); + } + }); + + it('should compute the flags correctly', () => { + // linkedSignal has the developerPreview tag on the overloads not on the main entry. + const linkedSignal = entries.get('linkedSignal'); + expect(linkedSignal).toBeDefined(); + expect(linkedSignal!.isDeprecated).toBe(false); + expect(linkedSignal!.isDeveloperPreview).toBe(true); + expect(linkedSignal!.isExperimental).toBe(false); + }); +});