@snapsite/cms-editor
v0.1.1
Published
Embeddable headless-CMS editor for @snapsite client sites — CMSProvider, EditableText, EditableImage, EditableSection, MediaPicker, Shadow-DOM toolbar.
Maintainers
Readme
@snapsite/cms-editor
Embeddable, React-based in-page editor for SnapSite client sites. Wraps your app in a provider, lets signed-in owners switch on edit mode and modify content inline, and renders all editor chrome inside a Shadow DOM so nothing leaks into (or is affected by) your site's CSS.
Install
pnpm add @snapsite/cms-editor @snapsite/cms-core
# peer deps
pnpm add react react-domUsage
// src/main.tsx
import { createRoot } from "react-dom/client";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import {
CMSProvider,
CMSLogin,
EditableText,
EditableImage,
EditableSection,
} from "@snapsite/cms-editor";
import { defineSection } from "@snapsite/cms-editor";
import { z } from "zod";
// Generated by @snapsite/cms-bake at build time.
import { BAKED_CONTENT } from "./generated/content";
const heroSection = defineSection({
type: "hero",
label: "Hero",
schema: z.object({
heading: z.string(),
subheading: z.string(),
cta: z.object({ label: z.string(), href: z.string().url() }).optional(),
}),
defaults: { heading: "Welcome", subheading: "We build things people love." },
render: ({ data }) => (
<section>
<h1>{data.heading}</h1>
<p>{data.subheading}</p>
</section>
),
});
function App() {
return (
<CMSProvider
siteId={import.meta.env.VITE_CMS_SITE_ID}
supabaseUrl={import.meta.env.VITE_SUPABASE_URL}
supabaseAnonKey={import.meta.env.VITE_SUPABASE_ANON_KEY}
initialContent={BAKED_CONTENT}
sections={[heroSection]}
>
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/cms-login" element={<CMSLogin redirectTo="/" />} />
</Routes>
</BrowserRouter>
</CMSProvider>
);
}
function Home() {
return (
<main>
<EditableText path="/home/hero/heading" as="h1">
Welcome to our site
</EditableText>
<EditableImage path="/home/hero/image" alt="Hero" />
<EditableSection path="/home/sections" />
</main>
);
}
createRoot(document.getElementById("root")!).render(<App />);What the consumer sees
- Anonymous visitors: the page renders exactly from
initialContent. No editor UI. Disable JavaScript and the baked text is still in the HTML. - Authenticated owner: a small floating toggle appears bottom-right. Click
it to enter edit mode — a toolbar slides in at bottom-center, every
<EditableText>picks up a dashed outline on hover, every<EditableImage>reveals a "Click to replace" overlay, andEditableSectionexposes per-section move / publish / delete / duplicate / edit actions. Click, type, blur to save. - Authenticated non-owner: same as anonymous — you only see editor UI if
you have a
site_usersmembership for thissiteId.
All chrome renders inside a Shadow DOM attached to document.body, so the
host site's global CSS can't affect its layout, and its rules can't leak
back out.
Public API
| Export | Type | Description |
| --- | --- | --- |
| CMSProvider | component | Wrap your app. Props: siteId, supabaseUrl, supabaseAnonKey, initialContent?, sections?, errorReporter?, children. |
| EditableText | component | Inline-editable plain-text field. Props: path, as?, placeholder?, children?, className?. |
| EditableImage | component | Click-to-replace image. Props: path, alt?, className?, fallbackSrc?, style?. |
| EditableSection | component | Renders a sortable list of sections at path from your defineSection registry. |
| CMSLogin | component | Login form: magic link + optional password. |
| EditModeToggle | component | Standalone toggle — CMSProvider already renders one. |
| defineSection | helper | Type-safe section definition (Zod schema, defaults, optional custom form / render). |
| useCMS() | hook | { state, dispatch, client, siteId, runSave, lastFailedSave, ... }. |
| useContent(path) | hook | Current value for a content path. |
| useSections(path) | hook | Ordered list of sections at path. |
How saves work
- All edits route through
runSave(label, fn)on theuseCMS()context, which owns the SAVE_STARTED / SAVE_COMPLETED / SAVE_FAILED lifecycle. - On success the optimistic update sticks. On failure the user's input is preserved (no revert), the toolbar shows a Retry button that re-fires the original mutation closure, and the unsaved-changes guard prompts on sign-out.
- All save mutations are gated by Supabase row-level security; non-owners get a clean error and no data moves.
Error reporting
CMSProvider installs a cms_errors reporter automatically (writes
failures to public.cms_errors via log_cms_error). Pass an extra
errorReporter prop to fan out to Sentry et al:
import { createSentryReporter } from "@snapsite/cms-core";
import * as Sentry from "@sentry/browser";
<CMSProvider
...
errorReporter={createSentryReporter(Sentry.captureException)}
>Sentry init (DSN, sample rates) is the host's responsibility; this just wires the sink.
Session lifecycle
CMSProvider creates a single Supabase client via createAnonymousClient(...,
{ persistSession: true }). When CMSLogin calls signInWithPassword or
signInWithMagicLink, the session lands in localStorage. On the next page
load the same provider picks it up, checks ownership, and mounts the chrome.
Cross-domain handoff (Prompt 6)
For the dashboard to "Edit this site" without a second login, it mints a
single-use handoff token. The host site exposes a /cms-handoff route that
exchanges the token for a Supabase session and redirects to /?cms-edit=1.
See the dashboard's handoff.ts and the exchangeEditorHandoffToken API in
@snapsite/cms-core.
License
MIT
