@velastack/cms 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +107 -18
  2. package/dist/backend/index.d.ts +1 -1
  3. package/dist/backend/routes/seed.d.ts +6 -0
  4. package/dist/backend/routes/seed.js +96 -0
  5. package/dist/backend/routes/table.js +3 -0
  6. package/dist/backend/store/queries.d.ts +6 -1
  7. package/dist/backend/store/queries.js +89 -0
  8. package/dist/cms.js +13 -16
  9. package/dist/components/admin-bar/admin-bar-internal.svelte +4 -4
  10. package/dist/components/admin-bar/admin-bar.css +1 -1
  11. package/dist/components/admin-bar/field-input.svelte +220 -0
  12. package/dist/components/admin-bar/field-input.svelte.d.ts +29 -0
  13. package/dist/components/admin-bar/icons/pencil.svelte +19 -0
  14. package/dist/components/admin-bar/icons/pencil.svelte.d.ts +3 -0
  15. package/dist/components/admin-bar/keyboard-shortcuts-dialog.svelte +1 -1
  16. package/dist/components/admin-bar/locales-panel.svelte +42 -1
  17. package/dist/components/admin-bar/page-config.d.ts +8 -1
  18. package/dist/components/admin-bar/page-config.js +15 -2
  19. package/dist/components/admin-bar/reorder-buttons.svelte +52 -0
  20. package/dist/components/admin-bar/reorder-buttons.svelte.d.ts +10 -0
  21. package/dist/components/admin-bar/seo-panel.svelte +144 -189
  22. package/dist/components/admin-bar/site-options-panel.svelte +115 -0
  23. package/dist/components/admin-bar/site-options-panel.svelte.d.ts +7 -0
  24. package/dist/components/admin-bar/structured-editor-popover.svelte +250 -0
  25. package/dist/components/admin-bar/structured-editor-popover.svelte.d.ts +48 -0
  26. package/dist/components/admin-bar/ui/checkbox/checkbox.svelte +36 -0
  27. package/dist/components/admin-bar/ui/checkbox/checkbox.svelte.d.ts +5 -0
  28. package/dist/components/admin-bar/ui/checkbox/index.d.ts +2 -0
  29. package/dist/components/admin-bar/ui/checkbox/index.js +2 -0
  30. package/dist/components/admin-bar/ui/popover/index.d.ts +5 -0
  31. package/dist/components/admin-bar/ui/popover/index.js +7 -0
  32. package/dist/components/admin-bar/ui/popover/popover-close.svelte +7 -0
  33. package/dist/components/admin-bar/ui/popover/popover-close.svelte.d.ts +4 -0
  34. package/dist/components/admin-bar/ui/popover/popover-content.svelte +35 -0
  35. package/dist/components/admin-bar/ui/popover/popover-content.svelte.d.ts +10 -0
  36. package/dist/components/admin-bar/ui/popover/popover-trigger.svelte +7 -0
  37. package/dist/components/admin-bar/ui/popover/popover-trigger.svelte.d.ts +4 -0
  38. package/dist/components/admin-bar/ui/popover/popover.svelte +7 -0
  39. package/dist/components/admin-bar/ui/popover/popover.svelte.d.ts +3 -0
  40. package/dist/components/admin-bar/ui/sheet/index.d.ts +10 -0
  41. package/dist/components/admin-bar/ui/sheet/index.js +12 -0
  42. package/dist/components/admin-bar/ui/sheet/sheet-close.svelte +7 -0
  43. package/dist/components/admin-bar/ui/sheet/sheet-close.svelte.d.ts +4 -0
  44. package/dist/components/admin-bar/ui/sheet/sheet-content.svelte +64 -0
  45. package/dist/components/admin-bar/ui/sheet/sheet-content.svelte.d.ts +17 -0
  46. package/dist/components/admin-bar/ui/sheet/sheet-description.svelte +17 -0
  47. package/dist/components/admin-bar/ui/sheet/sheet-description.svelte.d.ts +4 -0
  48. package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte +23 -0
  49. package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte.d.ts +5 -0
  50. package/dist/components/admin-bar/ui/sheet/sheet-header.svelte +20 -0
  51. package/dist/components/admin-bar/ui/sheet/sheet-header.svelte.d.ts +5 -0
  52. package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte +20 -0
  53. package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
  54. package/dist/components/admin-bar/ui/sheet/sheet-title.svelte +17 -0
  55. package/dist/components/admin-bar/ui/sheet/sheet-title.svelte.d.ts +4 -0
  56. package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte +7 -0
  57. package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
  58. package/dist/components/admin-bar/ui/sheet/sheet.svelte +7 -0
  59. package/dist/components/admin-bar/ui/sheet/sheet.svelte.d.ts +3 -0
  60. package/dist/components/admin-bar/ui/switch/index.d.ts +2 -0
  61. package/dist/components/admin-bar/ui/switch/index.js +2 -0
  62. package/dist/components/admin-bar/ui/switch/switch.svelte +27 -0
  63. package/dist/components/admin-bar/ui/switch/switch.svelte.d.ts +5 -0
  64. package/dist/components/admin-bar/ui/tabs/index.d.ts +5 -0
  65. package/dist/components/admin-bar/ui/tabs/index.js +7 -0
  66. package/dist/components/admin-bar/ui/tabs/tabs-content.svelte +17 -0
  67. package/dist/components/admin-bar/ui/tabs/tabs-content.svelte.d.ts +4 -0
  68. package/dist/components/admin-bar/ui/tabs/tabs-list.svelte +16 -0
  69. package/dist/components/admin-bar/ui/tabs/tabs-list.svelte.d.ts +4 -0
  70. package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte +20 -0
  71. package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
  72. package/dist/components/admin-bar/ui/tabs/tabs.svelte +19 -0
  73. package/dist/components/admin-bar/ui/tabs/tabs.svelte.d.ts +4 -0
  74. package/dist/components/cms/cms-boolean.svelte +9 -18
  75. package/dist/components/cms/cms-boolean.svelte.d.ts +1 -0
  76. package/dist/components/cms/cms-date-time-editable.svelte +3 -3
  77. package/dist/components/cms/cms-date-time.svelte +17 -17
  78. package/dist/components/cms/cms-date-time.svelte.d.ts +2 -1
  79. package/dist/components/cms/cms-image-editable.svelte +13 -4
  80. package/dist/components/cms/cms-image.svelte +37 -30
  81. package/dist/components/cms/cms-image.svelte.d.ts +11 -4
  82. package/dist/components/cms/cms-link.svelte +26 -19
  83. package/dist/components/cms/cms-link.svelte.d.ts +2 -0
  84. package/dist/components/cms/cms-markdown.svelte +15 -16
  85. package/dist/components/cms/cms-markdown.svelte.d.ts +1 -0
  86. package/dist/components/cms/cms-number-editable.svelte +4 -2
  87. package/dist/components/cms/cms-number.svelte +18 -17
  88. package/dist/components/cms/cms-number.svelte.d.ts +2 -1
  89. package/dist/components/cms/cms-repeater.svelte +15 -18
  90. package/dist/components/cms/cms-repeater.svelte.d.ts +1 -0
  91. package/dist/components/cms/cms-rich-text.svelte +17 -22
  92. package/dist/components/cms/cms-rich-text.svelte.d.ts +1 -0
  93. package/dist/components/cms/cms-store.svelte.d.ts +14 -0
  94. package/dist/components/cms/cms-store.svelte.js +39 -2
  95. package/dist/components/cms/cms-text.svelte +23 -30
  96. package/dist/components/cms/cms-text.svelte.d.ts +3 -1
  97. package/dist/components/cms/scope.d.ts +25 -5
  98. package/dist/components/cms/structured-registry.d.ts +10 -0
  99. package/dist/components/cms/structured-registry.js +6 -0
  100. package/dist/components/cms/use-cms-field.svelte.d.ts +54 -0
  101. package/dist/components/cms/use-cms-field.svelte.js +83 -0
  102. package/dist/core/metadata.d.ts +44 -0
  103. package/dist/core/metadata.js +59 -0
  104. package/dist/core/structured.d.ts +108 -0
  105. package/dist/core/structured.js +184 -0
  106. package/dist/core/wire.d.ts +31 -1
  107. package/dist/editor.d.ts +22 -0
  108. package/dist/editor.js +21 -0
  109. package/dist/index.d.ts +12 -4
  110. package/dist/index.js +13 -3
  111. package/dist/server/load-cms.js +1 -0
  112. package/package.json +12 -3
  113. package/plugin/README.md +22 -2
  114. package/plugin/dist/build.d.ts +74 -0
  115. package/plugin/dist/build.js +210 -0
  116. package/plugin/dist/build.js.map +1 -0
  117. package/plugin/dist/index.d.ts +10 -1
  118. package/plugin/dist/index.js +63 -4
  119. package/plugin/dist/index.js.map +1 -1
  120. package/plugin/dist/manifest.d.ts +31 -0
  121. package/plugin/dist/manifest.js +146 -41
  122. package/plugin/dist/manifest.js.map +1 -1
  123. package/plugin/dist/parse-svelte.d.ts +15 -0
  124. package/plugin/dist/parse-svelte.js +26 -16
  125. package/plugin/dist/parse-svelte.js.map +1 -1
  126. package/dist/components/admin-bar/site-settings-panel.svelte +0 -225
  127. package/dist/components/admin-bar/site-settings-panel.svelte.d.ts +0 -7
