# CMS configuration: content, collections, menus, endpoints Read this when working with EmDash content after the site is deployed and set up: rendering the data model correctly, adding a new collection to an already-live site, wiring menus/widgets/scheduled publishing, adding custom API routes, or reading a plugin's own D1 table. ## Rendering CMS content (deploy-time symptoms) Two rendering bugs commonly surface right after a deploy — they live here because that's *when* you hit them, but the full rendering model (entry envelopes, `` with no `.src`, `Astro.cache`/`cacheHint`) is owned by the **`building-emdash-site`** skill. Read that for the model; use the two symptom rows below to unblock a deploy. - **Blank post (title/body empty but the slug/URL works):** `getEmDashCollection`/`getEmDashEntry` (from `emdash`) return an **entry envelope** `{ id, slug, status, data, cacheHint, edit }` — the field values are under **`.data`**. Read `entry.data.title`, not `entry.title` (unwrap once with `const posts = entries.map(e => e.data ?? e)`). Reading the envelope directly renders empty while the link still works, which masquerades as a CMS/data bug but is a template bug. - **Admin "preview" / permalink 404s:** the collection's **`url_pattern`** is null, so EmDash defaults to `//{slug}` (e.g. `/posts/...`). Set it to your real route (e.g. `/blog/{slug}`, placeholders `{slug}`/`{id}`) in `seed/seed.json` *and* the live `_emdash_collections` row. ## Adding a new collection to a live site (a 2nd content type) `seed/seed.json` applies on **first boot only** (or an explicit `emdash seed`) — it will **not** retro-add a collection to an already-seeded live DB. To add a second content type (e.g. `events` alongside `posts`) to a running site, provision it on the live DB with the **EmDash CLI**, then ship the templates: ```bash export EMDASH_TOKEN=$(grep '^EMDASH_TOKEN=' .env | cut -d= -f2-) ec() { node node_modules/emdash/dist/cli/index.mjs "$@"; } # or: npx emdash URL=https://. ec schema create events --label "Events" --label-singular "Event" --url "$URL" ec schema add-field events title --type string --label "Title" --required --url "$URL" ec schema add-field events event_date --type datetime --label "Start" --required --url "$URL" ec schema add-field events location --type string --label "Location" --url "$URL" ec schema add-field events description --type portableText --label "Description" --url "$URL" # field types: string text number integer boolean datetime image reference portableText json ec content create events --file ev.json --slug --url "$URL" # auto-publishes ``` Gotchas, in the order they bite: - **`schema create` makes an EMPTY collection** — no implicit `title`. Add every field (including `title`) via `add-field`. - **`url_pattern` defaults to `//{slug}`.** A collection named `events` with routes at `src/pages/events/[slug].astro` needs **no** override — admin preview/permalinks resolve out of the box. (Contrast `posts` → `/blog/{slug}`, which *does* need the override; see Rendering CMS content above.) - **Provision the collection in the live DB BEFORE deploying templates that call `getEmDashCollection('')`.** `astro build` does **not** execute SSR pages, so the build passes even when the collection doesn't exist yet — the failure surfaces only at runtime as a 500. Create the schema first, then `wrangler deploy`. - **`datetime` field validator is `z.string().datetime().or(z.string().date())`** — accepts ISO-with-`Z` (`2026-08-09T15:30:00.000Z`) **or** a bare `YYYY-MM-DD`, and **rejects `±HH:MM` offsets** (`...-05:00` → `event_date: Invalid input`). Store UTC `Z`; format for display in the page (`toLocaleString('en-US', { timeZone: 'America/Chicago' })`), and render bare all-day dates with `timeZone: 'UTC'` so the day doesn't slip backward when the worker runs in UTC. **For content spanning timezones, don't hardcode one display zone** — add a per-record IANA `timezone` string field (e.g. `America/Phoenix`), format with `timeZone: data.timezone || ''`, and pass `timeZoneName: 'short'` so the abbreviation shows (`11:00 AM MST` vs `9:00 AM CDT`) and removes the ambiguity. - **Don't `orderBy` a custom field.** `getEmDashCollection`'s `orderBy` is reliable for system columns (`published_at`); a custom field like `event_date` isn't guaranteed orderable. Fetch with a generous `limit` and sort/split in JS. - **portableText fields auto-convert markdown.** Pass `description` to `content create` as a markdown **string** → EmDash stores PT; the render path (and `content get --raw`) returns the **PT array** for ``, while a plain `content get` shows the round-trip markdown source (looks like "it didn't convert" — it did). Pass an **array** to send raw PT (custom blocks). - **Mirror the collection into `seed/seed.json`** (collection def + a sample item) for fresh-install parity, even though it won't auto-apply to the live DB. - **Route-scoped CSS:** copy the `blog.css` + `BlogHead.astro` pattern — a `public/.css` injected via the Layout `head` slot on those routes only. A plain linked stylesheet **cannot** use Astro `:global(...)`; that's scoped-`