Skip to content

fix(typegen): derive interface names from slug, not label#1349

Open
MA2153 wants to merge 6 commits into
emdash-cms:mainfrom
MA2153:fix/typegen-interface-name-from-slug
Open

fix(typegen): derive interface names from slug, not label#1349
MA2153 wants to merge 6 commits into
emdash-cms:mainfrom
MA2153:fix/typegen-interface-name-from-slug

Conversation

@MA2153
Copy link
Copy Markdown
Contributor

@MA2153 MA2153 commented Jun 5, 2026

What does this PR do?

Generated TypeScript interface names in emdash-env.d.ts were derived from a collection's human display label (labelSingular) instead of its slug. Labels are arbitrary and user-controlled, which produced broken type output:

  • Invalid identifier — a label with spaces/punctuation leaked straight into the identifier, e.g. Book (do not use)export interface Book(donotuse) (parentheses are illegal in an identifier).
  • Duplicate identifier — two collections sharing a label collapsed to the same PascalCased name.

Both are rejected by astro check / tsc, breaking the generated emdash-env.d.ts.

The fix derives interface names from the slug instead. Slugs are constrained to /^[a-z][a-z0-9_]*$/ and are unique, so PascalCasing one always yields a valid identifier (blog_postsBlogPosts). The EmDashCollections augmentation map references the same names.

Closes #

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. — n/a, no admin UI strings changed (type-generation code only)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion — n/a, bug fix

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

Screenshots / test output

 Test Files  1 passed (1)
      Tests  25 passed (25)

🤖 Generated with Claude Code

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>
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 5, 2026

🦋 Changeset detected

Latest commit: 60fc425

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added area/core size/M review/needs-review No maintainer or bot review yet labels Jun 5, 2026
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 5, 2026

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1349

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1349

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1349

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1349

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1349

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1349

emdash

npm i https://pkg.pr.new/emdash@1349

create-emdash

npm i https://pkg.pr.new/create-emdash@1349

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1349

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1349

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1349

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1349

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1349

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1349

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1349

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1349

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1349

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1349

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1349

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1349

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1349

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1349

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1349

commit: 60fc425

Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(typegen): derive interface names from slug, not label

Approach judgment

Solving the right problem the right way. Deriving TypeScript interface names from the constrained, unique collection slug instead of the arbitrary human labelSingular eliminates the two real bugs reported: invalid identifiers from punctuation/spaces and duplicate identifiers from shared labels. The code is clean, well-tested, and the test suite directly covers the regression cases.

Headline concern: unacknowledged breaking change

Where this PR falls short is packaging. Removing singularize and dropping the labelSingular fallback changes interface names for every collection that previously relied on them — not just the broken edge cases. Examples from the existing test fixture and demos:

  • slug="pages" → old Page, new Pages
  • slug="blog_posts" with labelSingular="Blog Post" → old BlogPost, new BlogPosts

Any user code that directly imports or references a generated interface name (rather than going through the EmDashCollections map) will fail to compile after updating. That is a real breaking change. AGENTS.md says breaking changes need an explicit decision, a package bump, and a changeset that calls the break out clearly — the current changeset frames this as a straight patch fix and omits the breakage entirely.

Other issues found

  • Overly-optimistic JSDoc on getInterfaceName claims PascalCasing slugs "always yields a valid, collision-free TS identifier." The "valid" part is true (slugs are constrained), but "collision-free" is not: test_1 and test1 both map to Test1. The collision risk is low in practice, but the absolute guarantee is mathematically false.
  • Stale checked-in fixtures: all demo emdash-env.d.ts files and the integration test fixture still declare Page / Post (old singularized convention). After this PR the dev-server auto-generator will emit Pages / Posts, so developers running the demos or integration tests will get dirty git state. Regenerating or updating those fixtures before merge would avoid the drift.

Verdict

The implementation is correct and the tests are thorough. Address the changeset bump/callout before merge.

Comment thread .changeset/fix-typegen-interface-name-from-slug.md Outdated
Comment thread packages/core/src/schema/zod-generator.ts
Comment thread packages/core/src/schema/zod-generator.ts
- 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>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jun 5, 2026
@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented Jun 5, 2026

Using slug is better, but an interface should be singular: the type is for a single Post, not a collection of Posts

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>
@MA2153
Copy link
Copy Markdown
Contributor Author

MA2153 commented Jun 5, 2026

@ascorbic Agreed and updated. Wanted to know what you think first.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jun 5, 2026
Copy link
Copy Markdown
Contributor

@emdashbot emdashbot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review: what's fixed versus still open

The three prior findings are all resolved:

  1. Changeset & breaking change — Fixed. The changeset now bumps emdash to minor and explicitly calls out the rename as breaking.
  2. Overly-optimistic JSDoc — Fixed. getInterfaceName now accurately notes that singularization can cause collisions and callers must dedupe.
  3. Stale fixtures — Resolved by approach change. Re-introducing singularize keeps common slugs like pages / posts mapping back to Page / Post, so existing demo and test fixtures remain valid.

New issues from the latest commit

The singularize + dedupe approach is the right idea, but two gaps remain:

  1. uniqueInterfaceNames can still emit duplicates. The collision algorithm tracks counts per base name, but it never checks whether a deduped name like Book2 collides with another slug's base name. A site with slugs book, books, and book2 ends up with two Book2 interfaces.
  2. The CLI type-export endpoint (/_emdash/api/schema?format=typescript) was not updated. It still calls generateTypeScript in a loop without deduping. Before this PR the labelSingular fallback often prevented collisions; now singularize(slug) makes them deterministic. Running emdash types on a project with book + books collections will emit duplicate Book interfaces and break the user's build.

Both should be addressed before merge.

…I export

Address review on emdash-cms#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>
@MA2153
Copy link
Copy Markdown
Contributor Author

MA2153 commented Jun 5, 2026

Both findings from the re-review are addressed in 0f13ed9:

  1. uniqueInterfaceNames could emit duplicates — the dedupe now picks suffixes against the set of names already emitted, not a per-base counter. For slugs book/books/book2, books advances past the taken Book2 to Book3. Added a regression test asserting all emitted names are unique for that case.

  2. CLI type-export endpoint not deduped/_emdash/api/schema?format=typescript now resolves names via uniqueInterfaceNames before generating, matching generateTypesFile, so emdash types on a project with colliding singularized slugs no longer emits duplicate interfaces.

Tests pass (28), lint clean, typecheck clean.

@ascorbic
Copy link
Copy Markdown
Collaborator

ascorbic commented Jun 5, 2026

I think these failing tests may be from something else

@MA2153
Copy link
Copy Markdown
Contributor Author

MA2153 commented Jun 5, 2026

@ascorbic Green

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/core bot:review Trigger an emdashbot code review on this PR cla: signed review/needs-rereview Author pushed changes since the last review size/M

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants