Appearance
CMS adapter layer
Lives in the spine at backend/src/workspace/collections/cms/ — every module publishes through it; no module talks to a CMS directly.
This page is the shared contract; the per-platform wire detail (auth flows, discovery mechanics, publish steps per CMS) is in CMS platform internals.
The contract (cms-adapter.js, abstract CMSAdapter)
publish(input) is a concrete template method owning input normalization (per-locale title/intro extraction), credential guard, primary-locale sanity, and the {success, error} envelope. Adapters implement either the default flow hooks — _preparePublish(ctx), _createPrimary(entry, shared), _addTranslation(id, entry, shared) — or override _publish(ctx) wholesale (Webflow bulk-locale staging, Wix draft-group-then-publish). Plus _credentialError().
Two contract tiers (plans/cms-contract-v1.md). Tier 1 — the whole uniform surface, required of every adapter: publish (via the hooks), testConnection, discoverSchema, discoverTaxonomyItems, static credentialFields(), static fixedCollection() (single-model only), static fieldAliases. Tier 2 — the future surface, existing ONLY as capability-gated base stubs throwing CmsCapabilityError: updateArticle (canEditPublished) and deleteFromCms (canDeleteFromCms + deleteSemantics), plus two capability keys with no method yet (canPublishDraft, canManageRedirects). The contract gate enforces flag ⇔ override in BOTH directions — an adapter cannot carry a working edit/delete path the app believes doesn't exist, and cannot declare one it doesn't implement. The old read surface (getPost, getPostIdBySlug, resolveCmsId, extractPlainText, updateTitleMeta) had zero callers and was deleted outright.
completePublish(input, existingCmsId) is the second concrete template method: it adds the locales that FAILED during first publish onto the existing primary document, through the same _addTranslation hook — the only sanctioned write to an already-published item, zero per-adapter code. Adapters that override _publish wholesale (Webflow, Wix) have no _addTranslation, so it refuses honestly before any write. blog.js publish is therefore CREATE (no cms_id) or COMPLETE (cms_id + failed locales); a fully-live group is refused — publish-once.
Two guards run before any hook. _credentialError() (per-adapter) and _mappingError() (shared): a field-mapped collection must map REQUIRED_PUBLISH_ROLES = title + body, or publish refuses before the first write. Without it a collection with those fields switched off published an EMPTY live item and still returned success: true — WordPress received {"excerpt":"…","status":"publish"}. usesFieldMapping: false adapters (custom) are exempt: they build a fixed envelope from the article itself. The same rule is enforced at save in store.js (assertPublishableFieldMap) and made unofferable in the UI, so config, storage and execution agree. Locked by a regression case in scripts/cms-publish-dryrun.mjs.
The single value funnel is buildFieldValues() — value-source precedence: managed → defaults; role → classifier > article; role-less → custom > classifier > default — then buildCmsPayload() (typed coercion, dot-path nesting, skips cmsField: null). _classifyTaxonomyFields fills generate-enabled relationship fields via AI, but a reviewed/stored value always wins.
Registry (cms/index.js)
Factory createCMSAdapter(settings.cmsType). getCMSCapabilities / listCMSTypes merge base defaults under each adapter's static capabilities (a subclass class field shadows the base — never read AdapterClass.capabilities directly). Also: getCMSCredentialFields, getCMSFixedCollection, getCMSFieldAliases, buildAdapterSettings, and getCMSLabel(cmsType) (2026-08-27 — the adapter's display name, so user-facing sentences such as the publishing-blocked message in workspace/collections/publishing.js never show a raw type key).
listCMSTypes() also ships each type's setup guide (setup-guides.js → {summary, minutes, steps[], gotchas[], limits[]}, where a step is {title, detail, bullets?, link?}) — the pre-credential instructions the wizard and Settings both render via CmsSetupGuide.tsx. bullets is for steps that are genuinely enumerable (the Drupal publishing role is eight separate permissions); detail stays the lead-in, so a step is never bullets with no context, and the contract gate rejects an empty array or a non-string entry. The contract gate requires one per registered adapter: a CMS the customer cannot connect unaided is not shippable. Guides live in one file rather than in each adapter because they are setup copy, not wire code, and change when a platform's admin UI changes.
Outbound HTTP (cms/http.js)
cmsFetch(url, options) is the ONE outbound helper for CMSs reached at a customer-supplied base URL (Payload, WordPress, Drupal, custom). It follows redirects manually and preserves the HTTP method, re-running the SSRF guard on every hop.
This exists because the HTTP spec downgrades POST→GET on a 301/302. A stored URL whose canonical host differs (www ↔ apex, http → https) therefore turns every write into a read: HRTH's Drupal token POST reached GET /oauth/token and returned 405 wrapped in 22 KB of Drupal's HTML error page. Fixed-host platforms (Webflow/Wix/Shopify) don't need it. summarizeErrorBody() reduces HTML error pages to their <title> so logs and toasts carry the fact, not the markup.
Pass timeoutMs, not a signal. A pre-built AbortSignal.timeout(N) starts counting before the first hop and keeps counting through every one after it, so on a redirecting host the real request only gets whatever the hop left over — HRTH's OAuth POST aborted 14.5s into a 15s allowance and killed the publish with it. timeoutMs gives each hop its own budget; a caller-supplied signal still applies on top, so real cancellation keeps working. Every cmsFetch call site uses timeoutMs.
The redirect warning is logged once per host per process. It is advice about a stored setting, not about a request, and paragraph-mode Drupal emits ~40 writes per republish — repeating it per call put 237 rows a day into the error dashboard.
Adapters & capabilities
| Type | collectionModel | status | bodyFormat | imageMode | localeModel | canEditPublished | canDeleteFromCms | deleteSemantics | canManageRedirects |
|---|---|---|---|---|---|---|---|---|---|
| payload | any | ga | lexical | upload | shared | false | false | — | false |
| webflow | any | ga | html | urlObject | shared | false³ | false³ | — | false¹ |
| drupal | any | ga | html | upload | primary-only² | false | false | — | false |
| wordpress | single (fixed blog) | ga | html | upload | separate | false | false | — | false |
| shopify | single (fixed blog) | ga | html | urlObject | shared | false | false | — | false |
| wix | single (fixed blog) | ga | ricos | upload | separate | false | false | — | false |
| sanity | any | ga | portable-text | upload | primary-only⁴ | false | false | — | false |
| custom | any | coming_soon | markdown (configurable) | passthrough | shared | false | false | — | false |
¹ canManageRedirects (A-1): the adapter can ship a real 301 on the platform. False EVERYWHERE until live-proven per platform (the canEditPublished precedent). Webflow has a redirects API and is the intended first proof. While false, consolidate_articles degrades honestly: it returns the exact 301 lines for the user to add in their hosting/CMS.
² Drupal's localeModel is the floor, not the verdict — see the _localeModel() seam below. A site with the companion orbit_translations module resolves to shared.
³ Lifecycle is OFF on every adapter since 2026-08-14 (plans/cms-contract-v1.md §0 — the operating doctrine: create + edit-in-review + publish is the whole write reach, on every CMS). Webflow was the live-proven opt-in; its updateArticle/deleteFromCms implementations now live in git history (pre-contract-v1, commit 343c50c and before), and reviving the capability = re-add the method + flip the flag — the contract gate enforces the flag⇔implementation pairing in both directions, so neither half can exist without the other.
⁴ Sanity has no native localization — the ecosystem convention (one document per language + a translation.metadata linker, the document-internationalization plugin) is a per-site Studio choice the adapter can't assume. publish() drops extra locales before any write; upgrading to separate is the documented future path in plans/sanity-adapter.md.
Tier model
collectionModel: 'any'(Payload / Webflow / Drupal / Custom) — discovers arbitrary collections live and publishes through the full field map.collectionModel: 'single'(WordPress / Shopify / Wix) — a platform-fixed content model → exactly ONE collection per workspace, fields seeded fromfixedCollection().localeModel: 'shared'— one document with locale variants (every locale row shares a cms_id);'separate'(WordPress / Wix) — each locale is its own document;'primary-only'(Drupal) — the platform cannot hold translations, andpublish()drops the extra locales before any write.
localeModel is declared; _localeModel() decides
static capabilities.localeModel is what the platform can do with nothing installed — the floor, and what the picker shows before a workspace connects. await adapter._localeModel() is what this connection can do, and an adapter may answer it per workspace. Base returns the static value; only Drupal overrides it, resolving shared once it detects the companion module and primary-only otherwise.
publish() calls it through _dropUnsupportedLocales(ctx) (now async), which checks ctx.additional.length FIRST so a single-language publish never pays for a capability probe. An override must never throw — a failed probe means "assume the declared model", never "fail the publish". _unsupportedLocaleMessage(locale, primaryLang) is the matching seam for the reason: Drupal returns a different remedy for "module not installed" than for "installed but the permission is missing", because they are different jobs for the customer.
Consumers that read the registry (getCMSCapabilities(cmsType) in blog.js, article-mutations.js) still see the static record. That is correct for them: they branch on === 'separate' for per-locale id persistence, and Drupal is never separate in either mode.
Drupal Metatag routing. Discover Fields recognizes the Metatag module's per-node override field config-first (_detectMetatagField: the field-config API filtered on field_type: 'metatag' — value sampling can't type it, the storage reads null on nodes without an override). At publish, _applyMetatag routes the article's meta title/description into that field as Metatag 2.x's single-JSON-string envelope; sites with plain meta text fields keep mapping normally.
- Webflow's full-fidelity implementation lives in git history (
updateArticle+ hard delete, live-proven, removed per the v1 both-directions rule — when reviving, note its delete needspublishSiteor the CDN keeps serving the page).
Layout
adapters/— the contract implementations (one per CMS).services/— raw API clients: payload, webflow, wordpress, wix, shopify, drupal.format/— markdown → html / lexical / ricos converters.taxonomy-classifier.js,image-formatter.js.