package/README.md CHANGED
@@ -201,13 +201,18 @@ If `endpoint` is omitted (e.g. when using `mockAdapter` for tests/demos), the pl
201
201
 
202
202
  ## CMS components
203
203
 
204
- All four included CMS components share the same prop shape:
204
+ Every CMS component shares the same prop shape:
205
205
 
206
- | Prop | Type | Notes |
207
- | ---------- | ------------------ | ------------------------------------------------------------------------ |
208
- | `name` | `string` | Field path inside this scope. Must be a static string at v1. |
209
- | `fallback` | varies | Rendered when no value is stored. `string` for text, HTML for rich text. |
210
- | `value` | `unknown` (opt-in) | Per-item override used inside `<CmsRepeater/>`; bypasses scope lookup. |
206
+ | Prop | Type | Notes |
207
+ | ---------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `name` | `string` | Field path inside this scope. Must be a static string at v1. |
209
+ | `scope` | `'root' \| string` | Read and write a value that lives in the root layout scope (`'root'`, i.e. `layout:/`) or another param-less layout scope (`layout:/(public)`). |
210
+ | `fallback` | varies | Rendered when **nothing** is stored. `string` for text, HTML for rich text, `{ url, alt }` or a URL for images. |
211
+ | `value` | `unknown` (opt-in) | Per-item override; bypasses scope lookup and disables editing. |
212
+
213
+ Two stored states are distinct: `undefined` (nothing stored) renders the fallback, `null` (the editor cleared the field) renders empty. Editables write `null` on clear, never drop the key, so a cleared field can't fall back to the template's demo copy after publish.
214
+
215
+ `scope="root"` is how site-wide content — hours, contact, navigation, branding — is edited where it renders: put the component in the root `+layout.svelte` and reference the same `name` from any page with `scope="root"`. The payload already carries every enclosing layout tree, so no extra fetch happens.
211
216
 
