Skip to content

Commit bd6cc3a

Browse files
MA2153claude
andauthored
fix(typegen): derive interface names from slug, not label (#1349)
* fix(typegen): derive interface names from slug, not label Generated TS interface names in emdash-env.d.ts were derived from the collection's human label (labelSingular). Labels are arbitrary and user-controlled, so a label with spaces/punctuation produced an invalid identifier (e.g. `Book (do not use)` -> `Book(donotuse)`) and two collections sharing a label collapsed to a duplicate identifier -- both rejected by astro check/tsc. Derive names from the slug instead, which is constrained to /^[a-z][a-z0-9_]*$/ and unique, so PascalCasing it always yields a valid, collision-free identifier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(typegen): address review — mark breaking, correct JSDoc - Changeset bumped patch -> minor and rewritten to call out the interface-name rename (e.g. Page -> Pages) as breaking, per AGENTS.md. - Drop the inaccurate "collision-free" guarantee from getInterfaceName JSDoc: test_1 and test1 both PascalCase to Test1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(typegen): singularize slug-derived interface names Review feedback: an interface describes a single entry, not the collection, so the name should be singular -- `Post`, not `Posts`. Keep deriving from the slug (valid, constrained identifier) but singularize it first. Singularization can map two distinct slugs onto the same name (`book` and `books` both -> `Book`), which would reintroduce the duplicate-identifier error this PR fixes, so resolve collisions in generateTypesFile with a deterministic numeric suffix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(typegen): dedupe interface names against emitted set; apply in CLI export Address review on #1349: - uniqueInterfaceNames now picks suffixes against the set of names already emitted rather than a per-base counter, so a suffixed name can't collide with another slug's base name (slugs book/books/book2 no longer emit Book2 twice). - The schema export route (?format=typescript) now resolves names via uniqueInterfaceNames before generating, so `emdash types` on a project with colliding singularized slugs no longer emits duplicate interfaces. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ec3b326 commit bd6cc3a

4 files changed

Lines changed: 156 additions & 11 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": minor
3+
---
4+
5+
**Breaking:** generated TypeScript interface names in `emdash-env.d.ts` now derive from the collection **slug** (singularized) instead of `labelSingular`. This fixes invalid identifiers (labels with spaces/punctuation) and duplicate identifiers (two collections sharing a label), while keeping names singular so each interface reads as a single entry (slug `posts``Post`, `blog_posts``BlogPost`). Interfaces are renamed wherever the old label-derived name differed from the slug. Users should regenerate `emdash-env.d.ts` (`emdash types` or dev-server start) and update any direct interface references in their code.

packages/core/src/astro/routes/api/schema/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { hashString } from "emdash";
1515
import { requirePerm } from "#api/authorize.js";
1616
import { apiSuccess, handleError, requireDb } from "#api/error.js";
1717
import { SchemaRegistry } from "#schema/registry.js";
18-
import { generateTypeScript } from "#schema/zod-generator.js";
18+
import { generateTypeScript, uniqueInterfaceNames } from "#schema/zod-generator.js";
1919

2020
export const prerender = false;
2121

@@ -45,8 +45,14 @@ export const GET: APIRoute = async ({ request, locals }) => {
4545
const format = url.searchParams.get("format");
4646

4747
if (format === "typescript") {
48-
// Generate TypeScript definitions
49-
const types = collectionsWithFields.map((c) => generateTypeScript(c)).join("\n\n");
48+
// Generate TypeScript definitions. Singularizing slugs can collapse
49+
// distinct collections onto the same interface name, so resolve names
50+
// up front to keep every emitted interface unique (`book` + `books`
51+
// would otherwise both emit `Book`).
52+
const interfaceNames = uniqueInterfaceNames(collectionsWithFields);
53+
const types = collectionsWithFields
54+
.map((c) => generateTypeScript(c, interfaceNames.get(c.slug)))
55+
.join("\n\n");
5056

5157
const header = `// Generated by EmDash CLI
5258
// Do not edit manually - run \`emdash types\` to regenerate

packages/core/src/schema/zod-generator.ts

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -268,8 +268,10 @@ export function validateContent(
268268
* Generate TypeScript interface from field definitions
269269
* Used by CLI `emdash types` to generate types
270270
*/
271-
export function generateTypeScript(collection: CollectionWithFields): string {
272-
const interfaceName = getInterfaceName(collection);
271+
export function generateTypeScript(
272+
collection: CollectionWithFields,
273+
interfaceName: string = getInterfaceName(collection),
274+
): string {
273275
const lines: string[] = [];
274276

275277
lines.push(`export interface ${interfaceName} {`);
@@ -324,18 +326,22 @@ export function generateTypesFile(collections: CollectionWithFields[]): string {
324326
lines.push(`import type { ${imports.join(", ")} } from "emdash";`);
325327
lines.push(``);
326328

329+
// Singularizing the slug can map two distinct slugs to the same name
330+
// (e.g. `book` and `books` both -> `Book`), so resolve collisions up front
331+
// to keep every interface identifier unique within the file.
332+
const interfaceNames = uniqueInterfaceNames(collections);
333+
327334
// Generate individual interfaces
328335
for (const collection of collections) {
329-
lines.push(generateTypeScript(collection));
336+
lines.push(generateTypeScript(collection, interfaceNames.get(collection.slug)));
330337
lines.push(``);
331338
}
332339

333340
// Generate the Collections interface for module augmentation
334341
lines.push(`declare module "emdash" {`);
335342
lines.push(` interface EmDashCollections {`);
336343
for (const collection of collections) {
337-
const interfaceName = getInterfaceName(collection);
338-
lines.push(` ${collection.slug}: ${interfaceName};`);
344+
lines.push(` ${collection.slug}: ${interfaceNames.get(collection.slug)};`);
339345
}
340346
lines.push(` }`);
341347
lines.push(`}`);
@@ -427,7 +433,8 @@ function pascalCase(str: string): string {
427433
}
428434

429435
/**
430-
* Simple singularization - handles common cases
436+
* Naive singularization for slug-derived interface names. Handles the common
437+
* English plural endings; intentionally simple, not a full inflector.
431438
*/
432439
function singularize(str: string): string {
433440
if (str.endsWith("ies")) {
@@ -446,8 +453,46 @@ function singularize(str: string): string {
446453
}
447454

448455
/**
449-
* Get the interface name for a collection
456+
* Get the interface name for a collection.
457+
*
458+
* Derived from the slug, not the human label. Slugs are constrained to
459+
* `/^[a-z][a-z0-9_]*$/`, so PascalCasing one always yields a valid TS
460+
* identifier; labels are arbitrary and user-controlled (punctuation, spaces,
461+
* duplicates across collections), which produced syntactically invalid or
462+
* duplicate interface names. The slug is singularized first because the
463+
* interface describes a single entry, not the collection (`posts` -> `Post`).
464+
*
465+
* Singularization can map two distinct slugs onto the same name, so callers
466+
* generating more than one interface must dedupe -- see `uniqueInterfaceNames`.
450467
*/
451468
function getInterfaceName(collection: CollectionWithFields): string {
452-
return pascalCase(collection.labelSingular || singularize(collection.slug));
469+
return pascalCase(singularize(collection.slug));
470+
}
471+
472+
/**
473+
* Resolve interface names for a set of collections, guaranteeing each is
474+
* unique within the file. Collisions (from singularization or PascalCasing
475+
* collapsing distinct slugs) get a numeric suffix in collection order, so the
476+
* generated `.d.ts` never declares two interfaces with the same identifier.
477+
*
478+
* The suffix is chosen against the set of names already emitted, not a
479+
* per-base counter, so a generated name can't collide with another slug's
480+
* base name (e.g. slugs `book`, `books`, `book2`: `books` -> `Book2` would
481+
* clash with `book2`, so it advances to `Book3`).
482+
*/
483+
export function uniqueInterfaceNames(collections: CollectionWithFields[]): Map<string, string> {
484+
const used = new Set<string>();
485+
const names = new Map<string, string>();
486+
for (const collection of collections) {
487+
const base = getInterfaceName(collection);
488+
let name = base;
489+
let suffix = 2;
490+
while (used.has(name)) {
491+
name = `${base}${suffix}`;
492+
suffix++;
493+
}
494+
used.add(name);
495+
names.set(collection.slug, name);
496+
}
497+
return names;
453498
}

packages/core/tests/unit/schema/zod-generator.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
generateFieldSchema,
77
validateContent,
88
generateTypeScript,
9+
generateTypesFile,
910
clearSchemaCache,
1011
} from "../../../src/schema/zod-generator.js";
1112

@@ -547,6 +548,9 @@ describe("Zod Generator", () => {
547548

548549
const ts = generateTypeScript(collection);
549550

551+
// Interface names derive from the singularized slug
552+
// (`blog_posts` -> `BlogPost`), not the human label, so they are
553+
// always valid TS identifiers describing a single entry.
550554
expect(ts).toContain("export interface BlogPost");
551555
expect(ts).toContain("title: string;");
552556
expect(ts).toContain("content: PortableTextBlock[];");
@@ -557,4 +561,89 @@ describe("Zod Generator", () => {
557561
expect(ts).toContain("terms?: Record<string, TaxonomyTerm[]>;");
558562
});
559563
});
564+
565+
describe("interface names derive from the singularized slug", () => {
566+
// A minimal collection factory: interface naming only depends on slug/labels.
567+
function makeCollection(
568+
slug: string,
569+
overrides: Partial<CollectionWithFields> = {},
570+
): CollectionWithFields {
571+
return {
572+
id: `c_${slug}`,
573+
slug,
574+
label: slug,
575+
supports: [],
576+
createdAt: new Date().toISOString(),
577+
updatedAt: new Date().toISOString(),
578+
fields: [],
579+
...overrides,
580+
};
581+
}
582+
583+
function interfaceNamesOf(ts: string): string[] {
584+
return Array.from(ts.matchAll(/export interface (\S+)/g), (m) => m[1]!);
585+
}
586+
587+
it("uses the slug, ignoring an arbitrary human label", () => {
588+
// The label has spaces and parentheses that are illegal in an
589+
// identifier; the slug (constrained `[a-z0-9_]`) is used instead.
590+
const ts = generateTypeScript(makeCollection("book", { labelSingular: "Book (do not use)" }));
591+
592+
expect(interfaceNamesOf(ts)).toEqual(["Book"]);
593+
});
594+
595+
it("keeps names unique when singularization collapses two slugs", () => {
596+
// `book` and `books` both singularize to `Book`; the collision is
597+
// resolved with a numeric suffix so the generated `.d.ts` never
598+
// declares the same identifier twice.
599+
const ts = generateTypesFile([makeCollection("book"), makeCollection("books")]);
600+
601+
const names = interfaceNamesOf(ts);
602+
expect(names).toEqual(["Book", "Book2"]);
603+
expect(new Set(names).size).toBe(names.length);
604+
});
605+
606+
it("keeps names unique when a suffixed name collides with another slug", () => {
607+
// `book` and `books` both singularize to `Book`, so `books` gets
608+
// suffixed to `Book2` -- which is also exactly what `book2` produces.
609+
// The dedupe must skip past an already-taken suffix, not blindly emit
610+
// it, or the file declares `Book2` twice.
611+
const ts = generateTypesFile([
612+
makeCollection("book"),
613+
makeCollection("books"),
614+
makeCollection("book2"),
615+
]);
616+
617+
const names = interfaceNamesOf(ts);
618+
expect(new Set(names).size).toBe(names.length);
619+
});
620+
621+
it("singularizes and PascalCases multi-word slugs", () => {
622+
expect(interfaceNamesOf(generateTypeScript(makeCollection("blog_posts")))).toEqual([
623+
"BlogPost",
624+
]);
625+
});
626+
627+
it("singularizes a plural slug to describe a single entry", () => {
628+
expect(interfaceNamesOf(generateTypeScript(makeCollection("pages")))).toEqual(["Page"]);
629+
});
630+
631+
it("leaves an already-singular slug unchanged", () => {
632+
expect(interfaceNamesOf(generateTypeScript(makeCollection("book")))).toEqual(["Book"]);
633+
});
634+
635+
it("references the same interface names in the EmDashCollections map", () => {
636+
const ts = generateTypesFile([
637+
makeCollection("book", { labelSingular: "Book (do not use)" }),
638+
makeCollection("blog_posts"),
639+
]);
640+
641+
// Every interface declared must be referenced by the augmentation map,
642+
// keyed by slug -> interface name.
643+
expect(ts).toContain("export interface Book {");
644+
expect(ts).toContain("book: Book;");
645+
expect(ts).toContain("export interface BlogPost {");
646+
expect(ts).toContain("blog_posts: BlogPost;");
647+
});
648+
});
560649
});

0 commit comments

Comments
 (0)