@spacefast/zero-compat-emdash
v0.4.1
Published
Translates an EmDash CMS project into a Spacefast Zero content model, entirely on the CLI side.
Readme
@spacefast/zero-compat-emdash
Translates an EmDash project into Spacefast Zero content.
This is a CLI-side translation, not a server-side adapter. Spacefast runs Zero and only Zero; nothing here ships to the platform, and there is no EmDash compatibility layer at runtime. You point it at an EmDash seed, you get Zero content and a report of exactly what happened to your site.
Two emit targets; the CLI takes the authored one.
sf zero import emdashreadstranslateEmDashSeed's mapped model and lets@spacefast/zero-compileprint the capsule content module the author edits next — that authoring lane has landed. This package's owntranslateEmDashProjectstill emits the compiledspacefast.zero-wordpressContentModelRelease plus the Markdown files its sync bindings name, for callers that want the build output directly. The split is what makes carrying both cheap:translate.tsmaps an EmDash seed onto a source-agnostic model and knows nothing about either target; onlyemit-content-model.tsis about the compiled one.
Input
An EmDash site declares its whole schema and optional content in one seed document, resolved
from .emdash/seed.json, package.json#emdash.seed, or seed/seed.json. Export a whole
project with:
npx emdash export-seed --with-content > seed.jsonUse
import { readFile } from "node:fs/promises";
import { formatTranslationReport, translateEmDashProject } from "@spacefast/zero-compat-emdash";
const seed = JSON.parse(await readFile("seed.json", "utf8"));
const { contentModel, files, report } = await translateEmDashProject(seed);
console.log(formatTranslationReport(report));
for (const [path, markdown] of Object.entries(files)) {
await writeFile(path, markdown);
}contentModel is already validated against contentModelReleaseV1Schema, revision included.
files are the repo Markdown files, keyed by the exact path each sync binding names.
The report
Silent loss is the one thing a compatibility translator must never do, so every part of the source project leaves a record:
| status | meaning |
| ------------ | ---------------------------------------------------------------------------------------- |
| mapped | carried over with its meaning intact |
| renamed | carried over under a different name, because the original is not a legal Zero identifier |
| downgraded | carried over, but the target expresses less than the source did |
| refused | not carried over; Zero has no counterpart |
A translation is faithful exactly to the extent the refused list is empty. It usually is not,
and that is the useful part.
What carries over
| EmDash field type | Zero | note |
| ----------------- | ---------------------------- | ------------------------------------------ |
| string, url | text (single line) | |
| text | text (multiline) | |
| number | number | |
| integer | number | downgraded — integrality is lost |
| boolean | boolean | |
| datetime | datetime | |
| select | enum | refused when the field declares no options |
| multiSelect | enum (multiple) | refused when the field declares no options |
| portableText | blocks + a Markdown source | |
| image, file | media | |
| reference | reference | refused when the target is not declared |
| slug | WordPress post slug | native, so not a content model field |
| json | — | refused — needs a Lakebed table |
| repeater | — | refused |
A WordPress content model always declares exactly one posts, pages, and media resource. The
seed's own posts and pages collections are adopted onto the natives; any that the seed does
not declare are synthesized. A seed collection named media is refused — the media library is
the platform's.
Every translated collection gets list and get queries and a save mutation, so the SDK's
invalidation graph is real: save writes the resource its own reads read. Synthesized natives
the seed never declared stay read-only.
What does not
Refused with a reason, never dropped quietly: menus, widgetAreas, sections, redirects,
bylines, taxonomies, settings, and any localization (defaultLocale, per-entry locale,
translationOf). None have a counterpart in a Zero content model.
Content data is the subtle one. A ContentModelRelease describes a content schema; the only content
it carries is Markdown reached through a sync binding. So an entry's portableText field
becomes content/<collection>/<slug>.md (or content/<collection>/<slug>.<field>.md when the
collection declares more than one), and every other value on that entry — title,
excerpt, featured_image, taxonomy assignments, bylines, draft status — is refused by name.
Importing that data is a separate job from compiling a content model.
Portable Text
Rich text becomes Markdown, because Markdown is what a sync binding reconciles against a WordPress block field. The WordPress php-toolkit owns Markdown-to-blocks and back; nothing here produces blocks directly.
Paragraphs, h1–h6, blockquotes, bullet and numbered lists, strong, em, code,
strike-through, links, and image nodes convert. Anything else — a custom node type, an unknown
decorator, an unresolvable mark definition — is refused by name. Real EmDash templates ship
custom node types (marketing.hero and friends), and a converter that quietly flattened one
into its text would corrupt a page while reporting success.
A full Portable Text converter, including custom node types mapped onto registered Zero components, is follow-up work.
Fixture
src/fixtures/emdash-blog-seed.json is the unmodified templates/blog/seed/seed.json from
emdash-cms/emdash at commit
1717d31b351164a5f78e95fe004ee582c7c50f40 (MIT).
