Skip to content

Commit 9020f04

Browse files
committed
fix(api): fold a const object and its derived union type onto one page
`export const X = { … } as const` plus `export type X = (typeof X)[keyof typeof X]` is one concept TypeScript makes you declare twice, once in value space and once in type space. Both declarations are therefore public and neither can be renamed or marked @internal without breaking consumers — but api-documenter derives every filename from the lowercased symbol name, so the pair collides on <pkg>.x.md and the page-name assertion fails the build. That is the only thing that has kept @imqueue/pg-prisma out of the reference (src/audit.ts:27+32, AuditAction). normalizeModel() now folds the alias into the variable. The alias's own excerpt tokens are appended to the variable's, so the page shows both declarations exactly as the source writes them rather than a reconstructed type, and any reference naming the alias is retargeted at the survivor — an unretargeted reference resolves to nothing and renders as unlinked plain text with no warning. The discriminator is exact rather than a guess: the alias must carry a Reference token pointing at the variable's own canonicalReference, which is what `typeof X` leaves in the model. A TypeAlias and a Variable that merely share a name are two different things, so they are NOT folded — they keep the existing report and still fail the assertion, because putting two unrelated symbols on one page would be a worse lie than failing. The Variable survives rather than the alias for a mechanical reason documented in the file: the alias's excerpt is a complete statement that appends cleanly, the variable's is not. One cost, invisible in the output and so recorded in the build log: the package page lists the symbol under Variables only, since the Type Aliases row came from the node that was folded away. Verified: a full build-docs over all 14 shipped packages changed no generated file at all, so this is a no-op for everything already published; the four checks pass (1,598 pages crawled, 0 broken, 398/4 sitemap URLs); and pg-prisma now extracts to 91 pages with 0 errors and no collisions, where before it could not be generated at all.
1 parent 9644b52 commit 9020f04

1 file changed

Lines changed: 138 additions & 3 deletions

File tree

scripts/lib/api-model.js

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,10 @@
4545
//
4646
// This lever only works where api-documenter honours overloadIndex, which is
4747
// ApiParameterListMixin — methods, functions, constructors and signatures. A
48-
// TypeAlias colliding with a Variable (@imqueue/pg-prisma's AuditAction) has NO
49-
// filename lever at all, so it is reported and left for api-pages.js to fail on:
50-
// the remedy there is a source change or an @internal, and pretending otherwise
48+
// TypeAlias colliding with a Variable has NO filename lever at all. Where the two
49+
// are one thing declared twice, (4) folds them; where they are genuinely unrelated
50+
// there is nothing to be done here, so it is reported and left for api-pages.js to
51+
// fail on: the remedy is a source change or an @internal, and pretending otherwise
5152
// would ship a lost page.
5253
//
5354
// --- 3. ambient-collision renames (`Response_2`) ----------------------------
@@ -80,6 +81,43 @@
8081
// api-documenter resolves signature links through those references, so changing
8182
// `name` alone turns a working cross-reference into plain text with no link and
8283
// no warning.
84+
//
85+
// --- 4. const object + its derived union type (`AuditAction`) ----------------
86+
//
87+
// The standard TypeScript way to get an enum without `enum` is to declare a frozen
88+
// object and derive a union from its values:
89+
//
90+
// export const AuditAction = { INSERT: 'INSERT', … } as const;
91+
// export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction];
92+
//
93+
// That is ONE concept the language requires you to declare twice, once in value
94+
// space and once in type space — which is why both declarations are public and why
95+
// neither can be renamed or marked @internal without breaking consumers.
96+
// @imqueue/pg-prisma does this at src/audit.ts:27+32 and it is the last thing
97+
// standing between that package and a published reference.
98+
//
99+
// It reaches api-documenter as a TypeAlias and a Variable of the same name, both
100+
// wanting pg-prisma.auditaction.md, and (2) has no lever for either kind. So this
101+
// folds them onto one page instead: the Variable survives and the alias's
102+
// declaration is appended to its signature, giving a page that shows both lines
103+
// exactly as the source writes them.
104+
//
105+
// The discriminator is exact rather than a guess — the alias must carry a Reference
106+
// token pointing at the variable's own canonicalReference, which is what
107+
// `typeof AuditAction` compiles to in the model. A TypeAlias and a Variable that
108+
// merely happen to share a name are two different things and are NOT folded; they
109+
// keep the (2) report and the api-pages.js failure, because putting two unrelated
110+
// symbols on one page would be a worse lie than failing.
111+
//
112+
// The Variable is the survivor, not the alias, for a mechanical reason: the alias's
113+
// excerpt reads `export type X = (typeof X)[keyof typeof X];`, a complete statement
114+
// that appends cleanly, whereas the variable's reads `X: { … }`, which is not. It
115+
// also keeps the alias's Reference tokens resolvable — they point at the variable,
116+
// which is now the page itself.
117+
//
118+
// One cost, stated because it is invisible in the output: the package page lists
119+
// the symbol under "Variables" only, since the row under "Type Aliases" came from
120+
// the node that was folded away. The page it links to documents both declarations.
83121

