From 2c8170faac738dae489974454bc7bc62516fa617 Mon Sep 17 00:00:00 2001 From: Janpot <2109932+Janpot@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:40:23 +0200 Subject: [PATCH 1/2] [docs-infra] Fold only value exports into constant groups Blocked on michaldudak/typescript-api-extractor#226 being released: the code uses the isValue declaration-space flag that beta.4 does not report yet, so typecheck and the new tests stay red until the dependency is bumped. Type-only exports (literal type aliases, interfaces) no longer fold into the group or trip the constants-only error; they are typing helpers with no runtime constant to document. Standalone constants next to the file's enum now fail the build instead of silently vanishing from the docs, since downstream matching reads only the enum. --- .../pipeline/load-server-types-meta/page.mdx | 2 + .../transformConstantGroup.test.ts | 64 ++++++++++++++++++- .../transformConstantGroup.ts | 46 +++++++++++-- 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/docs/app/docs-infra/pipeline/load-server-types-meta/page.mdx b/docs/app/docs-infra/pipeline/load-server-types-meta/page.mdx index 1b6a50174..0a97a0322 100644 --- a/docs/app/docs-infra/pipeline/load-server-types-meta/page.mdx +++ b/docs/app/docs-infra/pipeline/load-server-types-meta/page.mdx @@ -160,6 +160,8 @@ export const accordionPanelHeight = '--accordion-panel-height'; Both produce identical documentation. The constant form lets application bundlers inline and tree-shake the values, since nothing references a runtime enum object. Constants re-exported from another meta file (`export * from '../combobox/clear/ComboboxClearDataAttributes'`) are collected too, and belong to the group named after the re-exporting file. +Type-only exports (type aliases, interfaces) have no runtime constant to document and may sit next to either style as typing helpers. Mixing the two styles in one file is rejected: standalone constants next to the file's enum would silently disappear from the documentation, so the build fails instead. + ### 4. Process Types in Worker Type extraction is offloaded to a dedicated worker thread. Inside the worker: diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.test.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.test.ts index 744f0b07a..1cf961333 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.test.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.test.ts @@ -98,6 +98,38 @@ describe('transformConstantGroup', () => { ]); }); + it("throws when standalone constants sit alongside the file's enum, naming them", () => { + expect(() => + transformSources({ + 'ComponentRootDataAttributes.ts': ` + export enum ComponentRootDataAttributes { + /** Present when open. */ + open = 'data-open', + } + /** Present when disabled. */ + export const disabled = 'data-disabled'; + `, + }), + ).toThrow(/disabled/); + }); + + it("leaves type helpers next to the file's enum untouched", () => { + const exports = transformSources({ + 'ComponentRootDataAttributes.ts': ` + export enum ComponentRootDataAttributes { + /** Present when open. */ + open = 'data-open', + } + export type ComponentRootDataAttribute = keyof typeof ComponentRootDataAttributes; + `, + }); + + expect(exports.map((node) => node.name)).toEqual([ + 'ComponentRootDataAttributes', + 'ComponentRootDataAttribute', + ]); + }); + it('throws when an enum under a different name sits alongside constants, naming it', () => { expect(() => transformSources({ @@ -225,7 +257,24 @@ describe('transformConstantGroup', () => { ]); }); - it('throws when non-constant exports sit alongside constants, naming them', () => { + it('skips type-only exports, documenting only the runtime constants', () => { + const group = groupOf( + transformSources({ + 'ComponentRootDataAttributes.ts': ` + export type AttributeName = 'data-open'; + + /** Present when open. */ + export const open: AttributeName = 'data-open'; + `, + }), + ); + + expect(membersOf(group.type)).toEqual([ + { name: 'open', value: 'data-open', description: 'Present when open.', type: undefined }, + ]); + }); + + it('throws when runtime exports that are not constants sit alongside constants, naming them', () => { let message = ''; try { transformSources({ @@ -233,7 +282,6 @@ describe('transformConstantGroup', () => { /** Present when open. */ export const open = 'data-open'; - export type Attribute = string; export function helper(): string { return 'data-open'; } @@ -244,7 +292,6 @@ describe('transformConstantGroup', () => { } expect(message).toContain('ComponentRootDataAttributes'); - expect(message).toContain('Attribute'); expect(message).toContain('helper'); }); @@ -263,6 +310,17 @@ describe('transformConstantGroup', () => { }); describe('unrecognized files', () => { + it('leaves a file exporting only types untouched', () => { + const exports = transformSources({ + 'ComponentRootDataAttributes.ts': ` + export type AttributeName = 'data-open'; + `, + }); + + expect(exports.every((node) => node.type.kind !== 'enum')).toBe(true); + expect(exports.map((node) => node.name)).toEqual(['AttributeName']); + }); + it('leaves a file with no literal constants untouched', () => { const exports = transformSources({ 'ComponentRootDataAttributes.ts': ` diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts index 1800aec9d..f06983ba0 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts @@ -37,6 +37,18 @@ function readLiteralValue(value: unknown): string | undefined { return undefined; } +/** + * Whether an export is a documentable constant: a runtime value narrowed to a + * supported literal type. Type-only exports resolve to the same literal nodes + * (`export type X = 'data-open'`), so the declaration space is checked, not just + * the type shape. + */ +function isConstantExport(node: tae.ExportNode): boolean { + return ( + node.isValue && isLiteralType(node.type) && readLiteralValue(node.type.value) !== undefined + ); +} + /** * Normalizes a metadata file's exports into a single constant group named after the file. * @@ -44,10 +56,14 @@ function readLiteralValue(value: unknown): string | undefined { * component — the data attributes it sets, or the CSS variables it reads. Authors may * declare it as an enum named after the file, or as named literal constants; both end up * as the same enum-shaped export so the rest of the pipeline sees one representation. + * Type-only exports have no runtime constant to document and may sit next to either + * style as typing helpers. * * Exports matching no known authoring style are returned unchanged. Once a group is formed - * it replaces the file's exports, so a metadata file that also exports something which is - * not a constant is a mistake: it throws rather than dropping those exports silently. + * it replaces the file's exports, so a metadata file that also exports a runtime value + * which is not a constant is a mistake: it throws rather than dropping those exports + * silently. Mixing standalone constants with the file's enum throws for the same reason — + * downstream matching reads only the enum, so the constants would vanish from the docs. */ export function transformConstantGroup( filePath: string, @@ -57,6 +73,15 @@ export function transformConstantGroup( // Already an enum declaration named after its file, so there is nothing to normalize. if (exports.some((node) => node.name === groupName && isEnumType(node.type))) { + const mixedConstants = exports + .filter((node) => node.name !== groupName && isConstantExport(node)) + .map((node) => node.name); + if (mixedConstants.length > 0) { + throw new Error( + `[transformConstantGroup] ${groupName} - metadata files must not mix standalone constants with the file's enum, move these into the enum: ${mixedConstants.join(', ')}`, + ); + } + return exports; } @@ -67,6 +92,10 @@ export function transformConstantGroup( const discarded: string[] = []; for (const node of exports) { + if (!node.isValue) { + continue; + } + const value = isLiteralType(node.type) ? readLiteralValue(node.type.value) : undefined; if (value === undefined) { discarded.push(node.name); @@ -79,8 +108,8 @@ export function transformConstantGroup( return exports; } - // The group replaces the file's exports wholesale, so anything that is not a constant — - // a helper function, a type alias, an enum under another name, a constant widened off its + // The group replaces the file's exports wholesale, so a runtime export that is not a + // constant — a helper function, an enum under another name, a constant widened off its // literal type — would be documented nowhere. Metadata files are expected to hold // constants only, so fail the build rather than drop these exports silently. if (discarded.length > 0) { @@ -90,6 +119,13 @@ export function transformConstantGroup( } return [ - new ExportNode(groupName, new EnumNode(new TypeName(groupName), members, undefined), undefined), + new ExportNode( + groupName, + new EnumNode(new TypeName(groupName), members, undefined), + undefined, + true, + true, + false, + ), ]; } From cf84cdad3f93b48f2da1e1cdb233531746ead794 Mon Sep 17 00:00:00 2001 From: Janpot <2109932+Janpot@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:37:02 +0200 Subject: [PATCH 2/2] [docs-infra] Apply cleanup review to constant-group fold Single readConstantValue spelling shared by the predicate and the fold loop, identity-based sibling check against the found enum instead of a paired name comparison, and the ExportNode declaration spaces passed as the named object the extractor PR settled on instead of positional booleans. --- .../transformConstantGroup.ts | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts b/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts index f06983ba0..3871881ec 100644 --- a/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts +++ b/packages/docs-infra/src/pipeline/loadServerTypesMeta/transformConstantGroup.ts @@ -37,6 +37,14 @@ function readLiteralValue(value: unknown): string | undefined { return undefined; } +/** + * Reads an export's constant value, or `undefined` when its type is not a + * supported literal. + */ +function readConstantValue(node: tae.ExportNode): string | undefined { + return isLiteralType(node.type) ? readLiteralValue(node.type.value) : undefined; +} + /** * Whether an export is a documentable constant: a runtime value narrowed to a * supported literal type. Type-only exports resolve to the same literal nodes @@ -44,9 +52,7 @@ function readLiteralValue(value: unknown): string | undefined { * the type shape. */ function isConstantExport(node: tae.ExportNode): boolean { - return ( - node.isValue && isLiteralType(node.type) && readLiteralValue(node.type.value) !== undefined - ); + return node.isValue && readConstantValue(node) !== undefined; } /** @@ -56,8 +62,8 @@ function isConstantExport(node: tae.ExportNode): boolean { * component — the data attributes it sets, or the CSS variables it reads. Authors may * declare it as an enum named after the file, or as named literal constants; both end up * as the same enum-shaped export so the rest of the pipeline sees one representation. - * Type-only exports have no runtime constant to document and may sit next to either - * style as typing helpers. + * Non-value exports are skipped, never discarded: a typing helper next to the + * constants is not an authoring mistake. * * Exports matching no known authoring style are returned unchanged. Once a group is formed * it replaces the file's exports, so a metadata file that also exports a runtime value @@ -72,9 +78,10 @@ export function transformConstantGroup( const groupName = getGroupName(filePath); // Already an enum declaration named after its file, so there is nothing to normalize. - if (exports.some((node) => node.name === groupName && isEnumType(node.type))) { + const groupEnum = exports.find((node) => node.name === groupName && isEnumType(node.type)); + if (groupEnum) { const mixedConstants = exports - .filter((node) => node.name !== groupName && isConstantExport(node)) + .filter((node) => node !== groupEnum && isConstantExport(node)) .map((node) => node.name); if (mixedConstants.length > 0) { throw new Error( @@ -96,7 +103,7 @@ export function transformConstantGroup( continue; } - const value = isLiteralType(node.type) ? readLiteralValue(node.type.value) : undefined; + const value = readConstantValue(node); if (value === undefined) { discarded.push(node.name); } else { @@ -123,9 +130,11 @@ export function transformConstantGroup( groupName, new EnumNode(new TypeName(groupName), members, undefined), undefined, - true, - true, - false, + { + isValue: true, + isType: true, + isNamespace: false, + }, ), ]; }