Commit Graph

58 Commits

Author SHA1 Message Date
Noah (Nguyen Pham) 3ceabc4746 fix(core): reconcile media usage indexes automatically (#2443)
* feat(core): add guarded collection deletion foundation

* feat(core): safely detach activated collections

* feat(core): process bounded collection deletion cleanup

* feat(core): expose collection deletion recovery controls

* fix(cloudflare): harden collection deletion guards

* fix(core): bound collection deletion completion checks

* fix(core): use database time for deletion progress

* chore(core): register collection deletion schemas

* chore(core): keep deletion lease guards transaction-scoped

* fix(core): preserve collection deletion compatibility

* fix(cloudflare): pin mutation reads to DO primary

* feat(media): add reconciliation coordinator state

* feat(media): add bounded reconciliation scan

* feat(media): finalize automatic reconciliation

* feat(media): schedule automatic reconciliation

* fix(media): preserve scheduled maintenance compatibility

* docs: clarify automatic reconciliation changeset

* fix(core): guard null media usage revisions on postgres

* fix(cloudflare): default scheduled cron routing

* docs(cloudflare): keep worker comments current
2026-08-13 13:29:05 +01:00
Daniel dffe2cba74 fix(skills): correct the Block Kit examples that break the admin page (#2446)
* fix(skills): correct the Block Kit examples that break the admin page

Five of the fourteen block examples in the creating-plugins skill describe
a shape that packages/blocks does not accept. Stats puts its cards under
`stats` where StatsBlock declares `items`, and Columns wraps each column in
an object where ColumnsBlock declares an array of blocks — both crash the
admin renderer, which calls .map() on the missing value. Table omits the
required page_action_id, and three button examples use `text` where
ButtonElement declares `label`.

These files ship inside every generated project and are read by coding
agents that cannot check a rendered page before shipping, so an example
that disagrees with the types is copied straight into a broken plugin.
Running every example through validateBlocks() now reports 0 of 14 failing,
down from 5.

* test(blocks): guard the Block Kit reference examples against type drift

The corrected examples were checked once by hand, so nothing stops them
from drifting away from the declared block and element shapes again. Every
JSON example in the reference now runs through validateBlocks; reverting
the reference to its previous state fails six of them.
2026-08-13 09:25:26 +00:00
Matt Kane f6385dab2c refactor(cli): deprecate emdash dev (#2405)
* refactor(cli): remove dev command

* refactor(cli): deprecate dev command
2026-08-10 13:41:21 +01:00
Scott Buscemi 534f23884f fix(ui): move Comments/CommentForm to emdash/ui/comments subpath (#2039) (#2044)
* fix(ui): move Comments/CommentForm to emdash/ui/comments subpath (#2039)

Their <style> blocks were pulled into a shared, render-blocking CSS chunk
on every page that imported anything from the emdash/ui barrel (e.g.
PortableText), because Astro scans the whole barrel module graph. Splitting
them into a dedicated entry point keeps comment CSS off pages that don't
render comments, mirroring the existing emdash/ui/search entry point.

* fix(ui): keep barrel Comments exports as deprecated (#2039)

Address ascorbic review: add emdash/ui/comments without breaking existing
emdash/ui imports. Mark barrel re-exports @deprecated (remove in 1.0),
allowlist the subpath in the public-source guard, and sync template skills.
2026-08-08 11:39:42 +01:00
logelog f81aa6842c fix(core): compute taxonomy counts on demand (#2219)
* fix(core): compute taxonomy counts on demand

* fix(core): version taxonomy term-list cache key

---------

Co-authored-by: logelog <194732487+logelog@users.noreply.github.com>
2026-07-24 15:00:40 +01:00
Cozmocat 76d8943578 fix: add spacing before Admin link in blog template (#1968)
* fix: add spacing before Admin link in blog template

The Admin link currently renders directly after the final navigation item as `PostsAdmin`.

This adds spacing before the Admin link in the blog template while preserving the existing mobile layout.

The change is made in `theme.css` so it can flow to the Cloudflare template through the normal template sync.

* style: format admin navigation spacing

* fix: sync admin nav spacing to cloudflare template

* style: format admin navigation spacing

Use the repository’s tab-based indentation for the Admin navigation spacing rule.

* fix: add admin nav spacing to cloudflare template

Apply the same Admin navigation spacing rule to the Cloudflare blog template as requested by the PR review.

* fix: add admin nav spacing to cloudflare template

Apply the same Admin navigation spacing rule to the Cloudflare blog template as requested by the PR review.

* style: use tabs for admin navigation spacing

Format the Admin navigation spacing rule with the repository-standard tab indentation in both blog theme.css files. No behavior change.

* style: use tabs for Cloudflare admin navigation spacing

Match the Cloudflare blog template’s Admin navigation rule to the repository’s tab indentation convention. No behavior change.

* style: use tabs for Cloudflare admin navigation spacing

Match the Cloudflare blog template’s Admin navigation rule to the repository’s tab indentation convention. No behavior change.
2026-07-17 12:12:24 +00:00
Matt Kane 138bb2faa2 chore: bump oxfmt to 0.58.0 and pin CI npx version (#1869)
Runs the format pass with the updated formatter and pins oxfmt in
the auto-format and format-command workflows so npx doesn't silently
fetch a newer version than what's used locally.
2026-07-08 06:42:23 +00:00
Matt Kane ee5bfe6b47 Use Astro Image for local EmDash media (#1837)
* test(core): cover image rendering edge cases

* feat(core): use Astro image for local media

* ci: update query-count snapshots

* fix(core): preserve image fallback sizing

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-07-06 20:03:21 +01:00
Matt Kane f03f44fd0d feat(templates): migrate portfolio to the semantic theme-token architecture (#1733)
* feat(templates): migrate portfolio to the semantic theme-token architecture

Same treatment as blog/marketing: tokens move to src/styles/tokens.css
as @layer base defaults with light-dark() colors and a @supports
fallback for pre-light-dark browsers; theme.css becomes a pure override
file; the theme switcher pins color-scheme instead of maintaining
triplicated palettes. Semantic names: --font-heading (was --font-serif,
incl. astro.config cssVariable in both variants), --font-body (was
--font-sans), --color-brand (was --color-accent) with --color-on-brand
and --color-brand-ring replacing hardcoded white and the frozen violet
focus ring, and --color-danger fixing form errors that ignored the
theme toggle. Adds weight/leading/tracking tokens, drops the unused
--color-accent-muted and --font-mono, and strips ProjectCard's inline
var() fallbacks that would mask a broken token.

* fix(templates): guard portfolio color-scheme pins behind @supports

Same fix as on the blog/marketing branch: without the guard, browsers
on the plain-light fallback palette get dark UA form controls when the
toggle sets the dark cookie.
2026-07-02 21:03:15 +01:00
Matt Kane 92fd412272 fix(skills): correct audit-log example, re-sync template skills, stop .gitignore clobber (#1732)
* fix(skills): correct audit-log plugin example and re-sync template skills

The plugin registration example in building-emdash-site showed a named
auditLogPlugin() factory that has never existed -- the package's only
export is a default plugin descriptor, matching what the template
astro.configs already do. Re-running sync-template-skills.sh also
propagates the ctx.input guidance (#1555) and trailing-slash page
resolution note (#1305) that landed in skills/ without a re-sync.

sync-cloudflare-templates.sh no longer copies .gitignore over the
cloudflare variants, which was erasing their wrangler-specific
.dev.vars entries on every run.

* fix(docs): address Copilot review findings on plugin examples

Cast Block Kit interactions to the real BlockInteraction union from
@emdash-cms/blocks instead of an ad-hoc shape that under-typed
form_submit and omitted page_load's page field. Fix the emdash()
plugins JSDoc example in core, which showed the same nonexistent
auditLogPlugin()/webhookNotifierPlugin() factory API the skills doc
did -- both plugins are default-export descriptors.
2026-07-02 21:02:33 +01:00
Matt Kane ba4df8cb4c feat(templates): retheme-from-theme.css architecture for blog and marketing (#1731)
* feat(templates): restructure blog and marketing theming around semantic tokens

Design tokens move to src/styles/tokens.css as real @layer base defaults;
theme.css becomes a pure unlayered override file. Colors are defined once
with light-dark() so one override rethemes both modes, replacing the
triplicated light/media-dark/.dark palettes. Semantic names: --font-body/
--font-heading, --color-brand (+ on-brand, ring), marketing --gradient-*
and --color-danger tokens, heading weight tokens. Marketing blocks share
.section-header/.icon-tile utilities and the nav CTA composes .btn.

* fix(templates): review fixes for light-dark fallback and CTA transition

Add a @supports not (light-dark) block to tokens.css so browsers
without light-dark() (Safari < 17.5, Chrome < 123) get the plain light
palette instead of invalid-at-computed-value tokens -- without it the
marketing gradient-text spots (hero headline, logo, 404) rendered as
invisible text. Also add transform to .btn-primary's transition list so
the nav CTA hover lift animates instead of snapping.

* chore: drop changeset for ignored template packages

Template packages are private and in the changesets ignore list; a
changeset naming them is never released and can break changeset
version.

* fix(templates): address bot review findings on theme pinning and marketing links

Wrap the .light/.dark color-scheme pins in @supports (light-dark) so
browsers on the plain-light fallback palette don't get dark UA form
controls when the toggle sets the dark cookie (Copilot). Guard the
marketing link reset against emdash/ec- classed elements like the blog
does, and give classless inline links in content (Portable Text renders
bare <a> tags) a visible brand-colored underline instead of inheriting
currentColor with no decoration (emdashbot).
2026-07-02 21:02:09 +01:00
ttmx facdcfc745 feat(core): add content schedule hooks (#1659)
* feat(core): add content schedule hooks

* Address schedule hook review

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-07-01 12:00:27 +01:00
ttmx 1f4aa59a28 Add after-restore hook (#1658)
* refactor(core): return restored content item

* feat(core): add content restore hook

* docs: add content restore hook docs
2026-06-30 10:52:41 +01:00
marcusbellamyshaw-cell 2153928805 fix: set urlPattern "/{slug}" on the pages collection in starter seeds (#1062) (#1575)
The starter and starter-cloudflare templates ship a root `[slug].astro`
route for pages, but the seeded pages collection had no `urlPattern`, so
the admin "View" link and visual-editing overlay pointed at a URL that
didn't match the route. Declaring the pattern aligns the collection with
the route the template already provides.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 08:51:51 +01:00
Matt Kane c39789c383 fix(scheduling): drive scheduled publishing from a real heartbeat (#1312)
* fix(scheduling): drive scheduled publishing from a real heartbeat (#1303)

Scheduled content never transitioned to published: nothing called the
publishing machinery. Add a publishDueContent sweep (sibling to system
cleanup) that promotes due content, driven by a real heartbeat rather than
request side effects.

- Node: the timer-based scheduler runs the sweep alongside its tick.
- Cloudflare: a scheduled() handler wired to a Cron Trigger runs
  EmDashRuntime.runScheduledTasks(); the request-driven PiggybackScheduler
  is removed.

Ship @emdash-cms/cloudflare/worker (default export = Astro handler + the
scheduled() handler, re-exporting PluginBridge) plus createScheduledHandler().
The adapter is externalized so its build-time virtual resolves in the
consuming app's Astro build. The handler purges edge-cache tags for published
content via the configured Astro cache provider.

Templates collapse worker.ts to a one-line re-export and add the Cron Trigger.

* refactor(scheduling): inject the scheduler from the platform, harden the publish sweep

Replace the navigator.userAgent "Cloudflare-Workers" runtime sniff in
EmDashRuntime with a build-time createScheduler factory injected via a new
virtual:emdash/scheduler module (parallel to virtual:emdash/wait-until). The
adapter decision lives in the integration, keyed off astroConfig.adapter?.name
and the Vite command, so core has no Cloudflare-specific runtime path. Local
astro dev keeps the Node timer even under the Cloudflare adapter, where
production relies on the Cron Trigger.

Also harden the scheduled-publish sweep, per adversarial review:

- Fire content:afterPublish hooks by routing the sweep through the runtime
  wrapper instead of the raw DB handler (search indexing, webhooks, etc.).
- Record the scheduled time as published_at on first publication rather than
  the later sweep time.
- Claim each due row atomically with a single conditional UPDATE before
  promoting it, preventing publish-after-unschedule and double-publish across
  overlapping sweeps. Restore the schedule if post-claim work fails on a
  driver without transactions (D1) so the row stays retryable.

* style: format

* fix(scheduling): bound the sweep, invalidate cache incrementally, cap Node latency

Hardening from a second adversarial review pass:

- Bound publishDueContent to 100 items per collection per tick
  (findReadyToPublish gains a LIMIT); a large backlog drains over
  successive ticks instead of exhausting a Worker invocation's
  CPU/subrequest budget in one unbounded pass.
- Invalidate edge-cache tags incrementally after each collection's
  batch via a new onPublished callback, rather than once at the very
  end. A scheduled() invocation killed mid-sweep no longer strands
  already-published content behind stale cache.
- Cap NodeCronScheduler's max poll interval at 60s so scheduled-publish
  latency matches the Cloudflare Cron Trigger cadence instead of lagging
  up to five minutes when no plugin cron is due sooner.
- Restore updated_at (not just scheduled_at) on the no-transaction
  failed-publish path, avoiding a phantom modification timestamp.
- Document that the restore uses the pre-claim scheduled_at snapshot.

Adds tests for the per-collection limit, incremental onPublished
batching, non-fatal onPublished failure, and updated_at restoration.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-06-12 14:26:36 +01:00
Matt Kane 7ee9467719 perf(core): fix query waterfalls — getTerm parallelism, hydrated terms reuse, menu pattern caching (#1409)
* perf(core): remove three query waterfalls on content renders

- getTerm() runs its usage-count and children queries under Promise.all
  (both depend only on the term row), saving a round trip on every
  tag/category archive page.
- getMenu() request-caches the _emdash_collections url_pattern lookup,
  so pages rendering multiple menus only pay for it once per request.
- Templates/demos no longer call getEntryTerms() per post detail page:
  getEmDashEntry already hydrates entry.data.terms (keyed by taxonomy
  name) via getAllTermsForEntries. The type generator now emits the
  hydrated terms field so templates can use it type-safely, and the
  checked-in emdash-env.d.ts files for the touched sites are updated
  to match.

Query-count snapshots will change in CI (fewer queries per route).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: user-facing changeset wording

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:38:33 +01:00
Matt Kane 792f73c12c chore: bump pnpm to 11.1.3 and pin scaffolded sites to a recent pnpm (#1115)
- Bump root packageManager via `corepack use pnpm@latest`.
- Sync script bakes the root's packageManager into each template's
  package.json, so scaffolded sites auto-track the monorepo pin.
- create-emdash strips packageManager when the user picks npm/yarn/bun
  so corepack doesn't force pnpm on a non-pnpm user.
- Drop dead `pnpm.onlyBuiltDependencies` from demo/fixture/template
  package.json files; pnpm 11 ignores `package.json#pnpm` and the root
  `allowBuilds` already covers these binaries.
- AGENTS.md / auto-implementer.md: drop `--silent` from lint commands;
  pnpm 11 prints the `$ command` line to stderr, so JSON pipes cleanly
  without it.
2026-05-20 08:13:53 +01:00
Matt Kane c0ce915c55 feat(plugin-cli): sandboxed plugin authoring CLI (#1057)
* feat(registry-cli): extend manifest schema with identity + trust contract

First phase of the sandboxed plugin redesign (#1028b). Adds the
manifest fields that make `src/index.ts` and the in-code descriptor
factory redundant. The trust contract is now hand-authored in the
manifest, where a security reviewer can find it without grep.

New required fields:

- `slug`: ASCII letter then letters/digits/hyphens/underscores, max 64
  chars. Matches the registry lexicon's rkey grammar via the shared
  PLUGIN_SLUG_RE in @emdash-cms/plugin-types.
- `version`: semver 2.0 subset, no build-metadata (atproto rkeys can't
  contain `+`). Validated via PLUGIN_VERSION_RE.
- `publisher`: now required (was optional in #1028a). The runtime
  cannot compute the plugin's AT URI without it; making it optional
  meant the plugin couldn't load locally before first publish.

New optional fields with sensible defaults:

- `capabilities`: array of capability strings. Defaults to []. Each
  entry validated against the current vocabulary; deprecated names are
  hard-rejected with a hint at the replacement (no deprecation window
  for new authoring).
- `allowedHosts`: array of host patterns. Defaults to []. Required
  non-empty when `network:request` is declared without
  `:unrestricted`. Forbidden when `:unrestricted` is declared.
- `storage`: map of collection name -> { indexes, uniqueIndexes? }.
  Defaults to {}.

The cross-field rule for network:request / allowedHosts mirrors the
release-extension lexicon's networkRequestConstraints behaviour, so
authors hit the schema error here rather than a PDS validation error
at publish time.

Schema regenerated. 33 new tests; 204 total passing.

Part of #1028b. The bundle rewrite, init command, plugin migrations,
and `localPlugin` dev helper land in subsequent commits.

* feat(registry-cli): bundle reads identity + trust contract from manifest

Second phase of the sandboxed plugin redesign (#1028b). Bundle no longer
imports src/index.ts for a descriptor factory; the manifest is the
source of truth for identity (slug, version) and the trust contract
(capabilities, allowedHosts, storage). Bundle still probes the runtime
code for the hook/route surface — that's a syntactic property that
needs the code to exist.

Changes to bundle:

- Drop the main-entry build and descriptor extraction. No more
  src/index.ts probing, no more `createPlugin` / default-factory /
  default-object format detection.
- Replace `resolveEntries`: just locates emdash-plugin.jsonc (loaded
  through the same loader the CLI's validate uses) and confirms
  src/plugin.ts exists. No more package.json `exports` parsing.
- Replace `extractResolvedPlugin` with `assembleResolvedPlugin`: builds
  the ResolvedPlugin shape from the manifest, then probes
  src/plugin.ts for hook/route names.
- Probe (renamed from `augmentWithSandboxProbe` to `probePluginSurface`)
  now reads src/plugin.ts. Hard-fails if the default export isn't a
  definePlugin result.
- New error codes: MISSING_MANIFEST, MISSING_PLUGIN_ENTRY,
  MANIFEST_INVALID. Old MISSING_PACKAGE_JSON / MISSING_ENTRYPOINT /
  MAIN_BUILD_FAILED gone.
- Admin entry handling (admin.js, adminPages, adminWidgets) deferred
  to a follow-up issue. The redesign hasn't touched admin yet; that
  surface stays as-is and is gated on the descriptor's `admin` field
  which no longer exists. When admin lands again it'll be a manifest
  field with its own probe.

Changes to translate.ts:

- `NormalisedManifest` gains slug, version, publisher (required),
  capabilities, allowedHosts, storage. Publisher is no longer
  Optional — the schema enforces it.

Fixtures:

- `minimal-plugin/`: src/index.ts gone, sandbox-entry.ts renamed to
  plugin.ts, new emdash-plugin.jsonc with identity + trust contract.
- `bad-plugin/`: stripped to manifest-only (no src/), exercises
  MISSING_PLUGIN_ENTRY. Old "declares hooks but no sandbox entry"
  case isn't possible anymore — there's no descriptor declaring
  anything.

Net diff: -228 lines.

* feat(registry-cli): init command scaffolds a sandboxed plugin

Third phase of the redesign (#1028b). Adds `emdash-registry init [name]`
which produces the three-file plugin layout introduced by the previous
commits: emdash-plugin.jsonc, src/plugin.ts, package.json, plus a
tsconfig, README, .gitignore, and a passing test.

Modes:
- Interactive (default on a TTY): clack prompts for each unset field
  with sensible defaults. ESC / Ctrl+C cancels cleanly.
- `--yes` / `-y` (non-interactive): no prompts; unset fields become
  TODO placeholders in the manifest. The author fixes them before
  first use.
- Non-TTY (CI, pipes): same as `--yes`; prompting into a non-
  interactive stdin would hang.

Pre-fills:
- Publisher: the active session's handle from FileCredentialStore.
  Resolved through @atcute/identity-resolver to a DID before write
  so the runtime never sees a mutable handle. The handle is emitted
  as a `// <handle>` line comment next to the pinned DID for `git
  diff` readability — same convention as the post-publish write-back.
- Author name / email: `git config user.name` / `user.email`.
- Repo: `git remote get-url origin`, normalised from SSH to https
  (`git@github.com:foo/bar.git` → `https://github.com/foo/bar`).
  Falls back to `package.json#repository.url` if no git remote.
- License, description: `package.json` in the target dir if one
  exists (for the "scaffold into existing repo skeleton" case).

Slug defaults to the positional `name`, `basename(--dir)`, or
basename(cwd) in that order. Every flag is optional in every mode.

Exported `resolveHandleToDid` from manifest/publisher.ts so init
can use the same resolver the post-publish write-back does.

Tests: 44 new (template renderers, scaffold filesystem behaviour,
environment probe). 249 total in the package.

* feat(plugins): migrate in-tree sandboxed plugins to the new layout

Fourth phase of the redesign (#1028b). Moves the 5 in-tree sandboxed
plugins to the manifest + src/plugin.ts shape so they become the
canonical references a plugin author looks at.

Each plugin's layout changes from:

  src/index.ts          (descriptor factory, ~50 lines)
  src/sandbox-entry.ts  (runtime code via definePlugin)
  package.json          (main / exports / files / build scripts)

to:

  emdash-plugin.jsonc   (identity + trust contract + admin surface)
  src/plugin.ts         (runtime code, unchanged)
  package.json          (private, typecheck script only)

Plugins migrated:
- atproto
- audit-log
- marketplace-test
- sandboxed-test
- webhook-notifier

Schema gains `admin` (pages + widgets) since four of the five plugins
declare admin surface. Mirrors PluginAdminPage / PluginDashboardWidget
in core. Atproto's plugin.test.ts rewritten to assert against the
manifest instead of the deleted descriptor factory.

KNOWN BREAKAGE: demos that import the old factories
(`auditLogPlugin()`, `webhookNotifierPlugin()`) from
astro.config.mjs are broken until the next commit ships
`@emdash-cms/registry-cli/dev`'s `localPlugin(dir)` helper and
updates the demos.

All published plugins still work — the bundled manifest.json shape
is unchanged. Only authoring changed.

* feat(registry-cli): add localPlugin(dir) dev helper + wire demos

Final piece of the sandboxed-plugin redesign (#1028b). Closes the gap
the plugin migrations opened — demos that previously imported
`auditLogPlugin()` / `webhookNotifierPlugin()` factories now consume
the plugins through their source directories.

New subpath `@emdash-cms/registry-cli/dev` exports `localPlugin(dir)`,
which:

- Reads `<dir>/emdash-plugin.jsonc` via the same loader the CLI uses.
- Confirms `<dir>/src/plugin.ts` exists.
- Resolves the manifest's publisher (handle → DID) so the descriptor
  is in canonical form.
- Returns a PluginDescriptor-shaped object with `entrypoint` set to
  the absolute `file://` URL of `src/plugin.ts`. Vite resolves the
  URL through its standard fs path resolver — no build step needed.

The descriptor carries id, version, capabilities, allowedHosts,
storage, and (when declared) adminPages + adminWidgets from the
manifest. Plugins that don't expose admin surface pass through
without the optional fields, keeping the descriptor tidy.

Demos updated:
- demos/simple: auditLogPlugin() → localPlugin("../../packages/plugins/audit-log")
- demos/plugins-demo: auditLog + webhookNotifier the same way
- demos/cloudflare: webhookNotifier via localPlugin
- infra/cache-demo, infra/blog-demo: same

Trusted plugins (formsPlugin, embedsPlugin, apiTestPlugin) keep their
factory-based imports — they're not on the new shape and aren't part
of this redesign's scope.

Errors surface as a structured LocalPluginError with codes:
- MANIFEST_INVALID
- PLUGIN_ENTRY_MISSING
- PUBLISHER_UNRESOLVED

Tests: 10 new (descriptor shape, error paths, admin pass-through).
259 total in the package.

* feat(plugin-cli): rework sandboxed plugin authoring, build, and CLI

Renames @emdash-cms/registry-cli to @emdash-cms/plugin-cli and the
binary emdash-registry to emdash-plugin. Adds build + dev commands,
consolidates the build pipeline so bundle is a thin packaging step on
top of build. Introduces a strict author-facing SandboxedPlugin type
via the new emdash/plugin type-only subpath; sandboxed plugins now
default-export a bare { hooks?, routes? } object with satisfies
SandboxedPlugin and have no runtime emdash import. Drops definePlugin
and the build shim for sandboxed plugins (definePlugin is native-only
now). Migrates the five in-tree sandboxed plugins to the new shape.
Manifest version is optional and reconciled with package.json#version.

* fix(plugin-cli): adversarial review fixes

- init scaffold emits the new `satisfies SandboxedPlugin` shape and
  npm-shape package.json (build/dev scripts, ./sandbox export, plugin-cli
  devDep) instead of the broken `definePlugin` template
- publish reads package.json#version and reconciles via normaliseManifest
  so the new "version in package.json only" pattern actually publishes;
  malformed package.json surfaces a CliError, not a misleading
  VERSION_MISSING further down
- dev watcher serialises rebuilds (queue collapsed to one follow-up),
  closes the watcher before draining pending on Ctrl-C, short-circuits
  scheduleRebuild during shutdown, handles Windows path separators in
  the outDir ignore glob, clears pending+queuedTrigger in finally so an
  IIFE rejection can't deadlock the session, and removes SIGINT handlers
  on shutdown
- adapter normalises ctx.request to SandboxedRequest shape in-process
  so handlers see the same { url, method, headers: Record } promised by
  the strict type; null/array/non-object default exports rejected with
  a plugin-id-bearing message
- build's readPackageMeta rejects empty/non-string version with the
  same strictness as publish, killing the build-pass/publish-fail
  asymmetry
- pipeline probe rejects invalid hook config (errorPolicy, priority,
  timeout) so untyped JS authors get a build error rather than a
  silently-wrong runtime contract
- versionless minimal-plugin fixture so bundle/publish/build integration
  tests exercise the package.json-as-source-of-truth path
- definePlugin error wording softened for native-plugin authors whose
  id field has a typo
- pipeline error messages and stale comments updated for the no-shim,
  no-definePlugin authoring shape
- removed dead EMDASH_SHIM from the Cloudflare sandbox runner
- changesets retargeted to @emdash-cms/plugin-cli; scaffold/atproto/core
  comments scrubbed for stale registry-cli references

* style: format

* docs(changesets): switch plugin migration examples to diff fences

* style: format

* Fix changeset ordering

* fix(ci): plugin build uses node-direct path; sweep stale registry-cli refs

In-workspace plugins use `node node_modules/@emdash-cms/plugin-cli/dist/index.mjs build`
because pnpm doesn't create the bin shim for a workspace package whose
bin target doesn't exist at install time. Plugin authors outside the
workspace get a published bin with a real dist, so `emdash-plugin build`
works for them via the natural scaffold.

Also fixes stale registry-cli references the rename pass missed:
- .oxfmtrc.json: schema ignore path
- .oxlintrc.json: 7 type-aware-cost allowlist entries
- .github/workflows/ci.yml: build filter includes plugin-cli for test:unit
- package.json: test:unit script
- packages/plugin-types/package.json: description

The schema file is regenerated to match what gen-schema produces. The
previously committed version had been hand-reformatted post-regen and
disagreed with the generator's output.

* fix(ci): remove legacy marketplace bundle path; address review findings

- Delete `packages/marketplace/tests/publish-e2e.test.ts` — invoked the
  legacy `emdash plugin bundle` from core CLI against the new
  manifest-driven plugin layout, which it doesn't understand.
- Remove the validate-plugins CI job — it used the same legacy CLI
  command. Plugin validation is now covered by `pnpm build`, which
  runs the new `emdash-plugin build` probe + manifest checks against
  every in-tree sandboxed plugin.
- Fix `no-base-to-string` lint errors in audit-log/plugin.ts. The
  canonical ContentHookEvent types `event.content.id` as unknown;
  `String(unknown)` lands on '[object Object]' for record IDs. Added
  a small `stringifyId` helper that returns '' for non-string/number
  inputs so the caller's existence check skips bad rows.
- pipeline.ts now hard-errors when the probed module has no `default`
  export, instead of silently falling through to an empty plugin
  (build had been writing dist/ artifacts with empty hooks/routes for
  any source that used `export const plugin = ...`).
- Scaffold README camelCases hyphenated slugs for the import binding.
  Slugs like `my-plugin` were producing `import my-plugin from ...`
  which is a syntax error. Test added with a hyphenated fixture.

Both bot review comments addressed.

* style: format

* fix(plugin-cli): bump test timeout to 30s for bundle tests on slow CI

bundle.test.ts > 'produces a tarball + manifest for a minimal valid
plugin' timed out at the 5s default on the GitHub-hosted runner.
The test runs the full build pipeline (tsdown probe + transpile +
tarball pack), which is fast locally (<2s) but cold-starts at 5-8s
on CI. Bump to 30s globally for the plugin-cli vitest config.

* chore: update lockfile

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ascorbic <213306+ascorbic@users.noreply.github.com>
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
2026-05-18 15:01:00 +01:00
Matt Kane 40579b1fdc Per-template AGENTS.md with template-specific guidance (#1071)
* Per-template AGENTS.md with template-specific guidance

Each template's AGENTS.md was previously a verbatim copy of
templates/starter/AGENTS.md, so portfolio, blog, marketing, and starter
all carried the same generic platform-level rules and nothing about what
made each template distinct. An agent reading portfolio-cloudflare/AGENTS.md
had no way to know it was a portfolio template, what CSS variables drove
the design, or what not to touch.

Restructure the sync so each template can carry its own design and
schema guidance without duplicating the shared rules:

- scripts/agents-base.md is the shared base (commands, key files, skills,
  docs MCP, platform rules), extracted from the old starter/AGENTS.md.
- templates/{name}/AGENTS-template.md is the per-template body, authored
  by hand. Covers what the template is, its pages, its schema, its visual
  character, which CSS variables matter, and what not to do.
- scripts/sync-template-skills.sh concatenates base + body to produce
  each templates/{name}/AGENTS.md. The *-cloudflare variants fall back
  to the base variant's body, so portfolio-cloudflare and portfolio get
  identical AGENTS.md.
- scripts/sync-templates-repo.mjs excludes AGENTS-template.md from the
  public emdash-cms/templates mirror; only the assembled AGENTS.md ships.

All four themed templates plus blank get template-specific bodies. The
content is declarative facts about each template, useful to both local
coding agents (Claude Code, Cursor) and the chat-harness BuilderAgent
in emdash-build.

* style: format

* Address review: portfolio gallery shape, marketing hero image

- portfolio: clarify that the projects.gallery JSON field expects
  { url, alt? } records, not EmDash image objects. Distinguish it
  from featured_image to avoid the footgun.
- marketing: remove the inaccurate "hero image" mention from the
  hand-entered URL fields list. The marketing.hero block has no
  image field in the editor schema; the renderer falls back to the
  bundled /hero-visual.svg. Document where to swap that.

---------

Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-05-17 09:18:27 +01:00
Matt Kane 796bd18e78 fix(templates): generate pnpm-workspace.yaml; 2-space package.json (#1063)
The standalone templates' build-script policy lived in package.json
`pnpm.onlyBuiltDependencies`, which pnpm 11 removed (replaced by
`allowBuilds`). With pnpm >=10.26/11 and `strictDepBuilds` on by
default, scaffolded sites failed `pnpm install` with
ERR_PNPM_IGNORED_BUILDS (better-sqlite3, esbuild, sharp, workerd).

The sync now synthesizes a per-template `pnpm-workspace.yaml` in the
standalone repo (it can't be committed to source: templates/* are
members of this monorepo's workspace, and a nested pnpm-workspace.yaml
would break workspace:* resolution -- the reason it was removed
downstream). It carries:

- allowBuilds: only the native builds each runtime needs are true;
  the rest are false so strictDepBuilds treats them as reviewed.
  cloudflare: esbuild/workerd; node: esbuild/better-sqlite3/sharp.
- minimumReleaseAge 1440 (emdash/@emdash-cms/* excluded)
- trustPolicy: no-downgrade
- blockExoticSubdeps: true

Also: the sync wrote package.json with tabs; emit 2-space (npm/pnpm
convention, and package.json is formatter-ignored). Drop the now-dead
`pnpm.onlyBuiltDependencies` from the 8 synced template package.json
and normalize them to 2-space. pnpm-workspace.yaml is added to the
prune-preserve set so the dest-clean step doesn't drop it.

Works on pnpm >=10.26 and pnpm 11 with no packageManager pin.
2026-05-16 16:42:16 +00:00
Matt Kane ddd5b19650 fix(templates): guard Astro.cache.set with cache.enabled check (#984)
Wraps `Astro.cache.set(cacheHint)` in `if (Astro.cache?.enabled)` across
all base templates (blog, starter, portfolio, marketing) and their
cloudflare variants. Avoids calling cache.set when no cache provider is
configured or in dev mode, per the CacheLike contract.

Skips contact.astro which keeps its existing try/catch for the POST-
response case.
2026-05-11 06:05:18 +00:00
Matt Kane 6b06244883 chore(templates): make marketing footer links editable via menus (#933)
* chore(templates): make marketing footer links editable via menus

Add three footer menus (footer_product, footer_company, footer_support)
to the marketing template seed, and refactor the footer in Base.astro
to render them dynamically with hidden-when-empty columns. Cloudflare
variant synced via scripts/sync-cloudflare-templates.sh.

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-07 12:19:56 +00:00
Ruman Ahmed c81dff7647 Fix mobile navbar overlap in blog templates (#683)
- Move .nav-admin outside .nav-right to prevent overlap on mobile
- Use CSS grid for mobile layout to properly position elements
- Add margin-inline-start: auto to .nav-right to preserve desktop layout
- Apply fix to templates/blog and templates/blog-cloudflare

Fixes #682
2026-05-04 09:33:55 +01:00
Matt Kane e03a523c47 templates+demos: drop init/seed scripts and stale D1 setup steps (#901)
* templates+demos: drop init/seed scripts and stale D1 setup steps

The runtime now auto-applies migrations and seeds on the first request,
so `emdash init` and `emdash seed` are no longer needed in setup. Wrangler
also provisions D1 databases on first deploy when `database_id` is omitted,
so the explicit `wrangler d1 create` + paste-the-id step is gone too.

This brings the templates and demos in line with where deployment docs
landed in #899:

- Drop `bootstrap` and `seed` scripts from every template and demo
  package.json (Node + Cloudflare).
- Drop the `database_id: "local"` placeholder and the
  "Run wrangler d1 create ... and paste the real ID here" comment from
  every Cloudflare template's wrangler.jsonc.
- Drop the `db:create` script and rewrite the Setup section in
  demos/cloudflare/README.md.
- Update canonical skills (building-emdash-site, emdash-cli) and the
  WordPress migration skills to describe runtime-applied seeds and the
  apply-time validation flow instead of the removed `emdash seed --validate`
  / `emdash seed` commands.
- Update templates/starter/AGENTS.md (canonical) to drop the stale
  `emdash seed --validate` reference.
- Re-run scripts/sync-template-skills.sh to propagate the canonical
  skill + AGENTS.md updates to all 8 templates. The sync also picked up
  pre-existing drift in the creating-plugins skill (capability rename
  read:content -> content:read, network:fetch -> network:request) — those
  are correct and overdue, included rather than left as more drift.

* skills: address Copilot review on PR 901

Two issues caught by Copilot, both repeated across canonical + 8 template
copies:

1. `npx emdash export-seed > .emdash/seed.json` fails on a fresh project
   because the `.emdash/` directory may not exist yet. Prepend
   `mkdir -p .emdash` so the redirect works and the runtime can still
   auto-discover the file on first boot.

2. The hooks Quick Reference table listed every content hook with no
   capability requirement (`—`), but `hooks.ts:261-266` shows all six
   require capabilities at registration time and are silently skipped
   without them. Fill in the correct columns:

     content:beforeSave     → content:write
     content:afterSave      → content:read
     content:beforeDelete   → content:read
     content:afterDelete    → content:read
     content:afterPublish   → content:read
     content:afterUnpublish → content:read

Edited the canonical files in `skills/` and re-ran
`scripts/sync-template-skills.sh` to propagate to all 8 templates.

* docs: prepend `mkdir -p .emdash` to seed export examples

The `.emdash/` directory may not exist on a fresh project, so the shell
redirect in `npx emdash export-seed > .emdash/seed.json` would fail.
Same Copilot feedback applied to the skills in the previous commit;
this catches the docs site that was missed when #899 merged.
2026-05-03 19:58:43 +01:00
Matt Kane 831c28b797 feat(docs): add docs-search MCP server (#888)
* feat(docs): add docs-search MCP server

Adds a stateless MCP server at https://docs.emdashcms.com/mcp that
exposes a single search_docs tool over the EmDash documentation,
backed by Cloudflare AI Search.

The docs Worker now wraps the Astro adapter via a custom worker
entrypoint, intercepting /mcp before the asset handler. Sets the
canonical site URL so a sitemap is generated; ships robots.txt
pointing at the sitemap so the AI Search crawler can discover it.

Templates ship .mcp.json, .cursor/mcp.json, and .vscode/mcp.json so
Claude Code, Cursor, and VS Code auto-discover the server. AGENTS.md
in each template references the docs MCP and points to the
configuration page for tools that need manual setup.

The two marketing templates previously ignored the entire .vscode
directory; updated their gitignores so .vscode/mcp.json is committed
while personal IDE settings remain ignored.

* Update docs/src/worker.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* lockfile

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-01 16:45:49 +00:00
Matt Kane 5b6f059d06 fix: load seed/seed.json in seed virtual module (#882)
* fix: load seed/seed.json in seed virtual module

Four templates (marketing-cloudflare, blog-cloudflare, starter-cloudflare,
starter) were missing the package.json#emdash.seed pointer. Without it, the
seed virtual module fell back to the built-in default seed instead of the
template's own seed/seed.json. The setup wizard would not offer demo content,
the wrong schema would be applied, and the user would end up with an empty
admin against an unrelated set of collections.

Adds the missing pointers, plus a third fallback in generateSeedModule that
searches for seed/seed.json (the conventional path documented in every
template's AGENTS.md) before giving up to the default. Logs a warning on
fallthrough so this kind of misconfiguration is loud during dev.

* fix: only warn about default seed fallback during astro dev

Per code review feedback: console.warn fires during build/preview/CI too,
which is noisy for sites that intentionally use the default seed (e.g. the
blank template). Gate the warning to NODE_ENV=development and latch it so
it fires at most once per process.
2026-05-01 10:17:24 +00:00
ask-bonk[bot] a8bac5d721 Fix #867: loosen PT _key validation and fix template seeds (#869)
* test: reproducing autosave validation error from keyless seed PT (issue #867)

* fix: inject _key on PT blocks at seed time so seeded content survives autosave

Fixes #867. Templates whose seed.json omits `_key` on Portable Text
blocks would round-trip valid through initial render but fail
autosave validation: the Zod schema generated for portableText fields
requires `_key: z.string()` on every top-level block, and the admin's
autosave path re-sends the data it loaded unchanged.

applySeed now passes content data, section content, and widget content
through a normalizer that injects stable, deterministic keys on every
PT-shaped node missing one. Existing keys in the seed file are
preserved; generated keys avoid colliding with explicit ones.

* fix: loosen PT _key + fix template seeds (re-roll #867)

Replaces the seed-time normalizer in the previous commits with the
two-layer fix the maintainer's audit landed on:

  1. zod-generator.ts: `_key` on Portable Text blocks is now
     `.optional()`, and non-required fields use `.nullish()` instead
     of `.optional()` so the validator accepts the `null`s the DB
     stores for unset columns. Both regressions trace back to PR #777
     wiring the existing schema into the runtime content-update path
     (autosave) without auditing whether stored data round-trips.

  2. Template seeds: every shipped `seed.json` (blog, portfolio,
     starter, marketing, plus the four `*-cloudflare` variants) now
     ships stable `_key` values on every Portable Text block / span
     / mark def. The portfolio template's bare-URL `featured_image`
     values are rewritten as `$media` references in the same shape
     the blog template already uses (the validator's `image`
     schema is `z.object({id, ...})`, so bare strings were a second
     latent autosave failure the original PR didn't address).

Drops the runtime normalizer and its unit suite -- the failure mode is
gone at the data level. The integration test that previously exercised
the normalizer is repurposed as a regression guard: every shipped
template seed is applied to a fresh DB and every stored entry is
re-validated against `validateContentData(..., { partial: true })`,
which is exactly what the autosave endpoint runs. Template changes
that break the contract fail before release.

Closes #867.

---------

Co-authored-by: opencode <opencode@emdash.cms>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
2026-05-01 07:33:01 +01:00
ask-bonk[bot] e0dc6fb8ad Fix blog template style inconsistencies (#858)
* Fix blog template style inconsistencies

Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>

* Nest CSS var fallbacks per review

Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>

---------

Co-authored-by: ask-bonk[bot] <ask-bonk[bot]@users.noreply.github.com>
Co-authored-by: ascorbic <ascorbic@users.noreply.github.com>
2026-04-30 16:12:33 +00:00
Matt Kane 1e2b02486e fix(core): inject favicon link with correct MIME type from site settings (#847)
* fix(core): inject favicon link with correct MIME type from site settings

Resolves #831. The user-configured site favicon was never emitted into
the public site's <head> by core. Per-template Base.astro layouts each
rendered their own <link rel="icon">, but pre-#448 templates lacked
the line entirely, and even current templates dropped the type
attribute. SVG favicons therefore failed to render in Chromium browsers,
which require type="image/svg+xml" when the URL has no .svg extension.

EmDashHead now emits a <link rel="icon"> with the correct type
attribute sourced from the stored media's MIME type. The work lives in
a new renderSiteIdentity helper rather than the plugin contribution
pipeline because that pipeline's isSafeHref allowlist rejects same-origin
paths (correct for sandboxed plugin contributions, but blocks our own
/_emdash/api/media/file/... favicon URLs).

MediaReference now carries url, contentType, width, and height when
resolved via resolveMediaReference, so callers can emit correct head
tags without a second round-trip to the media table.

Templates that already render their own favicon link continue to work;
browsers tolerate the duplicate, and a follow-up cleanup can drop the
per-template line.

Query-count neutral: getSiteSettings() is request-cached and the
standard Base.astro pattern already calls it before EmDashHead runs.

* fix(templates): drop per-template favicon link now that core injects it

Removes the redundant <link rel="icon"> from all 16 bundled
Base.astro layouts (templates, demos, fixtures, infra) and drops
siteFavicon from the matching site-identity helpers. Core's
EmDashHead now emits the favicon link via renderSiteIdentity()
with the correct type attribute, so per-template handling would
just produce a duplicate tag.

Existing user sites that still emit their own <link rel="icon">
continue to work because browsers tolerate the duplicate.

Updated blog-site-identity and starter-site-identity unit tests
to reflect the new helper return shape.
2026-04-30 15:01:59 +01:00
Matt Kane fd847f49c7 docs: drop getBylinesForEntries and getEntryBylines references (#841)
* docs: drop getBylinesForEntries references — function is internal, eager hydration covers the use case

The skill docs documented `getBylinesForEntries` as importable from "emdash"
for "batch-fetch for a list page (avoids N+1)", but:

1. It has never been re-exported from `packages/core/src/index.ts`. The
   import literally fails.
2. `getEmDashCollection` and `getEmDashEntry` already call
   `hydrateEntryBylines` internally, so every entry returned has
   `data.bylines` populated. The "batch helper" use case is already
   handled — site code doesn't need a standalone batch function.

Removes the broken import line from the source skill SKILL.md and from
each `templates/*/.agents/skills/building-emdash-site/SKILL.md`. Removes
the misleading "Batch-fetch for a list page" example block from the
matching `references/site-features.md` files. Updates the JSDoc on the
function in `packages/core/src/bylines/index.ts` to mark it as internal
(consumed by `hydrateEntryBylines`) and drops the import-from-emdash
example.

* docs: drop getEntryBylines references — hydration covers the use case

The skill docs documented `getEntryBylines` as importable from "emdash"
in the standalone query functions section, but:

1. It has never been re-exported from `packages/core/src/index.ts`. The
   import literally fails.
2. Every entry returned by `getEmDashCollection` / `getEmDashEntry`
   already has `data.bylines` populated by `hydrateEntryBylines`. Site
   code reads those fields rather than re-querying.

Removes the function from the import line in the source skill SKILL.md
and each `templates/*/.agents/skills/building-emdash-site/SKILL.md`,
and removes the standalone-query example block from the matching
`references/site-features.md` files. Updates the JSDoc on the function
in `packages/core/src/bylines/index.ts` to mark it as internal (consumed
indirectly via `hydrateEntryBylines` -> `getBylinesForEntries`) and
drops the import-from-emdash example.
2026-04-29 22:42:43 +00:00
Matt Kane cee403d5c0 feat(core): add centralized secrets module and emdash secrets CLI (#811)
* feat(core): add centralized secrets module and emdash secrets CLI

PR 1 of secrets infrastructure work. Adds packages/core/src/config/secrets.ts
as the single source of truth for site-level cryptographic secrets, replacing
scattered import.meta.env reads at five route handlers.

- Preview HMAC secret and commenter-IP hash salt are now generated and
  persisted in the options table on first need. EMDASH_PREVIEW_SECRET
  and EMDASH_IP_SALT env vars are optional overrides.
- Replaces the empty-string fallback that silently disabled preview-token
  verification, and the hardcoded 'emdash-ip-salt' constant that was
  correlatable across installs.
- New 'emdash secrets generate' and 'emdash secrets fingerprint' CLI
  commands. Generates versioned 'emdash_enc_v1_*' keys with canonical
  base64url enforcement (kid derived from decoded bytes).
- 'emdash auth secret' kept as deprecated alias.
- create-emdash scaffolds EMDASH_ENCRYPTION_KEY into .dev.vars or .env
  on project creation, idempotently.
- Template gitignores updated for .dev.vars and .env*.
- Lays groundwork for plugin-secret encryption-at-rest in a follow-up.

* docs: rewrite encryption-key copy without contributor jargon

The user-facing docs talked about 'PR 1', 'follow-up', and 'forthcoming
release' — meaningless to readers who aren't tracking the project's
internal sequencing. Reworded to plain user-facing language: the env
var is validated today, not yet used to encrypt anything, will start
being used automatically when plugin secret encryption rolls out.

* fix(core): address PR #811 review findings

- Decouple EMDASH_ENCRYPTION_KEY parse from resolveSecrets so a malformed
  value can no longer 500 preview/comment hot paths for anonymous
  visitors. Startup validation in EmDashRuntime.create surfaces
  misconfiguration in startup logs instead.
- Enforce canonical base64url in fingerprintKey, matching
  parseEncryptionKeys, so the CLI cannot print kids the runtime would
  later refuse to load.
- Replace bespoke SHA-256 + hex encoding in fingerprintKeyBytes with
  @oslojs/crypto and @oslojs/encoding. Add the family to the workspace
  catalog and switch packages/auth + packages/core to catalog refs.
- Fix kid doc comment ("raw key string" was wrong, kid is derived from
  decoded bytes).
- Rephrase "per-deployment" wording in changeset, snapshot.ts, and
  themes/preview.ts to "per-database" — auto-generated values converge
  across processes that share the DB.
- Drop "validates and stores" in deployment docs (we validate; we don't
  store the plaintext key).
2026-04-29 06:45:07 +00:00
Matt Kane e7df21f0ad feat(admin): allow plugins to set slash menu category for portable text blocks (#809)
Adds an optional `category` field to `PortableTextBlockConfig`. Plugin
authors can pick a meaningful group name (e.g. "Sections", "Marketing",
"Media") instead of every plugin block defaulting to "Embeds". Plugins
that omit the field continue to render under "Embeds" with no behaviour
change.

Plumbs the field through the runtime manifest, the admin manifest type,
the editor's `PluginBlockDef`, and the slash command builder. Widens
`SlashCommandItem.category` to accept either a Lingui `MessageDescriptor`
(for built-in commands) or a plain string (for plugin-supplied values),
and updates the marketing template's blocks to demonstrate the new field
with `category: "Sections"`.
2026-04-28 16:06:03 +01:00
Matt Kane 2ca95d69f5 feat(templates): make marketing blocks editable in admin (#804)
Adds an inline plugin to the marketing and marketing-cloudflare templates
that registers the five marketing block types (hero, features, testimonials,
pricing, faq) with Block Kit field schemas. Editors can now insert and edit
the blocks via the Portable Text editor's slash menu and modal, instead of
seeing them as opaque "Marketing.*" entries with a URL fallback.

The plugin is template-local: src/plugins/marketing-blocks/index.ts wired
via a relative entrypoint in astro.config.mjs. No new dependency.

Block Kit constraints forced two shape changes in the seed:

- CTAs are flattened to sibling fields (primaryCtaLabel, primaryCtaUrl,
  ctaLabel, ctaUrl) because Block Kit has no object-group element. Hero
  and Pricing renderers updated to read the flat shape.
- Pricing plan features become a newline-separated string because repeater
  sub-fields are scalar only (no nested repeaters). Pricing.astro splits
  on newline at render time.

Existing template users on prior versions are unaffected -- their data is
already in their database. New users get editable blocks day one.

Also picks up two unrelated dts cleanups from running the cloudflare-template
sync script: marketing-cloudflare/emdash-env.d.ts loses a stale Post type
that didn't match the seed, and starter-cloudflare/emdash-env.d.ts loses a
stale bylines field.
2026-04-28 12:16:09 +00:00
Matt Kane c83055b8e4 perf(templates): batch term queries and use FTS for blog search (#787)
Templates were calling getEntryTerms() in Promise.all loops, costing one
round-trip per post for tag pills on every list page. Replace with a
single batched getTermsForEntries() JOIN. Also push more work to the
database: limits where slicing was happening in JS, sort directives
on listings, and where: { tag } filtering for portfolio/work.

Replaces blog/search.astro's load-all-then-substring-match approach
with the FTS-backed search() API. Adds missing Astro.cache.set() to
category/tag archive pages.

Synced across the four templates (blog, marketing, portfolio, starter)
and their -cloudflare variants, plus the five demos, infra/blog-demo,
infra/cache-demo, and fixtures/perf-site.

The query-counts snapshot will need regenerating, which CI does
automatically.
2026-04-27 17:25:41 +01:00
Matt Kane e757dbda9c perf(marketing): use astro-iconset instead of unpkg phosphor font (#784)
* perf(marketing): use astro-icon instead of unpkg phosphor font

The marketing templates loaded the entire Phosphor icon font from
unpkg.com via a render-blocking <link>, hurting LCP/FCP and causing
icon-swap CLS for ~16 icons that were actually used. Replace it with
astro-icon + @iconify-json/ph so each used icon ships as inline SVG.

* fix(marketing): switch to astro-iconset and filter icon set

The astro-icon component import triggered Vite SSR dep re-optimization
mid-render under the Cloudflare dev runner (workerd), invalidating
in-flight chunk imports and causing the worker to hang with a 500.
Smoke tests reproduced this on templates/marketing-cloudflare.

Switches both marketing templates to astro-iconset (drop-in fork, same
API) and pre-bundles the components entry via ssr.optimizeDeps.include
so it isn't discovered late.

Also filters the bundled Phosphor set to just the 16 icons actually
referenced, avoiding shipping the full @iconify-json/ph set in the
worker bundle.

* fix(marketing): add empty src/icons/ directories for astro-iconset

astro-iconset scans iconDir (default: src/icons/) at build time and
aborts with ENOENT if the directory does not exist, even when only
Iconify icons are used. The Site build verification smoke test caught
this on CI.

Adding an empty src/icons/.gitkeep to both marketing templates lets
astro-iconset find the directory (it just loads zero local icons) and
the build completes.
2026-04-27 13:09:47 +01:00
emdashbot[bot] 8fb914508c ci: release (#505)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-13 21:36:19 +01:00
Matt Kane b450079af4 fix: use @layer to ensure theme.css overrides win over Base.astro defaults (#535)
Astro injects ESM-imported CSS before <style is:global> blocks in the
<head>. Both target :root with equal specificity, so the defaults in
Base.astro always won -- making theme.css overrides silently ineffective.

Wraps all default styles in @layer base. Unlayered declarations (theme.css)
always beat layered ones regardless of source order.

Also adds theme.css support to marketing and portfolio templates, which
were already fully tokenized with CSS variables but had no override file.
2026-04-13 17:08:30 +00:00
Matt Kane 5de868b106 chore: add worker_loaders binding to Cloudflare templates (#518) 2026-04-13 09:13:02 +00:00
Matt Kane c377884994 fix(templates): add pnpm.onlyBuiltDependencies for native addons (#501)
pnpm v10 blocks postinstall scripts by default. The monorepo root
allowlists native deps via pnpm.onlyBuiltDependencies, but standalone
template installs (e.g. in the emdash-cms/templates repo CI) have no
root config to inherit from. This caused better-sqlite3's native binary
to never compile, failing all smoke tests.
2026-04-12 21:49:31 +00:00
Hari Bonda 9da92eb871 fix: decode URI-encoded slugs for non-ASCII URL support (#115)
* fix: decode URI-encoded slugs for non-ASCII URL support

Slugs containing non-ASCII characters (e.g. Telugu, Hindi, CJK) are
percent-encoded by browsers when requested. Astro.params.slug can return
the encoded form, causing database lookups to fail with a 404.

Apply decodeURIComponent() to all [slug].astro pages across templates
and demos so non-Latin slugs resolve correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* add changeset for decode-uri-slug fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract decodeSlug helper and remove changeset

Export a shared decodeSlug() utility from the core package instead of
repeating inline decodeURIComponent() in every [slug].astro file.
Remove changeset since affected packages are all private/unpublished.

Addresses review feedback from @ascorbic on PR #115.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-04-12 20:12:37 +01:00
Matt Kane d57e260931 Migrate templates and demos to Astro Fonts API (#493)
* refactor: migrate templates and demos to Astro Fonts API

Replace Google Fonts <link> tags with Astro 6's built-in Fonts API
across all templates and demos. Fonts are now declared in
astro.config.mjs and rendered via the <Font> component, which
downloads and self-hosts font files at build time with automatic
optimized fallbacks.

* fix: add preload to primary Font components

The Astro Font component defaults preload to false. Enable it for the
primary body/heading font in each layout so the browser begins
downloading immediately.
2026-04-12 19:46:21 +01:00
Gregor B. Rosenauer c92e7e6907 add site image and favicon from settings to templates (#448)
* add image and favicon rendering to blog template

* Revert "add image and favicon rendering to blog template"

This reverts commit 59bc24ff46a4bf08eb9655ca7a78940c72584b27.

* fix issue 430: Site logo and favicon support for templates

* add test and changeset

* add missing test for starter-site-identity.test.ts

* style: format

* fix typecheck errors for marketing and portfolio templates

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com>
2026-04-12 12:25:19 +01:00
Matt Kane 1dc19b0f3a fix: restore worker.ts in cloudflare templates and add template sync (#478)
* fix: restore worker.ts in cloudflare templates and add template sync

Cloudflare templates were missing src/worker.ts and the "main" field in
wrangler.jsonc, so the custom worker entrypoint (Astro handler +
PluginBridge re-export) was never used. Also adds worker-configuration.d.ts
to the three templates that were missing it.

Adds scripts/sync-templates-repo.mjs to sync templates to the standalone
emdash-cms/templates repo with resolved dependency versions. A new
sync-templates workflow runs after publish or on manual dispatch.

Also fixes sync-cloudflare-templates.sh to use rsync with --exclude so
it preserves worker.ts when copying src/ from base templates.

* fix: lint error in sync-templates-repo.mjs

* fix: address review feedback on sync scripts

- Use gh repo clone instead of embedding token in clone URL
- Use execFileSync with arg array for gh pr create (no shell injection)
- Use -B and --force-with-lease for re-runnable branch push
- Guard against non-directory dest in sync-cloudflare-templates.sh

* fix: eliminate shell injection and harden sync workflow

- Replace all execSync/run() calls with execFileSync arg arrays so
  interpolated values (version strings from package.json) never pass
  through a shell
- Add ref guard (github.ref == refs/heads/main) to sync-templates
  workflow so it can't be dispatched from arbitrary branches
- Wrap post-clone logic in try/finally to clean up temp dir on error

* fix: address second round of review feedback

- Run gh auth setup-git after clone so git push works with GH_TOKEN
- Pin Node version in sync-templates workflow
- Handle dest-is-directory in file copy branch of sync-cloudflare-templates.sh

* fix: validate --local requires a path argument

* fix: handle re-runs gracefully (existing branch/PR)

- Fetch remote branch before force-with-lease so the lease has a ref
- Check for existing open PR before creating a new one
2026-04-12 11:44:56 +01:00
Jonah Foster d421ee2ced Fix page metadata titles without the site-name suffix (#209) 2026-04-11 17:08:22 +01:00
Ophir Bucai e3f7db8bb6 fix: scroll to and focus target field when navigating from visual editing (#185)
* fix: visual editing field navigation and scroll-to-field in admin

- Add missing `{...post.edit.excerpt}` annotation across all templates and demos
- Parse `?field=` search param in admin content edit route
- Add `useElementReady` hook to poll for DOM elements after async render
- Scroll to and focus the target field when navigating from visual editing
- Strip `?field=` from URL after scroll to prevent re-scroll on back navigation
- Add `id` to all field renderers (image, select, boolean, portableText) for scroll targeting

* fix: self-host Inter and JetBrains Mono via fontsource

Replace Google Fonts CDN links with @fontsource-variable/inter and
@fontsource/jetbrains-mono for privacy and offline reliability.

* fix: annotate content field for visual editing in simple demo

Add {...post.edit.content} to the article-content wrapper so the
Portable Text body participates in visual editing — clicking it in
edit mode now navigates to the admin editor with ?field=content,
triggering scroll-to-field.

* fix: generalize useElementReady to CSS selectors, focus ProseMirror for rich text fields

- useElementReady now accepts any CSS selector via querySelector
  instead of getElementById, making it more reusable
- Scroll-to-field now focuses the .ProseMirror contenteditable div
  for portableText fields, falling back to the field container for
  all other field types

* chore: ignore CLAUDE.local.md and .playwright-mcp

* Revert "fix: self-host Inter and JetBrains Mono via fontsource"

This reverts commit 35b8ba864c53dd7659d1e5a642e87d929d2d4591.

* refactor: use useStableCallback in useElementReady

* chore: ignore *.local.* files for personal cursor rules

* fix: stabilize useElementReady deps and normalize validateSearch

- Remove selector from useElementReady dep array; use a ref so the
  effect fires once at mount rather than restarting when the selector
  string identity changes mid-poll
- Normalize validateSearch to parse search.field safely from unknown
  input, dropping the pass-through that bypassed runtime validation
- Drop redundant post.edit.content annotation on article-content
  wrapper — PortableText renders the inline editor automatically via
  getEditMeta; the wrapper annotation was suppressing it
- Remove dead ProseMirror focus path; inline editor handles rich text

* refactor: replace useElementReady hook with inline effect

The extracted hook had a placement bug (hasAttempted set before rAF fires)
and added indirection for a single call site. The inline effect with a
hasScrolled ref is simpler, correct, and easier to reason about locally.

* revert: restore .gitignore to main

* fix: pass id directly to Select instead of wrapper div

* fix: pass id directly to Select instead of wrapper div

* fix: remove spread object from useEffect deps, use stable reference from useSearch. remove redundant hasScrolled check, URL is already updated. noticed that we need to defer the call to the next render now, so added requestIdleCallback & cleanup. shimmed at top of root component file

* chore: add changelist entry 🦋

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-04-11 09:39:14 +00:00
Pejman Pour-Moezzi 0139c91d41 fix(templates): use site settings in bundled blog and starter templates (#392)
* fix(templates): respect CMS site identity in blog defaults

* fix(templates): respect site settings in starter templates
2026-04-09 12:27:38 +01:00
Pejman Pour-Moezzi ebe543b59a Fix CJK reading time estimation in blog templates (#370) 2026-04-08 15:09:58 +00:00
Matt Kane d6cfc437f2 fix: migration 033 idempotency & smoke test cleanup (#365)
* fix(core): add IF NOT EXISTS to migration 033 index creation

The schema registry's createContentTable() creates composite indexes
on content tables at creation time. When migration 033 runs on a
database where tables were created after the registry added these
indexes, it fails with "index already exists". Adding IF NOT EXISTS
makes the migration idempotent.

* refactor(smoke): trim site matrix to templates + playground, remove typechecks

The smoke test matrix included every demo and template, plus typecheck
cases. This was slow and redundant — typechecks are already covered by
pnpm typecheck, and demos are dev targets not release artifacts.

Trim to templates + playground only, and replace the sequential per-site
astro build with a single recursive pnpm build. Remove the TypecheckSiteCase
type and handling since smoke tests should only verify runtime behavior.

* Update env

* chore: add changeset for migration 033 fix
2026-04-07 22:00:54 +00:00
Hasan Smadi dbaf8c6f85 fix(visual-editing): don’t open admin for portableText; simplify starter PT markup (#40)
* fix(visual-editing): PT inline editing; align starter templates

* chore(admin): format router.tsx

---------

Co-authored-by: Matt Kane <mkane@cloudflare.com>
2026-04-06 20:10:09 +00:00
Matt Kane 53329df155 Format 2026-04-01 16:16:45 +01:00