84122
'use strict';
85123

@@ -183,6 +221,101 @@ function mergeDeclarationMerges(model, notes) {
183221
}
184222
}
185223

224+
// Is this TypeAlias derived from that Variable? True only when the alias names the
225+
// variable itself, which is what `typeof <name>` leaves in the model — so a
226+
// same-named pair that has nothing to do with each other is not matched.
227+
function aliasDerivesFrom(alias, variable) {
228+
return (alias.excerptTokens || []).some(
229+
token => token.kind === 'Reference'
230+
&& token.canonicalReference === variable.canonicalReference,
231+
);
232+
}
233+
234+
// Point every canonicalReference at `toRef` that currently names `fromRef`,
235+
// including references to its members (`…!X:type#member`). Used when a node is
236+
// folded away: a reference left naming the removed node resolves to nothing, and
237+
// api-documenter renders that as unlinked plain text without warning.
238+
function retargetReferences(node, fromRef, toRef) {
239+
let rewritten = 0;
240+
241+
const visit = (item) => {
242+
if (Array.isArray(item)) {
243+
item.forEach(visit);
244+
245+
return;
246+
}
247+
if (!item || typeof item !== 'object') return;
248+
249+
const ref = item.canonicalReference;
250+
251+
if (typeof ref === 'string' && (ref === fromRef || ref.startsWith(`${fromRef}#`))) {
252+
item.canonicalReference = toRef + ref.slice(fromRef.length);
253+
rewritten++;
254+
}
255+
for (const value of Object.values(item)) {
256+
if (value && typeof value === 'object') visit(value);
257+
}
258+
};
259+
260+
visit(node);
261+
262+
return rewritten;
263+
}
264+
265+
// Fold `type X = (typeof X)[keyof typeof X]` into `const X`, so the pair the
266+
// language forces you to declare twice occupies one page instead of losing one.
267+
function foldDerivedUnionTypes(model, notes) {
268+
for (const parent of [...containers(model)]) {
269+
const byName = new Map();
270+
271+
for (const member of parent.members) {
272+
if (member.name === undefined) continue;
273+
if (!['TypeAlias', 'Variable'].includes(member.kind)) continue;
274+
if (!byName.has(member.name)) byName.set(member.name, []);
275+
byName.get(member.name).push(member);
276+
}
277+
278+
for (const [name, decls] of byName) {
279+
if (decls.length !== 2) continue;
280+
281+
const alias = decls.find(d => d.kind === 'TypeAlias');
282+
const variable = decls.find(d => d.kind === 'Variable');
283+
284+
if (!alias || !variable) continue;
285+
if (!aliasDerivesFrom(alias, variable)) {
286+
continue; // same name, unrelated declarations — not ours to merge
287+
}
288+
289+
// Append the alias's own tokens rather than a reconstructed type: the page
290+
// then shows the two declarations the source actually has, references and all.
291+
variable.excerptTokens = [
292+
...(variable.excerptTokens || []),
293+
{ kind: 'Content', text: '\n\n' },
294+
...(alias.excerptTokens || []).map(token => ({ ...token })),
295+
];
296+
297+
// Same rule as (1): keep the survivor's prose, inherit the folded node's only
298+
// when the survivor has none, so a documented alias is not thrown away.
299+
if (!String(variable.docComment || '').trim() && String(alias.docComment || '').trim()) {
300+
variable.docComment = alias.docComment;
301+
}
302+
303+
parent.members = parent.members.filter(m => m !== alias);
304+
305+
const refs = retargetReferences(
306+
model, alias.canonicalReference, variable.canonicalReference,
307+
);
308+
309+
notes.push(
310+
`folded type ${name} into const ${name} and retargeted ${refs} canonical ` +
311+
'reference(s): the alias is derived from the const, so the two are one ' +
312+
'concept TypeScript makes you declare twice — both declarations are now on ' +
313+
`the ${safeName(name)} page, which the package page lists under Variables`,
314+
);
315+
}
316+
}
317+
}
318+
186319
// Give same-filename siblings distinct overloadIndex values where the filename
187320
// derivation honours them.
188321
function disambiguateSiblings(model, notes) {
@@ -357,6 +490,8 @@ function normalizeModel(model) {
357490
const renames = [];
358491

359492
mergeDeclarationMerges(model, notes);
493+
// Before (2), so a pair this fixes never also draws (2)'s "no lever here" report.
494+
foldDerivedUnionTypes(model, notes);
360495
disambiguateSiblings(model, notes);
361496
// After (2), so a suffix this run assigned via overloadIndex is never mistaken
362497
// for one api-extractor baked into a name.

0 commit comments

Comments
 (0)