{"_id":"@alufie/cms","_rev":"3-2a660d6ea70eae0ea0dbafc0c8bf4618","name":"@alufie/cms","dist-tags":{"latest":"0.1.2"},"versions":{"0.1.0":{"name":"@alufie/cms","version":"0.1.0","keywords":["cms","svelte","sveltekit","svelte-5","drizzle","editor","r2","sharp"],"author":{"name":"Alufie"},"license":"MIT","_id":"@alufie/cms@0.1.0","maintainers":[{"name":"alufie","email":"dev@alufie.com"}],"dist":{"shasum":"f6da8694d7414e8046037d92fc085a86bfb09171","tarball":"https://registry.npmjs.org/@alufie/cms/-/cms-0.1.0.tgz","fileCount":28,"integrity":"sha512-osXjJQDnQ/7m4wqcoPe3tCcHxguIa7b2oobvjV3gV70YJboCmZXyNMdD9yp+lGyeK1qgr6vkBBo6bvKi/x5iCg==","signatures":[{"sig":"MEMCH0Mj3DO5x6cf4jkrTZOuPksnJp0fO0yuuTwnUBQkVvgCIDhs8SEYFKB9SkYqNSJzNc7N/8LTq1Of5h4G8RxaFUpI","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":47014},"type":"module","exports":{".":{"types":"./dist/index.d.ts","svelte":"./dist/index.js","default":"./dist/index.js"},"./db":{"types":"./dist/db/index.d.ts","default":"./dist/db/index.js"},"./db/pg":{"types":"./dist/db/pg.d.ts","default":"./dist/db/pg.js"},"./types":{"types":"./dist/types.d.ts","default":"./dist/types.js"},"./blocks":{"types":"./dist/blocks/index.d.ts","svelte":"./dist/blocks/index.js","default":"./dist/blocks/index.js"},"./server":{"types":"./dist/server/index.d.ts","default":"./dist/server/index.js"},"./CmsEditor":{"types":"./dist/components/CmsEditor.svelte.d.ts","svelte":"./dist/components/CmsEditor.svelte","default":"./dist/components/CmsEditor.svelte"},"./db/sqlite":{"types":"./dist/db/sqlite.d.ts","default":"./dist/db/sqlite.js"},"./blocks/Hero":{"types":"./dist/blocks/Hero.svelte.d.ts","svelte":"./dist/blocks/Hero.svelte","default":"./dist/blocks/Hero.svelte"},"./blocks/RichText":{"types":"./dist/blocks/RichText.svelte.d.ts","svelte":"./dist/blocks/RichText.svelte","default":"./dist/blocks/RichText.svelte"},"./blocks/ImageBlock":{"types":"./dist/blocks/ImageBlock.svelte.d.ts","svelte":"./dist/blocks/ImageBlock.svelte","default":"./dist/blocks/ImageBlock.svelte"},"./server/uploadHandler":{"types":"./dist/server/uploadHandler.d.ts","default":"./dist/server/uploadHandler.js"}},"scripts":{"build":"svelte-package --input src/lib --output dist --tsconfig ./tsconfig.json","check":"svelte-check --tsconfig ./tsconfig.json","prepublishOnly":"pnpm check && pnpm build"},"_npmUser":{"name":"alufie","email":"dev@alufie.com"},"_npmVersion":"11.8.0","description":"Secure, host-authenticated CMS primitives for Svelte 5, SvelteKit, and TypeScript.","directories":{},"sideEffects":false,"_nodeVersion":"22.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.32.1","devDependencies":{"vite":"^7.1.9","sharp":"^0.34.0","svelte":"^5.54.1","typescript":"^5.9.3","@types/node":"^24.6.1","drizzle-orm":"^0.45.2","svelte-check":"^4.4.5","@tsconfig/svelte":"^5.0.4","@sveltejs/package":"^2.3.7","@sveltejs/vite-plugin-svelte":"^6.2.1"},"peerDependencies":{"sharp":"^0.34.0","svelte":"^5.0.0","drizzle-orm":"^0.45.2"},"peerDependenciesMeta":{"sharp":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cms_0.1.0_1777298616426_0.6868086842768613","host":"s3://npm-registry-packages-npm-production"}},"0.1.1":{"name":"@alufie/cms","version":"0.1.1","keywords":["cms","svelte","sveltekit","svelte-5","drizzle","editor","r2","sharp"],"author":{"name":"Alufie"},"license":"MIT","_id":"@alufie/cms@0.1.1","maintainers":[{"name":"alufie","email":"dev@alufie.com"}],"dist":{"shasum":"9981e0ffe763313542c68698349e0d324168aee2","tarball":"https://registry.npmjs.org/@alufie/cms/-/cms-0.1.1.tgz","fileCount":44,"integrity":"sha512-JIDLtO++TEr2Y0H/k6W7eeNgtZoRftKmh6OU0fAaAfgYwW662p3NR8IuPrSOuVZ6Pdk5bkxPMynNd6FQfxZEuQ==","signatures":[{"sig":"MEYCIQD0gmovNG2W/Q4Ez0rnISDVQ4URuvoVM5WutOb91/QmAgIhAJGnABEYOKprfva+kpIeYCsKOT7IHdjJw0SJDmDK2Ozj","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":116500},"type":"module","exports":{".":{"types":"./dist/index.d.ts","svelte":"./dist/index.js","default":"./dist/index.js"},"./db":{"types":"./dist/db/index.d.ts","default":"./dist/db/index.js"},"./diff":{"types":"./dist/diff.d.ts","default":"./dist/diff.js"},"./db/pg":{"types":"./dist/db/pg.d.ts","default":"./dist/db/pg.js"},"./types":{"types":"./dist/types.d.ts","default":"./dist/types.js"},"./blocks":{"types":"./dist/blocks/index.d.ts","svelte":"./dist/blocks/index.js","default":"./dist/blocks/index.js"},"./server":{"types":"./dist/server/index.d.ts","default":"./dist/server/index.js"},"./history":{"types":"./dist/history.d.ts","default":"./dist/history.js"},"./autosave":{"types":"./dist/autosave.d.ts","default":"./dist/autosave.js"},"./registry":{"types":"./dist/registry.d.ts","default":"./dist/registry.js"},"./CmsEditor":{"types":"./dist/components/CmsEditor.svelte.d.ts","svelte":"./dist/components/CmsEditor.svelte","default":"./dist/components/CmsEditor.svelte"},"./db/sqlite":{"types":"./dist/db/sqlite.d.ts","default":"./dist/db/sqlite.js"},"./factories":{"types":"./dist/factories.d.ts","default":"./dist/factories.js"},"./validation":{"types":"./dist/validation.d.ts","default":"./dist/validation.js"},"./blocks/Hero":{"types":"./dist/blocks/Hero.svelte.d.ts","svelte":"./dist/blocks/Hero.svelte","default":"./dist/blocks/Hero.svelte"},"./blocks/RichText":{"types":"./dist/blocks/RichText.svelte.d.ts","svelte":"./dist/blocks/RichText.svelte","default":"./dist/blocks/RichText.svelte"},"./server/workflow":{"types":"./dist/server/workflow.d.ts","default":"./dist/server/workflow.js"},"./blocks/ImageBlock":{"types":"./dist/blocks/ImageBlock.svelte.d.ts","svelte":"./dist/blocks/ImageBlock.svelte","default":"./dist/blocks/ImageBlock.svelte"},"./server/uploadHandler":{"types":"./dist/server/uploadHandler.d.ts","default":"./dist/server/uploadHandler.js"}},"scripts":{"test":"vitest run","build":"svelte-package --input src/lib --output dist --tsconfig ./tsconfig.json","check":"svelte-check --tsconfig ./tsconfig.json","prepublishOnly":"pnpm check && pnpm build"},"_npmUser":{"name":"alufie","email":"dev@alufie.com"},"_npmVersion":"11.8.0","description":"Secure, host-authenticated CMS primitives for Svelte 5, SvelteKit, and TypeScript.","directories":{},"sideEffects":false,"_nodeVersion":"22.17.0","dependencies":{"valibot":"^1.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"packageManager":"pnpm@10.32.1","devDependencies":{"vite":"^7.1.9","sharp":"^0.34.0","svelte":"^5.54.1","vitest":"^3.2.4","typescript":"^5.9.3","@types/node":"^24.6.1","drizzle-orm":"^0.45.2","svelte-check":"^4.4.5","@tsconfig/svelte":"^5.0.4","@sveltejs/package":"^2.3.7","@sveltejs/vite-plugin-svelte":"^6.2.1"},"peerDependencies":{"sharp":"^0.34.0","svelte":"^5.0.0","drizzle-orm":"^0.45.2"},"peerDependenciesMeta":{"sharp":{"optional":true},"drizzle-orm":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/cms_0.1.1_1777303271049_0.265523870721752","host":"s3://npm-registry-packages-npm-production"}},"0.1.2":{"name":"@alufie/cms","version":"0.1.2","description":"Secure, host-authenticated CMS primitives for Svelte 5, SvelteKit, and TypeScript.","license":"MIT","author":{"name":"Alufie"},"type":"module","sideEffects":false,"keywords":["cms","svelte","sveltekit","svelte-5","drizzle","editor","r2","sharp"],"publishConfig":{"access":"public"},"exports":{".":{"types":"./dist/index.d.ts","svelte":"./dist/index.js","default":"./dist/index.js"},"./CmsEditor":{"types":"./dist/components/CmsEditor.svelte.d.ts","svelte":"./dist/components/CmsEditor.svelte","default":"./dist/components/CmsEditor.svelte"},"./blocks":{"types":"./dist/blocks/index.d.ts","svelte":"./dist/blocks/index.js","default":"./dist/blocks/index.js"},"./blocks/Hero":{"types":"./dist/blocks/Hero.svelte.d.ts","svelte":"./dist/blocks/Hero.svelte","default":"./dist/blocks/Hero.svelte"},"./blocks/ImageBlock":{"types":"./dist/blocks/ImageBlock.svelte.d.ts","svelte":"./dist/blocks/ImageBlock.svelte","default":"./dist/blocks/ImageBlock.svelte"},"./blocks/RichText":{"types":"./dist/blocks/RichText.svelte.d.ts","svelte":"./dist/blocks/RichText.svelte","default":"./dist/blocks/RichText.svelte"},"./db":{"types":"./dist/db/index.d.ts","default":"./dist/db/index.js"},"./db/pg":{"types":"./dist/db/pg.d.ts","default":"./dist/db/pg.js"},"./db/sqlite":{"types":"./dist/db/sqlite.d.ts","default":"./dist/db/sqlite.js"},"./server":{"types":"./dist/server/index.d.ts","default":"./dist/server/index.js"},"./server/uploadHandler":{"types":"./dist/server/uploadHandler.d.ts","default":"./dist/server/uploadHandler.js"},"./server/workflow":{"types":"./dist/server/workflow.d.ts","default":"./dist/server/workflow.js"},"./autosave":{"types":"./dist/autosave.d.ts","default":"./dist/autosave.js"},"./diff":{"types":"./dist/diff.d.ts","default":"./dist/diff.js"},"./history":{"types":"./dist/history.d.ts","default":"./dist/history.js"},"./factories":{"types":"./dist/factories.d.ts","default":"./dist/factories.js"},"./registry":{"types":"./dist/registry.d.ts","default":"./dist/registry.js"},"./validation":{"types":"./dist/validation.d.ts","default":"./dist/validation.js"},"./types":{"types":"./dist/types.d.ts","default":"./dist/types.js"}},"scripts":{"build":"svelte-package --input src/lib --output dist --tsconfig ./tsconfig.json","check":"svelte-check --tsconfig ./tsconfig.json","test":"vitest run","prepublishOnly":"pnpm check && pnpm build"},"peerDependencies":{"drizzle-orm":"^0.45.2","sharp":"^0.34.0","svelte":"^5.0.0"},"peerDependenciesMeta":{"drizzle-orm":{"optional":true},"sharp":{"optional":true}},"dependencies":{"valibot":"^1.1.0"},"devDependencies":{"@sveltejs/package":"^2.3.7","@sveltejs/vite-plugin-svelte":"^6.2.1","@tsconfig/svelte":"^5.0.4","@types/node":"^24.6.1","drizzle-orm":"^0.45.2","sharp":"^0.34.0","svelte":"^5.54.1","svelte-check":"^4.4.5","typescript":"^5.9.3","vite":"^7.1.9","vitest":"^3.2.4"},"packageManager":"pnpm@10.32.1","_id":"@alufie/cms@0.1.2","_nodeVersion":"22.17.0","_npmVersion":"11.8.0","dist":{"integrity":"sha512-FIpP9A0Cg1veghVVhk4mzgBan+7H87kEvbvAbY+v/31Gz2RwpqiX9+CFJtfSg1MM4A8sDo8JHoqI6v0TkEhieg==","shasum":"e15cd40e5533e712c3b28e105ce079c4e7cee700","tarball":"https://registry.npmjs.org/@alufie/cms/-/cms-0.1.2.tgz","fileCount":44,"unpackedSize":124240,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIBbuUCNVM65phkFKZk1Mgo9zWPFMruajcq/x3dIaKDDPAiAwIyl8kG3NrVawtSIX2thkIgKYKjUrIM750Vg0Tm6RNw=="}]},"_npmUser":{"name":"alufie","email":"dev@alufie.com"},"directories":{},"maintainers":[{"name":"alufie","email":"dev@alufie.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/cms_0.1.2_1777471167346_0.7321143674804433"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-27T14:03:36.326Z","modified":"2026-04-29T13:59:27.578Z","0.1.0":"2026-04-27T14:03:36.604Z","0.1.1":"2026-04-27T15:21:11.217Z","0.1.2":"2026-04-29T13:59:27.489Z"},"author":{"name":"Alufie"},"license":"MIT","keywords":["cms","svelte","sveltekit","svelte-5","drizzle","editor","r2","sharp"],"description":"Secure, host-authenticated CMS primitives for Svelte 5, SvelteKit, and TypeScript.","maintainers":[{"name":"alufie","email":"dev@alufie.com"}],"readme":"# @alufie/cms\n\nSvelte 5 CMS primitives for SvelteKit with host-delegated authorization, server-first document loading, portable Drizzle schemas, and a sharp-based image upload pipeline.\n\nThis package is intentionally small:\n\n- Svelte 5 runes only\n- no bundled auth\n- no client-side initial fetch requirement\n- read-only rendering path with editing fully disabled\n- ESM exports designed for tree shaking\n- Tailwind utility classes instead of packaged CSS\n- Valibot-powered document validation\n- registry-driven block rendering and creation\n\n## What this package contains\n\n`@alufie/cms` ships four groups of building blocks:\n\n1. `CmsEditor`\n2. standard blocks: `Hero`, `ImageBlock`, `RichText`\n3. portable Drizzle schemas for PostgreSQL and SQLite\n4. a server-side `createImageUploadHandler` utility for sharp + R2 style storage\n5. Valibot schemas plus parsing helpers for document validation\n6. a registry layer for extending and constraining available blocks\n7. versioning and publish workflow helpers\n8. a debounced autosave utility for host-side persistence\n9. document diff helpers for review and publish confirmation flows\n10. host-driven image upload wiring for `ImageBlock`\n11. normalization helpers for safer document upgrades\n\n## Security model\n\nThis package does not make authorization decisions.\n\n- It does not include auth providers, session libraries, or token helpers.\n- The host app must decide whether a user can edit in `hooks.server.ts`, `+layout.server.ts`, `+page.server.ts`, or action handlers.\n- `editable={false}` is a hard read-only mode in the UI layer:\n  - the toolbar is not rendered\n  - block mutation handlers early-return\n  - the rich text block removes `contenteditable`\n  - the editor becomes a pure display renderer\n\nTreat `editable` as a convenience for the UI, not as the final security boundary. All writes and uploads still need host-side authorization checks.\n\n## Tailwind requirement\n\nThe components are styled with Tailwind utility classes and do not ship CSS files.\n\nIn the host app, make sure Tailwind scans this package so the utilities are included in the final build.\n\nExample `tailwind.config.ts`:\n\n```ts\nimport type { Config } from 'tailwindcss';\n\nconst config: Config = {\n\tcontent: [\n\t\t'./src/**/*.{html,js,svelte,ts}',\n\t\t'./node_modules/@alufie/cms/dist/**/*.{js,svelte}'\n\t],\n\ttheme: {\n\t\textend: {}\n\t},\n\tplugins: []\n};\n\nexport default config;\n```\n\n## Installation\n\n```bash\npnpm add @alufie/cms\npnpm add -D tailwindcss\npnpm add drizzle-orm sharp\n```\n\nNotes:\n\n- `drizzle-orm` is only needed if you use the bundled schemas.\n- `sharp` is only needed if you use the upload handler.\n- The package is published as ESM and is tree-shake friendly.\n\n## Quick start\n\n### 1. Load the document on the server\n\nDo not fetch the initial document on the client. Load it in SvelteKit server code and pass it directly into the page.\n\n```ts\n// src/routes/cms/[slug]/+page.server.ts\nimport type { PageServerLoad } from './$types';\nimport { error } from '@sveltejs/kit';\n\nexport const load: PageServerLoad = async ({ params, locals }) => {\n\tconst page = await locals.db.query.cmsDocuments.findFirst({\n\t\twhere: (table, { eq }) => eq(table.slug, params.slug)\n\t});\n\n\tif (!page) {\n\t\tthrow error(404, 'Page not found');\n\t}\n\n\tconst editable = locals.user?.role === 'admin' || locals.user?.role === 'editor';\n\n\treturn {\n\t\tdocument: page.document,\n\t\teditable\n\t};\n};\n```\n\n### 2. Render the editor in the page\n\n```svelte\n<!-- src/routes/cms/[slug]/+page.svelte -->\n<script lang=\"ts\">\n\timport { CmsEditor, type CmsDocument } from '@alufie/cms';\n\timport type { PageData } from './$types';\n\n\tlet { data }: { data: PageData } = $props();\n\tlet document = $state<CmsDocument>(data.document);\n\n\t$effect(() => {\n\t\tdocument = data.document;\n\t});\n</script>\n\n<CmsEditor\n\tdata={document}\n\teditable={data.editable}\n\tuploadImage={async (file) => {\n\t\tconst formData = new FormData();\n\t\tformData.set('file', file);\n\n\t\tconst response = await fetch('/api/cms/upload', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: formData\n\t\t});\n\n\t\tconst result = await response.json();\n\t\treturn {\n\t\t\tsrc: result.src,\n\t\t\twidth: result.width,\n\t\t\theight: result.height\n\t\t};\n\t}}\n\tonChange={(next) => {\n\t\tif (!data.editable) return;\n\t\tdocument = next;\n\t}}\n/>\n```\n\n### 3. Save changes through a host-controlled action or endpoint\n\n```ts\n// src/routes/cms/[slug]/+page.server.ts\nimport { fail } from '@sveltejs/kit';\n\nexport const actions = {\n\tsave: async ({ request, locals, params }) => {\n\t\tif (locals.user?.role !== 'admin' && locals.user?.role !== 'editor') {\n\t\t\treturn fail(403, { message: 'Forbidden' });\n\t\t}\n\n\t\tconst { document } = await request.json();\n\n\t\tawait locals.db\n\t\t\t.update(locals.schema.cmsDocuments)\n\t\t\t.set({\n\t\t\t\tdocument,\n\t\t\t\tupdatedAt: new Date()\n\t\t\t})\n\t\t\t.where(locals.eq(locals.schema.cmsDocuments.slug, params.slug));\n\n\t\treturn { ok: true };\n\t}\n};\n```\n\n## File structure\n\n```text\nsrc/lib/\n  blocks/\n    Hero.svelte\n    ImageBlock.svelte\n    RichText.svelte\n    index.ts\n  components/\n    CmsEditor.svelte\n  db/\n    index.ts\n    pg.ts\n    shared.ts\n    sqlite.ts\n  server/\n    index.ts\n    uploadHandler.ts\n  index.ts\n  types.ts\n```\n\n## Public exports\n\nYou can import from the root entrypoint or from narrow subpaths.\n\n### Root imports\n\n```ts\nimport {\n\tCmsEditor,\n\tHero,\n\tImageBlock,\n\tRichText,\n\tpgCmsDocuments,\n\tsqliteCmsDocuments,\n\tcreateImageUploadHandler\n} from '@alufie/cms';\n```\n\n### Narrow imports\n\nPrefer narrow imports when you know exactly what you need.\n\n```ts\nimport CmsEditor from '@alufie/cms/CmsEditor';\nimport Hero from '@alufie/cms/blocks/Hero';\nimport { createCmsAutosave } from '@alufie/cms/autosave';\nimport { diffCmsDocuments, summarizeCmsDocumentDiff } from '@alufie/cms/diff';\nimport { createHeroBlock } from '@alufie/cms/factories';\nimport { normalizeCmsDocument } from '@alufie/cms/normalize';\nimport { defaultCmsBlockRegistry } from '@alufie/cms/registry';\nimport { pgCmsDocuments } from '@alufie/cms/db/pg';\nimport { createImageUploadHandler } from '@alufie/cms/server/uploadHandler';\nimport { publishCmsDocument } from '@alufie/cms/server/workflow';\nimport { parseCmsDocument } from '@alufie/cms/validation';\nimport type { CmsDocument } from '@alufie/cms/types';\n```\n\n## Tree shaking and package weight\n\nThe package is set up to keep unused code out of consumer bundles:\n\n- `\"sideEffects\": false` in `package.json`\n- ESM-only exports\n- split subpath exports for components, blocks, db, server, and types\n- no global CSS import\n- no bundled auth implementation\n\nTo get the best result:\n\n1. prefer narrow subpath imports for specialized use cases\n2. keep server-only imports in server files\n3. only install `drizzle-orm` and `sharp` if you use those features\n\n## Versioning and publish workflow\n\nThe package now includes storage-agnostic workflow helpers for:\n\n- draft saves\n- publish transitions\n- archive transitions\n- restore transitions\n- version snapshot creation\n\nThese helpers do not write to the database for you. They validate and shape the next document/version payloads so the host app stays in control.\n\n### Create a draft save and version snapshot\n\n```ts\nimport { updateCmsDocument } from '@alufie/cms/server/workflow';\n\nconst result = updateCmsDocument({\n\tdocument: payload.document,\n\tversion: currentVersion + 1,\n\tcreatedBy: locals.user.id\n});\n\nawait db.transaction(async (tx) => {\n\tawait tx.update(cmsDocuments).set(result.document).where(...);\n\tawait tx.insert(cmsDocumentVersions).values(result.version);\n});\n```\n\n### Publish a document\n\n```ts\nimport { publishCmsDocument } from '@alufie/cms/server/workflow';\n\nconst result = publishCmsDocument({\n\tdocument: payload.document,\n\tversion: currentVersion + 1,\n\tcreatedBy: locals.user.id\n});\n```\n\n## Review and diff helpers\n\nThe package now includes diff helpers so the host app can generate review summaries before saving or publishing.\n\n### Compare two documents\n\n```ts\nimport { diffCmsDocuments } from '@alufie/cms/diff';\n\nconst diff = diffCmsDocuments(previousDocument, nextDocument);\n\nconsole.log(diff.counts);\nconsole.log(diff.items);\n```\n\n### Generate user-facing change summaries\n\n```ts\nimport { summarizeCmsDocumentDiff } from '@alufie/cms/diff';\n\nconst summary = summarizeCmsDocumentDiff(previousDocument, nextDocument);\n\n// Example:\n// [\n//   'Title changed',\n//   'Moved hero (block-123) from position 1 to 2',\n//   'Updated richText (block-456)'\n// ]\n```\n\n### Use a diff in a publish review step\n\n```ts\nimport { publishCmsDocument } from '@alufie/cms/server/workflow';\nimport { summarizeCmsDocumentDiff } from '@alufie/cms/diff';\n\nconst reviewSummary = summarizeCmsDocumentDiff(currentDocument, incomingDocument);\n\nconst publishResult = publishCmsDocument({\n\tdocument: incomingDocument,\n\tversion: currentVersion + 1,\n\tcreatedBy: locals.user.id\n});\n```\n\n### Archive or restore a document\n\n```ts\nimport { archiveCmsDocument, restoreCmsDocument } from '@alufie/cms/server/workflow';\n```\n\n## Version table schemas\n\nThe Drizzle package surface now includes version tables:\n\n- `pgCmsDocumentVersions`\n- `sqliteCmsDocumentVersions`\n\nThese tables store:\n\n- document id\n- numeric version\n- reason (`draft-save`, `publish`, `archive`, `restore`, `manual`)\n- full document snapshot\n- created-at metadata\n- created-by metadata\n\n## Autosave guide\n\nThe package now includes a small debounced autosave helper for host-managed persistence.\n\n### Create an autosave controller\n\n```ts\nimport { createCmsAutosave } from '@alufie/cms/autosave';\n\nconst autosave = createCmsAutosave({\n\tdelayMs: 1000,\n\tsave: async (document) => {\n\t\tawait fetch('/api/cms/save', {\n\t\t\tmethod: 'POST',\n\t\t\theaders: { 'content-type': 'application/json' },\n\t\t\tbody: JSON.stringify({ document })\n\t\t});\n\t},\n\tonStateChange: (state) => {\n\t\tconsole.log(state.pending, state.lastSavedAt, state.lastError);\n\t}\n});\n```\n\n### Queue changes from the editor\n\n```svelte\n<script lang=\"ts\">\n\timport { CmsEditor } from '@alufie/cms';\n\timport { createCmsAutosave } from '@alufie/cms/autosave';\n\n\tlet { data } = $props();\n\tlet document = $state(data.document);\n\n\tconst autosave = createCmsAutosave({\n\t\tsave: async (next) => {\n\t\t\tawait fetch('/api/cms/save', {\n\t\t\t\tmethod: 'POST',\n\t\t\t\theaders: { 'content-type': 'application/json' },\n\t\t\t\tbody: JSON.stringify({ document: next })\n\t\t\t});\n\t\t}\n\t});\n</script>\n\n<CmsEditor\n\tdata={document}\n\teditable={data.editable}\n\tonChange={(next) => {\n\t\tdocument = next;\n\t\tautosave.queue(next);\n\t}}\n/>\n```\n\n## Editor validation UX\n\nWhen `validateOnChange={true}`, the editor now keeps invalid edits from being committed and can render a visible validation panel above the canvas.\n\n```svelte\n<CmsEditor\n\tdata={document}\n\teditable={data.editable}\n\tvalidateOnChange={true}\n\tshowValidationIssues={true}\n\tonInvalidDocument={(issues) => {\n\t\tconsole.error(issues);\n\t}}\n/>\n```\n\nIf you prefer to manage validation feedback entirely in the host app, set `showValidationIssues={false}` and use `onInvalidDocument`.\n\n## Image upload UI\n\n`ImageBlock` can consume a host-provided `uploadImage(file)` callback through `CmsEditor`.\n\n```svelte\n<CmsEditor\n\tdata={document}\n\teditable={data.editable}\n\tuploadImage={async (file) => {\n\t\tconst formData = new FormData();\n\t\tformData.set('file', file);\n\n\t\tconst response = await fetch('/api/cms/upload', {\n\t\t\tmethod: 'POST',\n\t\t\tbody: formData\n\t\t});\n\n\t\tconst result = await response.json();\n\t\treturn {\n\t\t\tsrc: result.src,\n\t\t\twidth: result.width,\n\t\t\theight: result.height\n\t\t};\n\t}}\n/>\n```\n\nThe package only uses the returned `src`, `width`, and `height`. Auth, rate limiting, and storage decisions still belong to the host app.\n\n## Normalization guide\n\nUse normalization helpers when importing legacy content, seeding documents, or migrating payloads between versions.\n\n```ts\nimport { normalizeCmsDocument } from '@alufie/cms/normalize';\n\nconst document = normalizeCmsDocument(unknownPayload);\n```\n\nCurrent normalization behavior:\n\n- fills missing document defaults\n- fills missing built-in block fields\n- upgrades legacy `richText` string data to `{ text: string }`\n\n## Validation guide\n\nThe package now ships Valibot schemas and helpers so the host app can validate incoming documents before save or publish.\n\n### Validate a document on the server\n\n```ts\nimport { parseCmsDocument } from '@alufie/cms/validation';\n\nconst document = parseCmsDocument(requestPayload);\n```\n\n### Safe-parse a document and return structured errors\n\n```ts\nimport { formatCmsValidationIssues, safeParseCmsDocument } from '@alufie/cms/validation';\n\nconst result = safeParseCmsDocument(requestPayload);\n\nif (!result.success) {\n\treturn {\n\t\tok: false,\n\t\terrors: formatCmsValidationIssues(result.issues)\n\t};\n}\n```\n\n### Validate against a custom registry\n\n```ts\nimport { createCmsBlockRegistry, defaultCmsBlockRegistry } from '@alufie/cms/registry';\nimport { safeParseCmsDocument } from '@alufie/cms/validation';\n\nconst registry = createCmsBlockRegistry({}, defaultCmsBlockRegistry);\nconst result = safeParseCmsDocument(payload, registry);\n```\n\n## Registry guide\n\nThe editor no longer hardcodes its block list. It now uses a registry object that defines:\n\n- which block types exist\n- which component renders each block\n- how new blocks are created\n- which Valibot schema validates each block's data\n\n### Built-in registry\n\n```ts\nimport { defaultCmsBlockRegistry } from '@alufie/cms/registry';\n```\n\n### Restrict the editor to specific block types\n\n```svelte\n<CmsEditor\n\tdata={document}\n\teditable={data.editable}\n\tallowedBlockTypes={['hero', 'richText']}\n/>\n```\n\n### Validate on each change\n\n```svelte\n<CmsEditor\n\tdata={document}\n\teditable={data.editable}\n\tvalidateOnChange={true}\n\tonInvalidDocument={(issues) => {\n\t\tconsole.error(issues);\n\t}}\n/>\n```\n\n### Add a custom block\n\n```ts\nimport type { CmsBlockDefinition } from '@alufie/cms/types';\nimport { createCmsBlockId } from '@alufie/cms/factories';\nimport { createCmsBlockRegistry, defaultCmsBlockRegistry } from '@alufie/cms/registry';\nimport * as v from 'valibot';\nimport QuoteBlock from '$lib/components/QuoteBlock.svelte';\n\nconst quoteBlockDefinition = {\n\ttype: 'quote',\n\tlabel: 'Quote',\n\tcomponent: QuoteBlock,\n\tcreate: () => ({\n\t\tid: createCmsBlockId(),\n\t\ttype: 'quote',\n\t\tdata: {\n\t\t\tquote: '',\n\t\t\tattribution: ''\n\t\t}\n\t}),\n\tschema: v.object({\n\t\tquote: v.string(),\n\t\tattribution: v.string()\n\t})\n} satisfies CmsBlockDefinition;\n\nexport const cmsRegistry = createCmsBlockRegistry({\n\tquote: quoteBlockDefinition\n}, defaultCmsBlockRegistry);\n```\n\nThen pass that registry into the editor:\n\n```svelte\n<CmsEditor data={document} editable={data.editable} registry={cmsRegistry} />\n```\n\n## Data model\n\n### `CmsDocument`\n\n```ts\ntype CmsDocument = {\n\tid?: string;\n\ttitle?: string;\n\tblocks: CmsBlock[];\n\tupdatedAt?: string;\n};\n```\n\n### `CmsBlock`\n\n```ts\ntype CmsBlock = CmsHeroBlock | CmsImageBlock | CmsRichTextBlock;\n```\n\n### `CmsHeroBlock`\n\n```ts\ntype CmsHeroBlock = {\n\tid: string;\n\ttype: 'hero';\n\tdata: {\n\t\teyebrow: string;\n\t\ttitle: string;\n\t\tsummary: string;\n\t\tctaLabel: string;\n\t\tctaHref: string;\n\t\talign: 'left' | 'center';\n\t};\n};\n```\n\n### `CmsImageBlock`\n\n```ts\ntype CmsImageBlock = {\n\tid: string;\n\ttype: 'image';\n\tdata: {\n\t\tsrc: string;\n\t\talt: string;\n\t\tcaption: string;\n\t\twidth: number | null;\n\t\theight: number | null;\n\t};\n};\n```\n\n### `CmsRichTextBlock`\n\n```ts\ntype CmsRichTextBlock = {\n\tid: string;\n\ttype: 'richText';\n\tdata: {\n\t\ttext: string;\n\t};\n};\n```\n\n## Component guide\n\n### `CmsEditor`\n\nFile: `src/lib/components/CmsEditor.svelte`\n\nResponsibilities:\n\n- receives a server-provided `CmsDocument`\n- clones the input into local state\n- renders blocks in order\n- renders toolbar and block controls only when `editable === true`\n- blocks all mutations when `editable === false`\n- emits `onChange(document)` when a block changes\n\nProps:\n\n```ts\ntype CmsEditorProps = {\n\tdata: CmsDocument;\n\teditable?: boolean;\n\tuploadImage?: (file: File) => Promise<{ src: string; width?: number | null; height?: number | null }>;\n\tonChange?: (document: CmsDocument) => void;\n};\n```\n\nKey internal functions:\n\n- `cloneDocument(value)`:\n  creates a safe mutable copy of the incoming document\n- `cloneBlock(block)`:\n  preserves block discriminated union typing while cloning block data\n- `commit(blocks)`:\n  replaces editor state and emits `onChange`\n- `createId()`:\n  generates a UUID for newly inserted blocks\n- `createBlock(type)`:\n  creates a default block from a built-in template\n- `insertBlock(type, index?)`:\n  inserts a new block\n- `updateBlock(index, block)`:\n  replaces a block after child edits\n- `moveBlock(index, direction)`:\n  reorders blocks\n- `duplicateBlock(index)`:\n  clones a block and inserts it after the original\n- `removeBlock(index)`:\n  deletes a block\n- `registry` prop:\n  controls which blocks exist, render, validate, and can be inserted\n- `validateOnChange` prop:\n  runs Valibot validation before emitting `onChange`\n- `allowedBlockTypes` prop:\n  limits toolbar insertion choices without changing the document model\n- `uploadImage` prop:\n  lets the host wire authenticated image uploads into `ImageBlock`\n\n### `Hero`\n\nFile: `src/lib/blocks/Hero.svelte`\n\nResponsibilities:\n\n- renders hero content\n- exposes form inputs when editable\n- falls back to semantic read-only display when not editable\n\n### `ImageBlock`\n\nFile: `src/lib/blocks/ImageBlock.svelte`\n\nResponsibilities:\n\n- renders image metadata inputs in edit mode\n- renders the image and caption in both modes\n- supports width and height metadata for layout stability\n- optionally uploads a local image through the host callback\n\n### `RichText`\n\nFile: `src/lib/blocks/RichText.svelte`\n\nResponsibilities:\n\n- uses `contenteditable` only in edit mode\n- becomes plain read-only text when not editable\n- stores plain text, which keeps rendering safe by default\n\n## Drizzle schema guide\n\n### Shared constants\n\nFile: `src/lib/db/shared.ts`\n\n- `CMS_DOCUMENT_TABLE`\n- `CMS_DOCUMENT_COLUMNS`\n- `CMS_DOCUMENT_VERSION_TABLE`\n- `CMS_DOCUMENT_VERSION_COLUMNS`\n- `CMS_DOCUMENT_STATUSES`\n- `CMS_VERSION_REASONS`\n\nThese keep table naming and column naming consistent across SQLite and PostgreSQL adapters.\n\n### PostgreSQL schema\n\nFile: `src/lib/db/pg.ts`\n\nExport:\n\n- `pgCmsDocuments`\n- `pgCmsDocumentVersions`\n\nShape:\n\n- `id`: primary key\n- `slug`: unique route identifier\n- `title`: document title\n- `document`: JSONB payload\n- `status`: draft/published style state\n- `createdAt`\n- `updatedAt`\n\n### SQLite schema\n\nFile: `src/lib/db/sqlite.ts`\n\nExport:\n\n- `sqliteCmsDocuments`\n- `sqliteCmsDocumentVersions`\n\nThis mirrors the PostgreSQL schema, but stores `document` as JSON text and timestamps as `timestamp_ms` integers.\n\n## Upload handler guide\n\nFile: `src/lib/server/uploadHandler.ts`\n\nExport:\n\n- `createImageUploadHandler(options)`\n\nPurpose:\n\n- validate input file type and size\n- normalize rotation\n- enforce hard image dimension limits\n- resize for delivery\n- convert to webp\n- upload through a host-supplied storage adapter\n- return metadata ready for the CMS document\n\n### Function signature\n\n```ts\ntype CreateImageUploadHandlerOptions = {\n\tmaxBytes?: number;\n\tmaxWidth?: number;\n\tmaxHeight?: number;\n\tallowedMimeTypes?: readonly string[];\n\tmakeKey?: (file: UploadFileLike) => string;\n\tputObject: (params: {\n\t\tkey: string;\n\t\tbody: Buffer;\n\t\tcontentType: string;\n\t\tcacheControl: string;\n\t}) => Promise<{ src: string } | string>;\n};\n```\n\n### Example with an R2-style client\n\n```ts\nimport { createImageUploadHandler } from '@alufie/cms/server/uploadHandler';\n\nexport const uploadImage = createImageUploadHandler({\n\tputObject: async ({ key, body, contentType, cacheControl }) => {\n\t\tawait env.BUCKET.put(key, body, {\n\t\t\thttpMetadata: {\n\t\t\t\tcontentType,\n\t\t\t\tcacheControl\n\t\t\t}\n\t\t});\n\n\t\treturn {\n\t\t\tsrc: `https://cdn.example.com/${key}`\n\t\t};\n\t}\n});\n```\n\n## How to expand the package\n\n### Add a new block type\n\n1. create a new block component\n2. define a block definition with `type`, `label`, `component`, `create`, and `schema`\n3. merge it into the registry with `createCmsBlockRegistry`\n4. pass that registry into `CmsEditor`\n5. validate documents with `safeParseCmsDocument(payload, registry)`\n\n### Use the factory helpers\n\nFactory helpers make it easier to create consistent content programmatically:\n\n```ts\nimport { createCmsDocument, createHeroBlock, createImageBlock } from '@alufie/cms/factories';\n\nconst document = createCmsDocument({\n\ttitle: 'Home',\n\tblocks: [\n\t\tcreateHeroBlock({ title: 'Welcome' }),\n\t\tcreateImageBlock({ src: 'https://cdn.example.com/hero.webp' })\n\t]\n});\n```\n\n### Add richer persistence\n\n1. keep the editor document format stable\n2. add migration logic in the host app when block structure changes\n3. version documents in your own schema if backward compatibility matters\n\n### Add richer text semantics\n\nRight now `RichText` stores plain text for safety and predictable rendering. If you need structured rich text:\n\n1. define a structured document model in `types.ts`\n2. replace the current block implementation\n3. keep the `editable={false}` path free of mutation hooks\n4. sanitize any HTML rendering in the host app or in a dedicated renderer\n\n## Recommended host integration pattern\n\n1. authorize once on the server\n2. load the document in `+page.server.ts`\n3. pass `document` and `editable` into the page\n4. render `CmsEditor`\n5. save via server actions or `+server.ts`\n6. re-check role permissions on every write\n\n## Development\n\n```bash\npnpm install\npnpm check\npnpm build\n```\n\n## Current package status\n\nThe scaffold currently provides:\n\n- Svelte 5 runes-only components\n- Tailwind utility styling\n- read-only safe rendering path\n- portable Drizzle schemas\n- document version table schemas\n- publish/archive/restore workflow helpers\n- debounced autosave helper\n- document diff and review helpers\n- inline validation issue rendering in the editor\n- image upload callback support in `ImageBlock`\n- normalization helpers for legacy content\n- image upload helper\n- narrow exports for tree shaking\n\nIt does not yet provide:\n\n- persistence actions\n- auth implementation\n- image picker UI\n- collaborative editing\n- structured rich text schema\n","readmeFilename":"README.md"}