Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-typegen-interface-name-from-slug.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 9 additions & 3 deletions packages/core/src/astro/routes/api/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
61 changes: 53 additions & 8 deletions packages/core/src/schema/zod-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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} {`);
Expand Down Expand Up @@ -320,18 +322,22 @@ 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(``);
}

// Generate the Collections interface for module augmentation
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(`}`);
Expand Down Expand Up @@ -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")) {
Expand All @@ -442,8 +449,46 @@ function singularize(str: string): string {
}

/**
* Get the interface name for a collection
* Get the interface name for a collection.
*
Comment thread
MA2153 marked this conversation as resolved.
* 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 {
Comment thread
MA2153 marked this conversation as resolved.
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<string, string> {
const used = new Set<string>();
const names = new Map<string, string>();
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;
}
89 changes: 89 additions & 0 deletions packages/core/tests/unit/schema/zod-generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
generateFieldSchema,
validateContent,
generateTypeScript,
generateTypesFile,
clearSchemaCache,
} from "../../../src/schema/zod-generator.js";

Expand Down Expand Up @@ -547,11 +548,99 @@ 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[];");
expect(ts).toContain("featured?: boolean;");
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> = {},
): 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;");
});
});
});
Loading