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..9676cb899 --- /dev/null +++ b/.changeset/fix-typegen-interface-name-from-slug.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +**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/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 bf7842a67..2a35ccb1d 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(`}`); @@ -423,7 +429,8 @@ function pascalCase(str: string): string { } /** - * Simple singularization - handles common cases + * 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")) { @@ -442,8 +449,46 @@ function singularize(str: string): string { } /** - * 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_]*$/`, 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.labelSingular || singularize(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. + * + * 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`). + */ +export function uniqueInterfaceNames(collections: CollectionWithFields[]): Map { + const used = new Set(); + const names = new Map(); + for (const collection of collections) { + const base = getInterfaceName(collection); + 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 4bc365739..6a9d32965 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,6 +548,9 @@ describe("Zod Generator", () => { const ts = generateTypeScript(collection); + // 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[];"); @@ -554,4 +558,89 @@ describe("Zod Generator", () => { expect(ts).toContain('status: "draft" | "published";'); }); }); + + describe("interface names derive from the singularized 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 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", "Book2"]); + 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", + ]); + }); + + 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", () => { + 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 BlogPost {"); + expect(ts).toContain("blog_posts: BlogPost;"); + }); + }); });