From e145071e9dc73c950144661de2e3ff72c51e9fdd Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:09:17 +0300 Subject: [PATCH 1/4] 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 --- .../fix-typegen-interface-name-from-slug.md | 5 ++ packages/core/src/schema/zod-generator.ts | 29 ++------ .../tests/unit/schema/zod-generator.test.ts | 73 ++++++++++++++++++- 3 files changed, 85 insertions(+), 22 deletions(-) create mode 100644 .changeset/fix-typegen-interface-name-from-slug.md diff --git a/.changeset/fix-typegen-interface-name-from-slug.md b/.changeset/fix-typegen-interface-name-from-slug.md new file mode 100644 index 000000000..47d3edaf5 --- /dev/null +++ b/.changeset/fix-typegen-interface-name-from-slug.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fix generated TypeScript interface names in `emdash-env.d.ts` being derived from a collection's human display label (`labelSingular`) rather than its slug. Labels are arbitrary and user-controlled, so a label with spaces/punctuation produced a syntactically invalid identifier (e.g. `Book (do not use)` → `export interface Book(donotuse)`) and two collections sharing a label collapsed to the same name (duplicate identifier) — both rejected by `astro check`/`tsc`. Interface names now derive from the slug, which is constrained to `/^[a-z][a-z0-9_]*$/` and unique, so PascalCasing it always yields a valid, collision-free identifier. The `EmDashCollections` augmentation map references the same names. diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index bf7842a67..709d3adba 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -423,27 +423,14 @@ function pascalCase(str: string): string { } /** - * Simple singularization - handles common cases - */ -function singularize(str: string): string { - if (str.endsWith("ies")) { - return str.slice(0, -3) + "y"; - } - if ( - str.endsWith("es") && - (str.endsWith("sses") || str.endsWith("xes") || str.endsWith("ches") || str.endsWith("shes")) - ) { - return str.slice(0, -2); - } - if (str.endsWith("s") && !str.endsWith("ss")) { - return str.slice(0, -1); - } - return str; -} - -/** - * Get the interface name for a collection + * Get the interface name for a collection. + * + * Derived from the slug, not the human label. Slugs are constrained to + * `/^[a-z][a-z0-9_]*$/` and are unique, so PascalCasing one always yields a + * valid, collision-free TS identifier. Labels are arbitrary and user-controlled + * (punctuation, spaces, duplicates across collections), which produced + * syntactically invalid or duplicate interface names. */ function getInterfaceName(collection: CollectionWithFields): string { - return pascalCase(collection.labelSingular || singularize(collection.slug)); + return pascalCase(collection.slug); } diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index 4bc365739..dcf593379 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -6,6 +6,7 @@ import { generateFieldSchema, validateContent, generateTypeScript, + generateTypesFile, clearSchemaCache, } from "../../../src/schema/zod-generator.js"; @@ -547,11 +548,81 @@ describe("Zod Generator", () => { const ts = generateTypeScript(collection); - expect(ts).toContain("export interface BlogPost"); + // Interface names derive from the slug (`blog_posts` -> `BlogPosts`), + // not the human label, so they are always valid TS identifiers. + expect(ts).toContain("export interface BlogPosts"); expect(ts).toContain("title: string;"); expect(ts).toContain("content: PortableTextBlock[];"); expect(ts).toContain("featured?: boolean;"); expect(ts).toContain('status: "draft" | "published";'); }); }); + + describe("interface names derive from the slug", () => { + // A minimal collection factory: interface naming only depends on slug/labels. + function makeCollection( + slug: string, + overrides: Partial = {}, + ): CollectionWithFields { + return { + id: `c_${slug}`, + slug, + label: slug, + supports: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + fields: [], + ...overrides, + }; + } + + function interfaceNamesOf(ts: string): string[] { + return Array.from(ts.matchAll(/export interface (\S+)/g), (m) => m[1]!); + } + + it("uses the slug, ignoring an arbitrary human label", () => { + // The label has spaces and parentheses that are illegal in an + // identifier; the slug (constrained `[a-z0-9_]`) is used instead. + const ts = generateTypeScript(makeCollection("book", { labelSingular: "Book (do not use)" })); + + expect(interfaceNamesOf(ts)).toEqual(["Book"]); + }); + + it("keeps names unique when two collections share a label", () => { + // Distinct slugs guarantee distinct interface names even when the + // human labels are identical (which previously collided). + const ts = generateTypesFile([ + makeCollection("book", { labelSingular: "Book" }), + makeCollection("books", { labelSingular: "Book" }), + ]); + + const names = interfaceNamesOf(ts); + expect(names).toEqual(["Book", "Books"]); + expect(new Set(names).size).toBe(names.length); + }); + + it("PascalCases multi-word slugs", () => { + expect(interfaceNamesOf(generateTypeScript(makeCollection("blog_posts")))).toEqual([ + "BlogPosts", + ]); + }); + + it("leaves a plain slug unchanged", () => { + expect(interfaceNamesOf(generateTypeScript(makeCollection("pages")))).toEqual(["Pages"]); + }); + + it("references the same interface names in the EmDashCollections map", () => { + const ts = generateTypesFile([ + makeCollection("book", { labelSingular: "Book (do not use)" }), + makeCollection("blog_posts"), + ]); + + // Every interface declared must be referenced by the augmentation map, + // keyed by slug -> interface name. + expect(ts).toContain("export interface Book {"); + expect(ts).toContain("book: Book;"); + expect(ts).toContain("export interface BlogPosts {"); + expect(ts).toContain("blog_posts: BlogPosts;"); + }); + }); }); From 10f1d77b362c558d4dd16660fc97dff1c7aa9afe Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:36:10 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(typegen):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20mark=20breaking,=20correct=20JSDoc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .changeset/fix-typegen-interface-name-from-slug.md | 4 ++-- packages/core/src/schema/zod-generator.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-typegen-interface-name-from-slug.md b/.changeset/fix-typegen-interface-name-from-slug.md index 47d3edaf5..de33dec66 100644 --- a/.changeset/fix-typegen-interface-name-from-slug.md +++ b/.changeset/fix-typegen-interface-name-from-slug.md @@ -1,5 +1,5 @@ --- -"emdash": patch +"emdash": minor --- -Fix generated TypeScript interface names in `emdash-env.d.ts` being derived from a collection's human display label (`labelSingular`) rather than its slug. Labels are arbitrary and user-controlled, so a label with spaces/punctuation produced a syntactically invalid identifier (e.g. `Book (do not use)` → `export interface Book(donotuse)`) and two collections sharing a label collapsed to the same name (duplicate identifier) — both rejected by `astro check`/`tsc`. Interface names now derive from the slug, which is constrained to `/^[a-z][a-z0-9_]*$/` and unique, so PascalCasing it always yields a valid, collision-free identifier. The `EmDashCollections` augmentation map references the same names. +**Breaking:** generated TypeScript interface names in `emdash-env.d.ts` now derive from the collection **slug** instead of `labelSingular`. This fixes invalid identifiers (labels with spaces/punctuation) and duplicate identifiers (two collections sharing a label), but it renames interfaces for existing collections — e.g. `Page` → `Pages`, `BlogPost` → `BlogPosts`. Users should regenerate `emdash-env.d.ts` (`emdash types` or dev-server start) and update any direct interface references in their code. diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 709d3adba..48655be28 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -427,7 +427,7 @@ function pascalCase(str: string): string { * * Derived from the slug, not the human label. Slugs are constrained to * `/^[a-z][a-z0-9_]*$/` and are unique, so PascalCasing one always yields a - * valid, collision-free TS identifier. Labels are arbitrary and user-controlled + * valid TS identifier. Labels are arbitrary and user-controlled * (punctuation, spaces, duplicates across collections), which produced * syntactically invalid or duplicate interface names. */ From 5998d4ad31bf816b6b748f08cd9ca922bbb68b1a Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:37:52 +0300 Subject: [PATCH 3/4] 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 --- .../fix-typegen-interface-name-from-slug.md | 2 +- packages/core/src/schema/zod-generator.ts | 68 ++++++++++++++++--- .../tests/unit/schema/zod-generator.test.ts | 39 ++++++----- 3 files changed, 80 insertions(+), 29 deletions(-) diff --git a/.changeset/fix-typegen-interface-name-from-slug.md b/.changeset/fix-typegen-interface-name-from-slug.md index de33dec66..9676cb899 100644 --- a/.changeset/fix-typegen-interface-name-from-slug.md +++ b/.changeset/fix-typegen-interface-name-from-slug.md @@ -2,4 +2,4 @@ "emdash": minor --- -**Breaking:** generated TypeScript interface names in `emdash-env.d.ts` now derive from the collection **slug** instead of `labelSingular`. This fixes invalid identifiers (labels with spaces/punctuation) and duplicate identifiers (two collections sharing a label), but it renames interfaces for existing collections — e.g. `Page` → `Pages`, `BlogPost` → `BlogPosts`. Users should regenerate `emdash-env.d.ts` (`emdash types` or dev-server start) and update any direct interface references in their code. +**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. diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 48655be28..82538eaab 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -268,8 +268,10 @@ export function validateContent( * Generate TypeScript interface from field definitions * Used by CLI `emdash types` to generate types */ -export function generateTypeScript(collection: CollectionWithFields): string { - const interfaceName = getInterfaceName(collection); +export function generateTypeScript( + collection: CollectionWithFields, + interfaceName: string = getInterfaceName(collection), +): string { const lines: string[] = []; lines.push(`export interface ${interfaceName} {`); @@ -320,9 +322,14 @@ export function generateTypesFile(collections: CollectionWithFields[]): string { lines.push(`import type { ${imports.join(", ")} } from "emdash";`); lines.push(``); + // Singularizing the slug can map two distinct slugs to the same name + // (e.g. `book` and `books` both -> `Book`), so resolve collisions up front + // to keep every interface identifier unique within the file. + const interfaceNames = uniqueInterfaceNames(collections); + // Generate individual interfaces for (const collection of collections) { - lines.push(generateTypeScript(collection)); + lines.push(generateTypeScript(collection, interfaceNames.get(collection.slug))); lines.push(``); } @@ -330,8 +337,7 @@ export function generateTypesFile(collections: CollectionWithFields[]): string { lines.push(`declare module "emdash" {`); lines.push(` interface EmDashCollections {`); for (const collection of collections) { - const interfaceName = getInterfaceName(collection); - lines.push(` ${collection.slug}: ${interfaceName};`); + lines.push(` ${collection.slug}: ${interfaceNames.get(collection.slug)};`); } lines.push(` }`); lines.push(`}`); @@ -422,15 +428,57 @@ function pascalCase(str: string): string { .join(""); } +/** + * Naive singularization for slug-derived interface names. Handles the common + * English plural endings; intentionally simple, not a full inflector. + */ +function singularize(str: string): string { + if (str.endsWith("ies")) { + return str.slice(0, -3) + "y"; + } + if ( + str.endsWith("es") && + (str.endsWith("sses") || str.endsWith("xes") || str.endsWith("ches") || str.endsWith("shes")) + ) { + return str.slice(0, -2); + } + if (str.endsWith("s") && !str.endsWith("ss")) { + return str.slice(0, -1); + } + return str; +} + /** * Get the interface name for a collection. * * Derived from the slug, not the human label. Slugs are constrained to - * `/^[a-z][a-z0-9_]*$/` and are unique, so PascalCasing one always yields a - * valid TS identifier. Labels are arbitrary and user-controlled - * (punctuation, spaces, duplicates across collections), which produced - * syntactically invalid or duplicate interface names. + * `/^[a-z][a-z0-9_]*$/`, so PascalCasing one always yields a valid TS + * identifier; labels are arbitrary and user-controlled (punctuation, spaces, + * duplicates across collections), which produced syntactically invalid or + * duplicate interface names. The slug is singularized first because the + * interface describes a single entry, not the collection (`posts` -> `Post`). + * + * Singularization can map two distinct slugs onto the same name, so callers + * generating more than one interface must dedupe -- see `uniqueInterfaceNames`. */ function getInterfaceName(collection: CollectionWithFields): string { - return pascalCase(collection.slug); + return pascalCase(singularize(collection.slug)); +} + +/** + * Resolve interface names for a set of collections, guaranteeing each is + * unique within the file. Collisions (from singularization or PascalCasing + * collapsing distinct slugs) get a numeric suffix in collection order, so the + * generated `.d.ts` never declares two interfaces with the same identifier. + */ +function uniqueInterfaceNames(collections: CollectionWithFields[]): Map { + const seen = new Map(); + const names = new Map(); + for (const collection of collections) { + const base = getInterfaceName(collection); + const count = seen.get(base) ?? 0; + seen.set(base, count + 1); + names.set(collection.slug, count === 0 ? base : `${base}${count + 1}`); + } + return names; } diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index dcf593379..29950003c 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -548,9 +548,10 @@ describe("Zod Generator", () => { const ts = generateTypeScript(collection); - // Interface names derive from the slug (`blog_posts` -> `BlogPosts`), - // not the human label, so they are always valid TS identifiers. - expect(ts).toContain("export interface BlogPosts"); + // Interface names derive from the singularized slug + // (`blog_posts` -> `BlogPost`), not the human label, so they are + // always valid TS identifiers describing a single entry. + expect(ts).toContain("export interface BlogPost"); expect(ts).toContain("title: string;"); expect(ts).toContain("content: PortableTextBlock[];"); expect(ts).toContain("featured?: boolean;"); @@ -558,7 +559,7 @@ describe("Zod Generator", () => { }); }); - describe("interface names derive from the slug", () => { + describe("interface names derive from the singularized slug", () => { // A minimal collection factory: interface naming only depends on slug/labels. function makeCollection( slug: string, @@ -588,27 +589,29 @@ describe("Zod Generator", () => { expect(interfaceNamesOf(ts)).toEqual(["Book"]); }); - it("keeps names unique when two collections share a label", () => { - // Distinct slugs guarantee distinct interface names even when the - // human labels are identical (which previously collided). - const ts = generateTypesFile([ - makeCollection("book", { labelSingular: "Book" }), - makeCollection("books", { labelSingular: "Book" }), - ]); + it("keeps names unique when singularization collapses two slugs", () => { + // `book` and `books` both singularize to `Book`; the collision is + // resolved with a numeric suffix so the generated `.d.ts` never + // declares the same identifier twice. + const ts = generateTypesFile([makeCollection("book"), makeCollection("books")]); const names = interfaceNamesOf(ts); - expect(names).toEqual(["Book", "Books"]); + expect(names).toEqual(["Book", "Book2"]); expect(new Set(names).size).toBe(names.length); }); - it("PascalCases multi-word slugs", () => { + it("singularizes and PascalCases multi-word slugs", () => { expect(interfaceNamesOf(generateTypeScript(makeCollection("blog_posts")))).toEqual([ - "BlogPosts", + "BlogPost", ]); }); - it("leaves a plain slug unchanged", () => { - expect(interfaceNamesOf(generateTypeScript(makeCollection("pages")))).toEqual(["Pages"]); + it("singularizes a plural slug to describe a single entry", () => { + expect(interfaceNamesOf(generateTypeScript(makeCollection("pages")))).toEqual(["Page"]); + }); + + it("leaves an already-singular slug unchanged", () => { + expect(interfaceNamesOf(generateTypeScript(makeCollection("book")))).toEqual(["Book"]); }); it("references the same interface names in the EmDashCollections map", () => { @@ -621,8 +624,8 @@ describe("Zod Generator", () => { // keyed by slug -> interface name. expect(ts).toContain("export interface Book {"); expect(ts).toContain("book: Book;"); - expect(ts).toContain("export interface BlogPosts {"); - expect(ts).toContain("blog_posts: BlogPosts;"); + expect(ts).toContain("export interface BlogPost {"); + expect(ts).toContain("blog_posts: BlogPost;"); }); }); }); From 0f13ed981a4ab932552f163e0aec5bbdf9c40fc1 Mon Sep 17 00:00:00 2001 From: Malloo <26630797+MA2153@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:46:52 +0300 Subject: [PATCH 4/4] 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 --- .../core/src/astro/routes/api/schema/index.ts | 12 ++++++++--- packages/core/src/schema/zod-generator.ts | 20 ++++++++++++++----- .../tests/unit/schema/zod-generator.test.ts | 15 ++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/packages/core/src/astro/routes/api/schema/index.ts b/packages/core/src/astro/routes/api/schema/index.ts index beed2dff2..effc6d851 100644 --- a/packages/core/src/astro/routes/api/schema/index.ts +++ b/packages/core/src/astro/routes/api/schema/index.ts @@ -15,7 +15,7 @@ import { hashString } from "emdash"; import { requirePerm } from "#api/authorize.js"; import { handleError, requireDb } from "#api/error.js"; import { SchemaRegistry } from "#schema/registry.js"; -import { generateTypeScript } from "#schema/zod-generator.js"; +import { generateTypeScript, uniqueInterfaceNames } from "#schema/zod-generator.js"; export const prerender = false; @@ -45,8 +45,14 @@ export const GET: APIRoute = async ({ request, locals }) => { const format = url.searchParams.get("format"); if (format === "typescript") { - // Generate TypeScript definitions - const types = collectionsWithFields.map((c) => generateTypeScript(c)).join("\n\n"); + // Generate TypeScript definitions. Singularizing slugs can collapse + // distinct collections onto the same interface name, so resolve names + // up front to keep every emitted interface unique (`book` + `books` + // would otherwise both emit `Book`). + const interfaceNames = uniqueInterfaceNames(collectionsWithFields); + const types = collectionsWithFields + .map((c) => generateTypeScript(c, interfaceNames.get(c.slug))) + .join("\n\n"); const header = `// Generated by EmDash CLI // Do not edit manually - run \`emdash types\` to regenerate diff --git a/packages/core/src/schema/zod-generator.ts b/packages/core/src/schema/zod-generator.ts index 82538eaab..2a35ccb1d 100644 --- a/packages/core/src/schema/zod-generator.ts +++ b/packages/core/src/schema/zod-generator.ts @@ -470,15 +470,25 @@ function getInterfaceName(collection: CollectionWithFields): string { * unique within the file. Collisions (from singularization or PascalCasing * collapsing distinct slugs) get a numeric suffix in collection order, so the * generated `.d.ts` never declares two interfaces with the same identifier. + * + * The suffix is chosen against the set of names already emitted, not a + * per-base counter, so a generated name can't collide with another slug's + * base name (e.g. slugs `book`, `books`, `book2`: `books` -> `Book2` would + * clash with `book2`, so it advances to `Book3`). */ -function uniqueInterfaceNames(collections: CollectionWithFields[]): Map { - const seen = new Map(); +export function uniqueInterfaceNames(collections: CollectionWithFields[]): Map { + const used = new Set(); const names = new Map(); for (const collection of collections) { const base = getInterfaceName(collection); - const count = seen.get(base) ?? 0; - seen.set(base, count + 1); - names.set(collection.slug, count === 0 ? base : `${base}${count + 1}`); + let name = base; + let suffix = 2; + while (used.has(name)) { + name = `${base}${suffix}`; + suffix++; + } + used.add(name); + names.set(collection.slug, name); } return names; } diff --git a/packages/core/tests/unit/schema/zod-generator.test.ts b/packages/core/tests/unit/schema/zod-generator.test.ts index 29950003c..6a9d32965 100644 --- a/packages/core/tests/unit/schema/zod-generator.test.ts +++ b/packages/core/tests/unit/schema/zod-generator.test.ts @@ -600,6 +600,21 @@ describe("Zod Generator", () => { expect(new Set(names).size).toBe(names.length); }); + it("keeps names unique when a suffixed name collides with another slug", () => { + // `book` and `books` both singularize to `Book`, so `books` gets + // suffixed to `Book2` -- which is also exactly what `book2` produces. + // The dedupe must skip past an already-taken suffix, not blindly emit + // it, or the file declares `Book2` twice. + const ts = generateTypesFile([ + makeCollection("book"), + makeCollection("books"), + makeCollection("book2"), + ]); + + const names = interfaceNamesOf(ts); + expect(new Set(names).size).toBe(names.length); + }); + it("singularizes and PascalCases multi-word slugs", () => { expect(interfaceNamesOf(generateTypeScript(makeCollection("blog_posts")))).toEqual([ "BlogPost",