212
217
  ### `<CmsText />`
213
218
 
@@ -248,7 +253,55 @@ Renders an `<img>`. Edit mode shows the current image plus a URL input.
248
253
  </CmsRepeater>
249
254
  ```
250
255
 
251
- Iterates an array stored at `name`. Inside the snippet, pass per-item values via `value=` to bypass scope lookup. Edit mode renders the same snippet for each item plus add/remove controls and per-key inputs.
256
+ Iterates an array stored at `name`. Inside the snippet, pass per-item values via `value=` to bypass scope lookup. Edit mode renders the same snippet for each item plus add/remove controls and per-key inputs. `CmsRepeater` is the placeholder for the structured list components; new templates should build on `defineStructured` below.
257
+
258
+ ## Structured components
259
+
260
+ Hours, team, testimonials and the like are one value with a fixed shape, edited in a slot-anchored popover and rendered by the template through a snippet. The package ships the pieces a structured component is made of; the components themselves come with the editor-components release.
261
+
262
+ ```ts
263
+ // hours.ts — the value's rules
264
+ import { defineStructured, registerStructured, asItems, asString } from '@velastack/cms';
265
+
266
+ export const hours = registerStructured(
267
+ defineStructured<Hours>({
268
+ component: 'CmsHours', // canonical export name, recorded in the manifest
269
+ version: 1, // written as `v` on every value
270
+ translatable: ['note'], // root fields that translate
271
+ items: { key: 'days', translatable: ['label'] }, // the list and its translatable fields
272
+ normalize: (raw) => ({ v: 1, note: asString(raw?.note), days: asItems(raw?.days, …) }),
273
+ empty: () => ({ v: 1, note: '', days: [] })
274
+ })
275
+ );
276
+ ```
277
+
278
+ ```svelte
279
+ <!-- cms-hours.svelte — the display component -->
280
+ <script lang="ts">
281
+ import { useCmsField } from '@velastack/cms';
282
+ let { name, scope, fallback, children } = $props();
283
+ const field = useCmsField(
284
+ () => ({ name, scope }),
285
+ (raw) => hours.read(raw, fallback)
286
+ );
287
+ </script>
288
+
289
+ {#if field.editable}
290
+ {#await import('./cms-hours-editable.svelte') then { default: Editable }}
291
+ <Editable {field} {children} />
292
+ {/await}
293
+ {:else}
294
+ {@render children(field.current)}
295
+ {/if}
296
+ ```
297
+
298
+ The editable sibling wraps `StructuredEditorPopover` from `@velastack/cms/editor` (also `FieldInput`, `ReorderButtons`, the Popover / Tabs / Switch / Checkbox / Sheet primitives and `CmsMediaPicker`), passes a `preview` snippet and an `editor` snippet over a local draft, and the popover commits on **Done**. Edit mode is page-wide; the bar's Save flushes the draft.
299
+
300
+ Rules every structured value follows (`core/structured.ts`):
301
+
302
+ - **Whole-object writes, explicit `null` clears.** Components never patch a leaf; they write the full `{ v, … }` object. Cleared fields are `null`.
303
+ - **List items carry a stable `id`** (`newItemId()`); reorder and delete work because arrays replace wholesale on merge.
304
+ - **Localisation is a string overlay.** The default locale stores the full value at `<name>`; every other locale stores only strings at `<name>.$t.<id>.<field>` (`_` is the id for root fields). `read()` folds the overlay in, structure never forks between languages, and the popover's locale tabs write only the overlay. The Locales panel counts translatable strings per locale for every structured usage on the current route.
252
305
 
253
306
  ### `<CmsEntries />`
254
307
 
@@ -296,7 +349,7 @@ A component is treated as a CMS component if **any** of the following hold:
296
349
 
297
350
  2. **It's a named export from your local `src/lib/components/cms/index.{ts,js}` barrel.** Re-export your component there if you prefer a single import path..
298
351
 
299
- 3. **It's imported from the `@velastack/cms` package itself.** The four built-ins (`CmsText`, `CmsRichText`, `CmsImage`, `CmsRepeater`) work this way: any import resolving inside the installed `@velastack/cms` package is auto-classified.
352
+ 3. **It's imported from the `@velastack/cms` package itself.** Every named import from `@velastack/cms` is a CMS component, whether or not the bundler resolved the package.
300
353
 
301
354
  ### Third-party packs (opt-in)
302
355
 
@@ -332,13 +385,12 @@ String patterns match `source === pattern || source.startsWith(pattern + '/')`.
332
385
 
333
386
  A CMS component should:
334
387
 
335
- - Accept `{ name: string; fallback?: string; value?: unknown }` props (and any extras you need).
336
- - Call `getCmsScope()` from `cms` to find its scope.
337
- - Read drafts via `cmsStore.hasDraft(scopeKey, name)` / `cmsStore.getValue(scopeKey, name)` when `cmsStore.isEditing` is true; fall back to `page.data.cms.docs[scopeKey][name]` otherwise.
338
- - Skip scope lookup when `value !== undefined` — that prop is the per-item override used inside `<CmsRepeater/>`.
339
- - Optionally provide an editable sibling that's dynamically `import()`'d when `cmsStore.isEditing` flips on, so editing code doesn't ship to public visitors.
388
+ - Accept `{ name: string; scope?: string; fallback?: …; value?: unknown }` props (and any extras you need).
389
+ - Call `useCmsField(() => ({ name, scope, value }), read)` once at init. It resolves the scope (context, `'root'`, or an explicit id), reads the merged value, reports `editable`, and exposes `set` / `clear`.
390
+ - Treat `undefined` as "render the fallback" and `null` as "render empty" in `read`.
391
+ - Optionally provide an editable sibling that's dynamically `import()`'d when `field.editable` flips on, so editing code doesn't ship to public visitors.
340
392
 
341
- The four built-ins are reference implementations; copy `src/lib/components/cms/cms-text.svelte` (or one of the other built-ins with a paired `*-editable.svelte` sibling) as a starting point.
393
+ The built-ins are reference implementations; copy `src/lib/components/cms/cms-text.svelte` (or one of the others with a paired `*-editable.svelte` sibling) as a starting point.
342
394
 
343
395
  ## Architecture
344
396
 
@@ -399,7 +451,7 @@ Display components prefer drafts over published values when `isEditing` is true.
399
451
 
400
452
  ### Page metadata
401
453
 
402
- Page scopes carry an editable metadata field list (default: `title`, `description`, `canonical`, `robots`). Resolution order, per design:
454
+ Every page scope carries the full metadata set by default — `title`, `description`, `ogTitle`, `ogDescription`, `ogImage`, `twitterCard`, `canonical`, `noindex` — and a route's `page.cms.ts` `metadata` schema adds to or overrides it. The SEO panel previews the search snippet and the share card; blank share fields reuse the search fields. Resolution order, per design:
403
455
 
404
456
  ```
405
457
  editable page metadata (cms.metadata)
@@ -407,7 +459,19 @@ editable page metadata (cms.metadata)
407
459
  → static site defaults (defineBaseMetaTags)
408
460
  ```
409
461
 
410
- Hand `cms.metadata` directly to `definePageMetaTags(...)` from `svelte-meta-tags` and you're done.
462
+ `toMetaTags(cms.metadata, { siteName })` turns the branch into `svelte-meta-tags` props, with the title template and Open Graph site name from the root layout's `branding.name`:
463
+
464
+ ```svelte
465
+ <script lang="ts">
466
+ import { cms, toMetaTags } from '@velastack/cms';
467
+ const siteName = $derived(cms.docs['layout:/']?.branding?.name as string | undefined);
468
+ const metaTags = $derived(deepMerge(data.baseMetaTags, toMetaTags(cms.metadata, { siteName })));
469
+ </script>
470
+ ```
471
+
472
+ ### Site options
473
+
474
+ `createCms({ site })` declares the **Site Options** panel: project-wide values that never render on the page — the schema.org type (`enum`), the default share image (`image`), which locales are enabled (`boolean`). Field types are `text`, `markdown`, `number`, `datetime`, `url`, `color`, `image`, `enum` (with `values`) and `boolean`. Anything a visitor sees belongs in the root layout scope, edited in place with `scope="root"`; the site tree is one non-localised tree per project.
411
475
 
412
476
  ## Adapters
413
477
 
@@ -578,6 +642,24 @@ cms.locale; // 'es'
578
642
  cms.locales; // ['en', 'es', 'fr']
579
643
  ```
580
644
 
645
+ ## Seeding a project
646
+
647
+ A fresh project should open with content, not a blank site. The backend accepts a project's whole published state as one object and returns it in the same shape:
648
+
649
+ ```ts
650
+ type CmsSeed = {
651
+ layouts?: Record<locale, Record<routeId, Tree>>;
652
+ pages?: Record<locale, Record<routeId, Tree | PageEntry[]>>; // a bare tree is a static page
653
+ site?: Tree;
654
+ };
655
+ ```
656
+
657
+ - `POST /seed` with a `CmsSeed` (plus `force?: true`) writes the published rows directly, outside the release flow. It answers `409 { ok: false, reason: 'already-seeded' }` when the project already has published rows unless `force` is set, so it can never silently overwrite edits. Image values are seeded as URLs; nothing is uploaded.
658
+ - `GET /export` returns the published rows as a `CmsSeed` (tombstoned pages omitted).
659
+ - In-process: `backend.store.seed(projectId, seed, { force })` and `backend.store.exportPublished(projectId)`.
660
+
661
+ Both routes require an editor session. A host that creates projects (velastack.dev's `/setup`) reads the template's published `content/` and calls `store.seed` right after creating the project, in the locales the owner enabled.
662
+
581
663
  ## Redirects & tombstones
582
664
 
583
665
  Pages can be deleted with three different outcomes:
@@ -611,17 +693,24 @@ cms({
611
693
  endpoint: 'https://cms.example.com/v1/projects/p1/cms',
612
694
  locales: ['en'], // mirror createCms({ locales })
613
695
  mediaDir: 'static/cms-media', // download target (default)
614
- mediaPrefix: '/cms-media' // URL prefix in rewritten content (default)
696
+ mediaPrefix: '/cms-media', // URL prefix in rewritten content (default)
697
+
698
+ // Content manifest: `content/<locale>.json` keyed by scope id. When the
699
+ // default locale's file exists its values are injected as `fallback`
700
+ // props into every walked .svelte file, so components stay name-only.
701
+ content: 'content' // default; `false` disables
615
702
  });
616
703
  ```
617
704
 
705
+ Build tooling that needs the same parser — a `pack` step inlining fallbacks into a tarball, a `verify` step checking usages against a content manifest — imports it from `@velastack/cms/build`: `scanUsages(source)`, `injectFallbacks(source, treeOrResolver)` / `inlineFallbacks`, `buildManifest(...)` and `fallbackResolverFor(result, file, content)`. The manifest records each usage's component type (`scope.usages`), and the walk reports dynamic `name`s and out-of-chain `scope=` attributes as warnings instead of dropping them silently.
706
+
618
707
  The plugin also exposes `virtual:vela-cms/manifest` (typed via the package's ambient declaration). You almost never need to import it directly — `loadCms` does that internally — but it's there if you want to introspect the manifest at build time.
619
708
 
620
709
  ## Constraints (v1)
621
710
 
622
711
  Static analysis is conservative on purpose:
623
712
 
624
- - `name=` props must be string literals or `name={'literal'}`. Computed names are not extracted.
713
+ - `name=` props must be string literals or `name={'literal'}`. Computed names are not extracted; the plugin warns about each one.
625
714
  - Only static `import` of `.svelte` files is followed when walking the component graph. Bare specifiers (npm packages) are skipped unless they're the @velastack/cms package itself, are listed under `components`, or match a `traverse` pattern.
626
715
  - `<svelte:component this={…} />` and dynamic component selection are not traced.
627
716
  - Layout reset segments (`+page@layout.svelte`) are not yet supported.
@@ -49,6 +49,6 @@ export type { CmsStore } from './store/queries.js';
49
49
  export type { SqliteDb, SqliteStatement } from './store/sqlite.js';
50
50
  export type { Migration } from './store/migrations/index.js';
51
51
  export { MIGRATIONS } from './store/migrations/index.js';
52
- export type { DocsResponse, MediaItem, OpenRelease, PageMapEntry, PageMapRoute, PagesResponse, PagesRoute, PublishedRelease, ReleaseItem, ReleasePublisher, SiteResponse } from '../core/wire.js';
52
+ export type { CmsSeed, DocsResponse, MediaItem, OpenRelease, PageMapEntry, PageMapRoute, PagesResponse, PagesRoute, PublishedRelease, ReleaseItem, ReleasePublisher, SeedRequest, SeedResponse, SeedSummary, SiteResponse } from '../core/wire.js';
53
53
  export type { PageDeleteOutcome, PageEntry } from '../core/page-entry.js';
54
54
  export type { Tree } from '../core/path.js';
@@ -0,0 +1,6 @@
1
+ import type { CmsSeed } from '../../core/wire.js';
2
+ import { type RouteCtx } from './context.js';
3
+ /** Validate a request body into a {@link CmsSeed}, or explain why not. */
4
+ export declare const parseSeed: (body: unknown) => CmsSeed | string;
5
+ export declare const postSeed: (ctx: RouteCtx) => Promise<Response>;
6
+ export declare const getExport: (ctx: RouteCtx) => Response;
@@ -0,0 +1,96 @@
1
+ import { badRequest, isObjectRecord, isStringRecord, readJson } from './context.js';
2
+ const parseEntries = (raw) => {
3
+ const out = [];
4
+ for (const e of raw) {
5
+ if (!isObjectRecord(e))
6
+ return 'page entries must be objects';
7
+ if (!isStringRecord(e.params))
8
+ return 'page entry params must be a string-keyed object';
9
+ if (!isObjectRecord(e.published))
10
+ return 'page entry published must be an object';
11
+ const entry = { params: e.params, published: e.published };
12
+ const t = e.tombstone;
13
+ if (isObjectRecord(t)) {
14
+ if (t.kind === 'gone')
15
+ entry.tombstone = { kind: 'gone' };
16
+ else if (t.kind === 'redirect' && typeof t.to === 'string') {
17
+ entry.tombstone = { kind: 'redirect', to: t.to };
18
+ }
19
+ else
20
+ return 'page entry tombstone must be gone or redirect';
21
+ }
22
+ out.push(entry);
23
+ }
24
+ return out;
25
+ };
26
+ /** Validate a request body into a {@link CmsSeed}, or explain why not. */
27
+ export const parseSeed = (body) => {
28
+ if (!isObjectRecord(body))
29
+ return 'object body required';
30
+ const seed = {};
31
+ if (body.layouts !== undefined) {
32
+ if (!isObjectRecord(body.layouts))
33
+ return 'layouts must be keyed by locale';
34
+ seed.layouts = {};
35
+ for (const [locale, byRoute] of Object.entries(body.layouts)) {
36
+ if (!isObjectRecord(byRoute))
37
+ return `layouts.${locale} must be keyed by route id`;
38
+ const bucket = {};
39
+ for (const [routeId, tree] of Object.entries(byRoute)) {
40
+ if (!routeId.startsWith('/'))
41
+ return `layouts.${locale}: "${routeId}" is not a route id`;
42
+ if (!isObjectRecord(tree))
43
+ return `layouts.${locale}.${routeId} must be an object`;
44
+ bucket[routeId] = tree;
45
+ }
46
+ seed.layouts[locale] = bucket;
47
+ }
48
+ }
49
+ if (body.pages !== undefined) {
50
+ if (!isObjectRecord(body.pages))
51
+ return 'pages must be keyed by locale';
52
+ seed.pages = {};
53
+ for (const [locale, byRoute] of Object.entries(body.pages)) {
54
+ if (!isObjectRecord(byRoute))
55
+ return `pages.${locale} must be keyed by route id`;
56
+ const bucket = {};
57
+ for (const [routeId, value] of Object.entries(byRoute)) {
58
+ if (!routeId.startsWith('/'))
59
+ return `pages.${locale}: "${routeId}" is not a route id`;
60
+ if (Array.isArray(value)) {
61
+ const entries = parseEntries(value);
62
+ if (typeof entries === 'string')
63
+ return `pages.${locale}.${routeId}: ${entries}`;
64
+ bucket[routeId] = entries;
65
+ }
66
+ else if (isObjectRecord(value)) {
67
+ bucket[routeId] = value;
68
+ }
69
+ else
70
+ return `pages.${locale}.${routeId} must be an object or an entry list`;
71
+ }
72
+ seed.pages[locale] = bucket;
73
+ }
74
+ }
75
+ if (body.site !== undefined) {
76
+ if (!isObjectRecord(body.site))
77
+ return 'site must be an object';
78
+ seed.site = body.site;
79
+ }
80
+ return seed;
81
+ };
82
+ export const postSeed = async (ctx) => {
83
+ const body = await readJson(ctx.event);
84
+ if (body === undefined)
85
+ return badRequest('invalid json');
86
+ const seed = parseSeed(body);
87
+ if (typeof seed === 'string')
88
+ return badRequest(seed);
89
+ const force = isObjectRecord(body) && body.force === true;
90
+ const result = ctx.store.seed(ctx.projectId, seed, { force });
91
+ if (!result.ok)
92
+ return Response.json(result, { status: 409 });
93
+ ctx.cache.clear();
94
+ return Response.json(result);
95
+ };
96
+ export const getExport = (ctx) => Response.json(ctx.store.exportPublished(ctx.projectId));
@@ -6,6 +6,7 @@ import { getLogin, getLoginSuccess, postLogin } from './login.js';
6
6
  import { deleteReleaseItems, getRelease, getReleaseHistory, postReleaseDiscard, postReleaseHistoryPreviewKey, postReleaseItems, postReleasePreviewKey, postReleasePublish, postReleaseRevert } from './release.js';
7
7
  import { deleteMediaItem, getMedia, getUpload, postMedia } from './media.js';
8
8
  import { getDeploy, postDeploy } from './deploy.js';
9
+ import { getExport, postSeed } from './seed.js';
9
10
  import { postTestReset } from './test-reset.js';
10
11
  export const ROUTES = [
11
12
  { method: 'GET', segments: ['docs'], auth: 'public', handler: getDocs },
@@ -65,6 +66,8 @@ export const ROUTES = [
65
66
  },
66
67
  { method: 'GET', segments: ['deploy'], auth: 'required', handler: getDeploy },
67
68
  { method: 'POST', segments: ['deploy'], auth: 'required', handler: postDeploy },
69
+ { method: 'POST', segments: ['seed'], auth: 'required', handler: postSeed },
70
+ { method: 'GET', segments: ['export'], auth: 'required', handler: getExport },
68
71
  { method: 'GET', segments: ['media'], auth: 'required', handler: getMedia },
69
72
  { method: 'POST', segments: ['media'], auth: 'required', handler: postMedia },
70
73
  { method: 'DELETE', segments: ['media', ':id'], auth: 'required', handler: deleteMediaItem },
@@ -1,6 +1,6 @@
1
1
  import { type PageEntry, type PageDeleteOutcome } from '../../core/page-entry.js';
2
2
  import { type Tree } from '../../core/path.js';
3
- import type { AddReleaseItemInput, CreatePageResult, DiscardItemTarget, ListMediaOptions, ListMediaResult, MediaItem, OpenRelease, PageMapRoute, PublishedRelease, ReleaseItem, RenamePageResult, StagePageDeleteResult } from '../../core/wire.js';
3
+ import type { AddReleaseItemInput, CmsSeed, CreatePageResult, DiscardItemTarget, ListMediaOptions, ListMediaResult, MediaItem, OpenRelease, PageMapRoute, PublishedRelease, ReleaseItem, RenamePageResult, SeedResponse, StagePageDeleteResult } from '../../core/wire.js';
4
4
  import type { SqliteDb } from './sqlite.js';
5
5
  import type { CmsEditor } from '../types.js';
6
6
  /** The locale a request falls back to when it names none. */
@@ -33,6 +33,7 @@ export declare const createStore: (db: SqliteDb) => {
33
33
  createPage: (projectId: string, userId: string, routeId: string, locale: string, params: Record<string, string>, metadata?: Record<string, unknown>) => CreatePageResult;
34
34
  deleteMediaItem: (projectId: string, id: string) => MediaItem | null;
35
35
  discardOpenRelease: (projectId: string, userId: string) => boolean;
36
+ exportPublished: (projectId: string) => CmsSeed;
36
37
  discardReleaseItem: (projectId: string, userId: string, target: DiscardItemTarget) => boolean;
37
38
  findMediaItem: (projectId: string, id: string) => MediaItem | null;
38
39
  findOpenReleaseByPreviewKey: (projectId: string, key: string) => OpenRelease | null;
@@ -44,6 +45,7 @@ export declare const createStore: (db: SqliteDb) => {
44
45
  getPublishedPageEntry: (projectId: string, locale: string, routeId: string, params: Record<string, string>) => PageEntry | null;
45
46
  getPublishedSiteTree: (projectId: string) => Tree;
46
47
  getReleaseHistory: (projectId: string) => PublishedRelease[];
48
+ hasPublishedContent: (projectId: string) => boolean;
47
49
  listAllPages: (projectId: string, userId: string, locale: string) => PageMapRoute[];
48
50
  listMediaItems: (projectId: string, { offset, limit }?: ListMediaOptions) => ListMediaResult;
49
51
  listPageParamValues: (projectId: string, routeId: string, param: string) => string[];
@@ -62,6 +64,9 @@ export declare const createStore: (db: SqliteDb) => {
62
64
  toUrl: string;
63
65
  }) => RenamePageResult;
64
66
  revertRelease: (projectId: string, releaseId: string, user: CmsEditor) => PublishedRelease | null;
67
+ seed: (projectId: string, input: CmsSeed, opts?: {
68
+ force?: boolean;
69
+ }) => SeedResponse;
65
70
  seedPublishedDocs: (projectId: string, layouts: Record<string, Record<string, Tree>>, pages: Record<string, Record<string, PageEntry[]>>) => void;
66
71
  stagePageDelete: (projectId: string, userId: string, routeId: string, locale: string, params: Record<string, string>, outcome?: PageDeleteOutcome) => StagePageDeleteResult;
67
72
  };
@@ -1177,6 +1177,92 @@ export const createStore = (db) => {
1177
1177
  });
1178
1178
  tx();
1179
1179
  };
1180
+ // ---------------------------------------------------------------------------
1181
+ // Seed / export
1182
+ // ---------------------------------------------------------------------------
1183
+ const countPublishedStmt = () => db.prepare(`SELECT (SELECT COUNT(*) FROM published_pages WHERE project_id = ?)
1184
+ + (SELECT COUNT(*) FROM published_layouts WHERE project_id = ?)
1185
+ + (SELECT COUNT(*) FROM published_site WHERE project_id = ?) AS n`);
1186
+ /** Whether the project holds any published page, layout or site row. */
1187
+ const hasPublishedContent = (projectId) => (countPublishedStmt().get(projectId, projectId, projectId)?.n ?? 0) > 0;
1188
+ /**
1189
+ * Seed a project's published content from a {@link CmsSeed} — a template's
1190
+ * `content/` at project creation, or an export of another project. Writes
1191
+ * the published rows directly, outside the release flow: there is no editor
1192
+ * and nothing to revert to. Refuses when the project already has published
1193
+ * rows unless `force`, so a second run cannot silently overwrite edits.
1194
+ * Rows a forced seed does not name are left alone. Bumps `cms_version` so
1195
+ * cached public reads are invalidated.
1196
+ */
1197
+ const seed = (projectId, input, opts = {}) => {
1198
+ const tx = db.transaction(() => {
1199
+ if (!opts.force && hasPublishedContent(projectId)) {
1200
+ return { ok: false, reason: 'already-seeded' };
1201
+ }
1202
+ ensureProjectState(projectId);
1203
+ const seedReleaseId = `seed-${projectId}`;
1204
+ const at = nowIso();
1205
+ const locales = new Set();
1206
+ let layouts = 0;
1207
+ let pages = 0;
1208
+ for (const [locale, byRoute] of Object.entries(input.layouts ?? {})) {
1209
+ for (const [routeId, tree] of Object.entries(byRoute)) {
1210
+ locales.add(locale);
1211
+ layouts += 1;
1212
+ upsertPublishedLayoutStmt().run(projectId, locale, routeId, JSON.stringify(tree), at, seedReleaseId);
1213
+ }
1214
+ }
1215
+ for (const [locale, byRoute] of Object.entries(input.pages ?? {})) {
1216
+ for (const [routeId, value] of Object.entries(byRoute)) {
1217
+ const entries = Array.isArray(value)
1218
+ ? value
1219
+ : [{ params: {}, published: value }];
1220
+ for (const entry of entries) {
1221
+ locales.add(locale);
1222
+ pages += 1;
1223
+ upsertPublishedPageStmt().run(projectId, locale, routeId, paramsHash(entry.params), JSON.stringify(entry.params), JSON.stringify(entry.published), entry.tombstone ? JSON.stringify(entry.tombstone) : null, at, seedReleaseId);
1224
+ }
1225
+ }
1226
+ }
1227
+ const site = !!input.site;
1228
+ if (input.site) {
1229
+ upsertPublishedSiteStmt().run(projectId, JSON.stringify(input.site), at, seedReleaseId);
1230
+ }
1231
+ bumpProjectVersionStmt().run(projectId);
1232
+ return { ok: true, seeded: { locales: [...locales].sort(), layouts, pages, site } };
1233
+ });
1234
+ return tx();
1235
+ };
1236
+ /**
1237
+ * Every published row, in the shape {@link seed} accepts. Tombstoned pages
1238
+ * are omitted: a seed describes content, not deletions.
1239
+ */
1240
+ const exportPublished = (projectId) => {
1241
+ const out = { layouts: {}, pages: {} };
1242
+ for (const { locale } of allLocalesForProjectStmt().all(projectId, projectId)) {
1243
+ const layouts = {};
1244
+ for (const r of allLayoutsByLocaleStmt().all(projectId, locale)) {
1245
+ layouts[r.route_id] = parseTree(r.tree) ?? {};
1246
+ }
1247
+ if (Object.keys(layouts).length > 0)
1248
+ out.layouts[locale] = layouts;
1249
+ const pages = {};
1250
+ for (const r of allPagesByLocaleStmt().all(projectId, locale)) {
1251
+ if (parseOutcome(r.tombstone))
1252
+ continue;
1253
+ (pages[r.route_id] ??= []).push({
1254
+ params: parseParams(r.params),
1255
+ published: parseTree(r.tree) ?? {}
1256
+ });
1257
+ }
1258
+ if (Object.keys(pages).length > 0)
1259
+ out.pages[locale] = pages;
1260
+ }
1261
+ const site = getPublishedSiteTree(projectId);
1262
+ if (Object.keys(site).length > 0)
1263
+ out.site = site;
1264
+ return out;
1265
+ };
1180
1266
  return {
1181
1267
  __resetProjectForTests,
1182
1268
  addReleaseItems,
@@ -1186,6 +1272,7 @@ export const createStore = (db) => {
1186
1272
  createPage,
1187
1273
  deleteMediaItem,
1188
1274
  discardOpenRelease,
1275
+ exportPublished,
1189
1276
  discardReleaseItem,
1190
1277
  findMediaItem,
1191
1278
  findOpenReleaseByPreviewKey,
@@ -1197,6 +1284,7 @@ export const createStore = (db) => {
1197
1284
  getPublishedPageEntry,
1198
1285
  getPublishedSiteTree,
1199
1286
  getReleaseHistory,
1287
+ hasPublishedContent,
1200
1288
  listAllPages,
1201
1289
  listMediaItems,
1202
1290
  listPageParamValues,
@@ -1206,6 +1294,7 @@ export const createStore = (db) => {
1206
1294
  regeneratePublishedPreviewKey,
1207
1295
  renamePage,
1208
1296
  revertRelease,
1297
+ seed,
1209
1298
  seedPublishedDocs,
1210
1299
  stagePageDelete
1211
1300
  };
package/dist/cms.js CHANGED
@@ -3,27 +3,24 @@ import { locales } from '../locales/data.js';
3
3
  export const { load: loadCms, generateEntries } = createCms({
4
4
  adapter: apiAdapter({ endpoint: 'https://velastack.dev/v1/projects/velastack-cms/cms' }),
5
5
  locales,
6
+ // Site options: values that never render on the page. Branding, contact
7
+ // and social links live in the root layout scope and are edited in place.
6
8
  site: {
7
- branding: {
8
- label: 'Branding',
9
+ seo: {
10
+ label: 'Search & sharing',
9
11
  fields: {
10
- name: { type: 'text', label: 'Business name' },
11
- tagline: { type: 'text', label: 'Tagline' },
12
- logo: { type: 'image', label: 'Logo' }
12
+ schemaType: {
13
+ type: 'enum',
14
+ label: 'Business type (schema.org)',
15
+ values: ['LocalBusiness', 'Restaurant', 'Hotel', 'Store', 'ProfessionalService']
16
+ },
17
+ shareImage: { type: 'image', label: 'Default share image' }
13
18
  }
14
19
  },
15
- contact: {
16
- label: 'Contact',
20
+ locales: {
21
+ label: 'Languages',
17
22
  fields: {
18
- email: { type: 'text', label: 'Email', placeholder: 'hello@example.com' },
19
- phone: { type: 'text', label: 'Phone' }
20
- }
21
- },
22
- social: {
23
- label: 'Social',
24
- fields: {
25
- twitter: { type: 'url', label: 'Twitter / X' },
26
- instagram: { type: 'url', label: 'Instagram' }
23
+ es: { type: 'boolean', label: 'Spanish (es) enabled' }
27
24
  }
28
25
  }
29
26
  }
@@ -1041,7 +1041,7 @@
1041
1041
  siteSettingsOpen = true;
1042
1042
  };
1043
1043
 
1044
- // Mirrors `onSaveSeo`: flush the site-settings draft as part of the open
1044
+ // Mirrors `onSaveSeo`: flush the site-options draft as part of the open
1045
1045
  // release, refresh overlays so the just-saved values show via the
1046
1046
  // preview-overlay path on the live page.
1047
1047
  const onSaveSiteSettings = async () => {
@@ -1440,7 +1440,7 @@
1440
1440
 
1441
1441
  <Menubar.Label class={menuLabelClass}>Settings</Menubar.Label>
1442
1442
  <Menubar.Item class={menuItemClass} onSelect={onOpenSiteSettings}>
1443
- Site Settings
1443
+ Site Options
1444
1444
  <KbdShortcut keys="⌘," class={menuShortcutClass} />
1445
1445
  </Menubar.Item>
1446
1446
  </Menubar.Content>
@@ -1662,8 +1662,8 @@
1662
1662
  {/if}
1663
1663
 
1664
1664
  {#if siteSettingsOpen}
1665
- {#await import('./site-settings-panel.svelte') then { default: SiteSettingsPanel }}
1666
- <SiteSettingsPanel onClose={() => (siteSettingsOpen = false)} onSave={onSaveSiteSettings} />
1665
+ {#await import('./site-options-panel.svelte') then { default: SiteOptionsPanel }}
1666
+ <SiteOptionsPanel onClose={() => (siteSettingsOpen = false)} onSave={onSaveSiteSettings} />
1667
1667
  {/await}
1668
1668
  {/if}
1669
1669