@velastack/cms 0.3.1 → 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 (132) hide show
  1. package/README.md +107 -18
  2. package/dist/backend/index.d.ts +1 -1
  3. package/dist/backend/routes/release.js +2 -2
  4. package/dist/backend/routes/seed.d.ts +6 -0
  5. package/dist/backend/routes/seed.js +96 -0
  6. package/dist/backend/routes/table.js +3 -0
  7. package/dist/backend/store/migrations/0004-release-publisher.d.ts +1 -0
  8. package/dist/backend/store/migrations/0004-release-publisher.js +15 -0
  9. package/dist/backend/store/migrations/index.js +3 -1
  10. package/dist/backend/store/queries.d.ts +9 -3
  11. package/dist/backend/store/queries.js +105 -11
  12. package/dist/cms.js +13 -16
  13. package/dist/components/admin-bar/admin-bar-internal.svelte +4 -4
  14. package/dist/components/admin-bar/admin-bar.css +1 -1
  15. package/dist/components/admin-bar/field-input.svelte +220 -0
  16. package/dist/components/admin-bar/field-input.svelte.d.ts +29 -0
  17. package/dist/components/admin-bar/history-panel.svelte +4 -5
  18. package/dist/components/admin-bar/icons/pencil.svelte +19 -0
  19. package/dist/components/admin-bar/icons/pencil.svelte.d.ts +3 -0
  20. package/dist/components/admin-bar/keyboard-shortcuts-dialog.svelte +1 -1
  21. package/dist/components/admin-bar/locales-panel.svelte +42 -1
  22. package/dist/components/admin-bar/page-config.d.ts +8 -1
  23. package/dist/components/admin-bar/page-config.js +15 -2
  24. package/dist/components/admin-bar/reorder-buttons.svelte +52 -0
  25. package/dist/components/admin-bar/reorder-buttons.svelte.d.ts +10 -0
  26. package/dist/components/admin-bar/seo-panel.svelte +144 -189
  27. package/dist/components/admin-bar/site-options-panel.svelte +115 -0
  28. package/dist/components/admin-bar/site-options-panel.svelte.d.ts +7 -0
  29. package/dist/components/admin-bar/structured-editor-popover.svelte +250 -0
  30. package/dist/components/admin-bar/structured-editor-popover.svelte.d.ts +48 -0
  31. package/dist/components/admin-bar/ui/checkbox/checkbox.svelte +36 -0
  32. package/dist/components/admin-bar/ui/checkbox/checkbox.svelte.d.ts +5 -0
  33. package/dist/components/admin-bar/ui/checkbox/index.d.ts +2 -0
  34. package/dist/components/admin-bar/ui/checkbox/index.js +2 -0
  35. package/dist/components/admin-bar/ui/popover/index.d.ts +5 -0
  36. package/dist/components/admin-bar/ui/popover/index.js +7 -0
  37. package/dist/components/admin-bar/ui/popover/popover-close.svelte +7 -0
  38. package/dist/components/admin-bar/ui/popover/popover-close.svelte.d.ts +4 -0
  39. package/dist/components/admin-bar/ui/popover/popover-content.svelte +35 -0
  40. package/dist/components/admin-bar/ui/popover/popover-content.svelte.d.ts +10 -0
  41. package/dist/components/admin-bar/ui/popover/popover-trigger.svelte +7 -0
  42. package/dist/components/admin-bar/ui/popover/popover-trigger.svelte.d.ts +4 -0
  43. package/dist/components/admin-bar/ui/popover/popover.svelte +7 -0
  44. package/dist/components/admin-bar/ui/popover/popover.svelte.d.ts +3 -0
  45. package/dist/components/admin-bar/ui/sheet/index.d.ts +10 -0
  46. package/dist/components/admin-bar/ui/sheet/index.js +12 -0
  47. package/dist/components/admin-bar/ui/sheet/sheet-close.svelte +7 -0
  48. package/dist/components/admin-bar/ui/sheet/sheet-close.svelte.d.ts +4 -0
  49. package/dist/components/admin-bar/ui/sheet/sheet-content.svelte +64 -0
  50. package/dist/components/admin-bar/ui/sheet/sheet-content.svelte.d.ts +17 -0
  51. package/dist/components/admin-bar/ui/sheet/sheet-description.svelte +17 -0
  52. package/dist/components/admin-bar/ui/sheet/sheet-description.svelte.d.ts +4 -0
  53. package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte +23 -0
  54. package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte.d.ts +5 -0
  55. package/dist/components/admin-bar/ui/sheet/sheet-header.svelte +20 -0
  56. package/dist/components/admin-bar/ui/sheet/sheet-header.svelte.d.ts +5 -0
  57. package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte +20 -0
  58. package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
  59. package/dist/components/admin-bar/ui/sheet/sheet-title.svelte +17 -0
  60. package/dist/components/admin-bar/ui/sheet/sheet-title.svelte.d.ts +4 -0
  61. package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte +7 -0
  62. package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
  63. package/dist/components/admin-bar/ui/sheet/sheet.svelte +7 -0
  64. package/dist/components/admin-bar/ui/sheet/sheet.svelte.d.ts +3 -0
  65. package/dist/components/admin-bar/ui/switch/index.d.ts +2 -0
  66. package/dist/components/admin-bar/ui/switch/index.js +2 -0
  67. package/dist/components/admin-bar/ui/switch/switch.svelte +27 -0
  68. package/dist/components/admin-bar/ui/switch/switch.svelte.d.ts +5 -0
  69. package/dist/components/admin-bar/ui/tabs/index.d.ts +5 -0
  70. package/dist/components/admin-bar/ui/tabs/index.js +7 -0
  71. package/dist/components/admin-bar/ui/tabs/tabs-content.svelte +17 -0
  72. package/dist/components/admin-bar/ui/tabs/tabs-content.svelte.d.ts +4 -0
  73. package/dist/components/admin-bar/ui/tabs/tabs-list.svelte +16 -0
  74. package/dist/components/admin-bar/ui/tabs/tabs-list.svelte.d.ts +4 -0
  75. package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte +20 -0
  76. package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
  77. package/dist/components/admin-bar/ui/tabs/tabs.svelte +19 -0
  78. package/dist/components/admin-bar/ui/tabs/tabs.svelte.d.ts +4 -0
  79. package/dist/components/cms/cms-boolean.svelte +9 -18
  80. package/dist/components/cms/cms-boolean.svelte.d.ts +1 -0
  81. package/dist/components/cms/cms-date-time-editable.svelte +3 -3
  82. package/dist/components/cms/cms-date-time.svelte +17 -17
  83. package/dist/components/cms/cms-date-time.svelte.d.ts +2 -1
  84. package/dist/components/cms/cms-image-editable.svelte +13 -4
  85. package/dist/components/cms/cms-image.svelte +37 -30
  86. package/dist/components/cms/cms-image.svelte.d.ts +11 -4
  87. package/dist/components/cms/cms-link.svelte +26 -19
  88. package/dist/components/cms/cms-link.svelte.d.ts +2 -0
  89. package/dist/components/cms/cms-markdown.svelte +15 -16
  90. package/dist/components/cms/cms-markdown.svelte.d.ts +1 -0
  91. package/dist/components/cms/cms-number-editable.svelte +4 -2
  92. package/dist/components/cms/cms-number.svelte +18 -17
  93. package/dist/components/cms/cms-number.svelte.d.ts +2 -1
  94. package/dist/components/cms/cms-repeater.svelte +15 -18
  95. package/dist/components/cms/cms-repeater.svelte.d.ts +1 -0
  96. package/dist/components/cms/cms-rich-text.svelte +17 -22
  97. package/dist/components/cms/cms-rich-text.svelte.d.ts +1 -0
  98. package/dist/components/cms/cms-store.svelte.d.ts +14 -0
  99. package/dist/components/cms/cms-store.svelte.js +39 -2
  100. package/dist/components/cms/cms-text.svelte +23 -30
  101. package/dist/components/cms/cms-text.svelte.d.ts +3 -1
  102. package/dist/components/cms/scope.d.ts +25 -5
  103. package/dist/components/cms/structured-registry.d.ts +10 -0
  104. package/dist/components/cms/structured-registry.js +6 -0
  105. package/dist/components/cms/use-cms-field.svelte.d.ts +54 -0
  106. package/dist/components/cms/use-cms-field.svelte.js +83 -0
  107. package/dist/core/metadata.d.ts +44 -0
  108. package/dist/core/metadata.js +59 -0
  109. package/dist/core/structured.d.ts +108 -0
  110. package/dist/core/structured.js +184 -0
  111. package/dist/core/wire.d.ts +42 -2
  112. package/dist/editor.d.ts +22 -0
  113. package/dist/editor.js +21 -0
  114. package/dist/index.d.ts +12 -4
  115. package/dist/index.js +13 -3
  116. package/dist/server/load-cms.js +1 -0
  117. package/package.json +15 -6
  118. package/plugin/README.md +22 -2
  119. package/plugin/dist/build.d.ts +74 -0
  120. package/plugin/dist/build.js +210 -0
  121. package/plugin/dist/build.js.map +1 -0
  122. package/plugin/dist/index.d.ts +10 -1
  123. package/plugin/dist/index.js +63 -4
  124. package/plugin/dist/index.js.map +1 -1
  125. package/plugin/dist/manifest.d.ts +31 -0
  126. package/plugin/dist/manifest.js +146 -41
  127. package/plugin/dist/manifest.js.map +1 -1
  128. package/plugin/dist/parse-svelte.d.ts +15 -0
  129. package/plugin/dist/parse-svelte.js +26 -16
  130. package/plugin/dist/parse-svelte.js.map +1 -1
  131. package/dist/components/admin-bar/site-settings-panel.svelte +0 -225
  132. 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, 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';
@@ -101,7 +101,7 @@ export const postReleasePublish = async (ctx) => {
101
101
  name = body.name;
102
102
  }
103
103
  }
104
- const published = ctx.store.publishRelease(ctx.projectId, user.id, name);
104
+ const published = ctx.store.publishRelease(ctx.projectId, user, name);
105
105
  if (!published)
106
106
  return new Response('no open release to publish', { status: 404 });
107
107
  return Response.json({ release: published });
@@ -130,7 +130,7 @@ export const postReleaseRevert = (ctx) => {
130
130
  const id = ctx.params.id;
131
131
  if (!id)
132
132
  return badRequest('id required');
133
- const reverted = ctx.store.revertRelease(ctx.projectId, id, user.id);
133
+ const reverted = ctx.store.revertRelease(ctx.projectId, id, user);
134
134
  if (!reverted)
135
135
  return new Response('release not found', { status: 404 });
136
136
  return Response.json({ release: reverted });
@@ -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 },
@@ -0,0 +1 @@
1
+ export declare const sql = "\nALTER TABLE releases ADD COLUMN published_by_email TEXT;\nALTER TABLE releases ADD COLUMN published_by_name TEXT;\nUPDATE releases SET\n published_by_email = (SELECT e.email FROM cms_editors e WHERE e.id = releases.published_by),\n published_by_name = (SELECT e.name FROM cms_editors e WHERE e.id = releases.published_by);\n";
@@ -0,0 +1,15 @@
1
+ // Who published a release, as they were at the time.
2
+ //
3
+ // `published_by` is an opaque editor id, which is all the auth adapter
4
+ // promises to keep stable — and all the admin bar could show. The publisher's
5
+ // email and name are now captured on the row at publish time, so history reads
6
+ // the same under any adapter and survives the account being renamed or
7
+ // removed. Existing rows are filled from `cms_editors` where the id still
8
+ // resolves; the rest stay null and readers fall back to the id.
9
+ export const sql = `
10
+ ALTER TABLE releases ADD COLUMN published_by_email TEXT;
11
+ ALTER TABLE releases ADD COLUMN published_by_name TEXT;
12
+ UPDATE releases SET
13
+ published_by_email = (SELECT e.email FROM cms_editors e WHERE e.id = releases.published_by),
14
+ published_by_name = (SELECT e.name FROM cms_editors e WHERE e.id = releases.published_by);
15
+ `;
@@ -1,6 +1,7 @@
1
1
  import { sql as init } from './0001-init.js';
2
2
  import { sql as site } from './0002-site.js';
3
3
  import { sql as editors } from './0003-editors.js';
4
+ import { sql as releasePublisher } from './0004-release-publisher.js';
4
5
  /**
5
6
  * Every migration, in apply order.
6
7
  *
@@ -14,5 +15,6 @@ import { sql as editors } from './0003-editors.js';
14
15
  export const MIGRATIONS = [
15
16
  { name: '0001_init.sql', sql: init },
16
17
  { name: '0002_site.sql', sql: site },
17
- { name: '0003_editors.sql', sql: editors }
18
+ { name: '0003_editors.sql', sql: editors },
19
+ { name: '0004_release_publisher.sql', sql: releasePublisher }
18
20
  ];
@@ -1,7 +1,8 @@
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
+ import type { CmsEditor } from '../types.js';
5
6
  /** The locale a request falls back to when it names none. */
6
7
  export declare const DEFAULT_LOCALE = "en";
7
8
  /** An opaque, unguessable key for a release's preview URL. */
@@ -32,6 +33,7 @@ export declare const createStore: (db: SqliteDb) => {
32
33
  createPage: (projectId: string, userId: string, routeId: string, locale: string, params: Record<string, string>, metadata?: Record<string, unknown>) => CreatePageResult;
33
34
  deleteMediaItem: (projectId: string, id: string) => MediaItem | null;
34
35
  discardOpenRelease: (projectId: string, userId: string) => boolean;
36
+ exportPublished: (projectId: string) => CmsSeed;
35
37
  discardReleaseItem: (projectId: string, userId: string, target: DiscardItemTarget) => boolean;
36
38
  findMediaItem: (projectId: string, id: string) => MediaItem | null;
37
39
  findOpenReleaseByPreviewKey: (projectId: string, key: string) => OpenRelease | null;
@@ -43,6 +45,7 @@ export declare const createStore: (db: SqliteDb) => {
43
45
  getPublishedPageEntry: (projectId: string, locale: string, routeId: string, params: Record<string, string>) => PageEntry | null;
44
46
  getPublishedSiteTree: (projectId: string) => Tree;
45
47
  getReleaseHistory: (projectId: string) => PublishedRelease[];
48
+ hasPublishedContent: (projectId: string) => boolean;
46
49
  listAllPages: (projectId: string, userId: string, locale: string) => PageMapRoute[];
47
50
  listMediaItems: (projectId: string, { offset, limit }?: ListMediaOptions) => ListMediaResult;
48
51
  listPageParamValues: (projectId: string, routeId: string, param: string) => string[];
@@ -50,7 +53,7 @@ export declare const createStore: (db: SqliteDb) => {
50
53
  id: string;
51
54
  items: ReleaseItem[];
52
55
  } | null;
53
- publishRelease: (projectId: string, userId: string, name?: string) => PublishedRelease | null;
56
+ publishRelease: (projectId: string, user: CmsEditor, name?: string) => PublishedRelease | null;
54
57
  regeneratePreviewKey: (projectId: string, userId: string) => string | null;
55
58
  regeneratePublishedPreviewKey: (projectId: string, releaseId: string) => string | null;
56
59
  renamePage: (projectId: string, userId: string, args: {
@@ -60,7 +63,10 @@ export declare const createStore: (db: SqliteDb) => {
60
63
  toParams: Record<string, string>;
61
64
  toUrl: string;
62
65
  }) => RenamePageResult;
63
- revertRelease: (projectId: string, releaseId: string, userId: string) => PublishedRelease | null;
66
+ revertRelease: (projectId: string, releaseId: string, user: CmsEditor) => PublishedRelease | null;
67
+ seed: (projectId: string, input: CmsSeed, opts?: {
68
+ force?: boolean;
69
+ }) => SeedResponse;
64
70
  seedPublishedDocs: (projectId: string, layouts: Record<string, Record<string, Tree>>, pages: Record<string, Record<string, PageEntry[]>>) => void;
65
71
  stagePageDelete: (projectId: string, userId: string, routeId: string, locale: string, params: Record<string, string>, outcome?: PageDeleteOutcome) => StagePageDeleteResult;
66
72
  };
@@ -517,7 +517,7 @@ export const createStore = (db) => {
517
517
  updated_by_release = excluded.updated_by_release`);
518
518
  const updatePublishedPageTombstoneStmt = () => db.prepare('UPDATE published_pages SET tombstone = ?, updated_at = ?, updated_by_release = ? WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
519
519
  const deletePublishedPageStmt = () => db.prepare('DELETE FROM published_pages WHERE project_id = ? AND locale = ? AND route_id = ? AND params_hash = ?');
520
- const insertReleaseStmt = () => db.prepare('INSERT INTO releases (id, project_id, seq, name, published_by, published_at, preview_key) VALUES (?, ?, ?, ?, ?, ?, ?)');
520
+ const insertReleaseStmt = () => db.prepare('INSERT INTO releases (id, project_id, seq, name, published_by, published_by_email, published_by_name, published_at, preview_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
521
521
  const insertReleaseItemStmt = () => db.prepare('INSERT INTO release_items (release_id, project_id, seq, kind, route_id, locale, params_hash, params, tree, prior_tree, outcome, prior_tombstone, added_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
522
522
  const deleteOpenItemsForUserStmt = () => db.prepare('DELETE FROM open_release_items WHERE project_id = ? AND user_id = ?');
523
523
  /**
@@ -527,7 +527,8 @@ export const createStore = (db) => {
527
527
  * open release. Returns the new `PublishedRelease`, or null if there's nothing
528
528
  * to publish.
529
529
  */
530
- const publishRelease = (projectId, userId, name) => {
530
+ const publishRelease = (projectId, user, name) => {
531
+ const userId = user.id;
531
532
  const tx = db.transaction(() => {
532
533
  ensureProjectState(projectId);
533
534
  const releaseRow = getOpenReleaseStmt().get(projectId, userId);
@@ -546,7 +547,7 @@ export const createStore = (db) => {
546
547
  // resolves on every per-item insert below. We delete the header at the
547
548
  // end if the body turned out to be empty.
548
549
  const finalName = name ?? releaseRow.name ?? null;
549
- insertReleaseStmt().run(releaseId, projectId, seq, finalName, userId, publishedAt, previewKey);
550
+ insertReleaseStmt().run(releaseId, projectId, seq, finalName, user.id, user.email, user.name, publishedAt, previewKey);
550
551
  for (const item of items) {
551
552
  if (item.kind === 'site') {
552
553
  const target = getPublishedSiteTree(projectId);
@@ -635,7 +636,7 @@ export const createStore = (db) => {
635
636
  deleteOpenReleaseStmt().run(projectId, userId);
636
637
  const result = {
637
638
  id: releaseId,
638
- publishedBy: userId,
639
+ publishedBy: { id: user.id, email: user.email, name: user.name },
639
640
  publishedAt,
640
641
  preview_key: previewKey,
641
642
  items: publishedItems
@@ -646,9 +647,9 @@ export const createStore = (db) => {
646
647
  });
647
648
  return tx();
648
649
  };
649
- const listReleaseHistoryStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at FROM releases WHERE project_id = ? ORDER BY seq ASC');
650
- const findReleaseByPreviewKeyStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND preview_key = ?');
651
- const getReleaseByIdStmt = () => db.prepare('SELECT id, name, published_by, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND id = ?');
650
+ const listReleaseHistoryStmt = () => db.prepare('SELECT id, name, published_by, published_by_email, published_by_name, published_at, preview_key, reverted_at FROM releases WHERE project_id = ? ORDER BY seq ASC');
651
+ const findReleaseByPreviewKeyStmt = () => db.prepare('SELECT id, name, published_by, published_by_email, published_by_name, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND preview_key = ?');
652
+ const getReleaseByIdStmt = () => db.prepare('SELECT id, name, published_by, published_by_email, published_by_name, published_at, preview_key, reverted_at, seq FROM releases WHERE project_id = ? AND id = ?');
652
653
  const listReleaseItemsByReleaseIdStmt = () => db.prepare('SELECT kind, route_id, locale, params, tree, prior_tree, outcome, prior_tombstone FROM release_items WHERE release_id = ? ORDER BY id');
653
654
  const listReleaseItemsAfterSeqStmt = () => db.prepare('SELECT seq, kind, route_id, locale, params, tree, prior_tree, outcome, prior_tombstone FROM release_items WHERE project_id = ? AND seq > ? ORDER BY seq DESC, id DESC');
654
655
  const updateReleasePreviewKeyStmt = () => db.prepare('UPDATE releases SET preview_key = ? WHERE id = ?');
@@ -702,7 +703,11 @@ export const createStore = (db) => {
702
703
  const items = listReleaseItemsByReleaseIdStmt().all(row.id).map(rowToPublishedItem);
703
704
  const r = {
704
705
  id: row.id,
705
- publishedBy: row.published_by,
706
+ publishedBy: {
707
+ id: row.published_by,
708
+ email: row.published_by_email,
709
+ name: row.published_by_name
710
+ },
706
711
  publishedAt: row.published_at,
707
712
  preview_key: row.preview_key,
708
713
  items
@@ -822,7 +827,7 @@ export const createStore = (db) => {
822
827
  // ---------------------------------------------------------------------------
823
828
  // Revert
824
829
  // ---------------------------------------------------------------------------
825
- const revertRelease = (projectId, releaseId, userId) => {
830
+ const revertRelease = (projectId, releaseId, user) => {
826
831
  const tx = db.transaction(() => {
827
832
  ensureProjectState(projectId);
828
833
  const sourceRow = getReleaseByIdStmt().get(projectId, releaseId);
@@ -839,7 +844,7 @@ export const createStore = (db) => {
839
844
  const inverseName = sourceRow.name
840
845
  ? `Revert "${sourceRow.name}"`
841
846
  : `Revert ${sourceRow.id.slice(0, 8)}`;
842
- insertReleaseStmt().run(inverseId, projectId, seq, inverseName, userId, publishedAt, previewKey);
847
+ insertReleaseStmt().run(inverseId, projectId, seq, inverseName, user.id, user.email, user.name, publishedAt, previewKey);
843
848
  for (const item of sourceItems) {
844
849
  if (item.kind === 'site') {
845
850
  const restoreTree = item.priorTree ?? {};
@@ -962,7 +967,7 @@ export const createStore = (db) => {
962
967
  return {
963
968
  id: inverseId,
964
969
  name: inverseName,
965
- publishedBy: userId,
970
+ publishedBy: { id: user.id, email: user.email, name: user.name },
966
971
  publishedAt,
967
972
  preview_key: previewKey,
968
973
  items: inverseItems
@@ -1172,6 +1177,92 @@ export const createStore = (db) => {
1172
1177
  });
1173
1178
  tx();
1174
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
+ };
1175
1266
  return {
1176
1267
  __resetProjectForTests,
1177
1268
  addReleaseItems,
@@ -1181,6 +1272,7 @@ export const createStore = (db) => {
1181
1272
  createPage,
1182
1273
  deleteMediaItem,
1183
1274
  discardOpenRelease,
1275
+ exportPublished,
1184
1276
  discardReleaseItem,
1185
1277
  findMediaItem,
1186
1278
  findOpenReleaseByPreviewKey,
@@ -1192,6 +1284,7 @@ export const createStore = (db) => {
1192
1284
  getPublishedPageEntry,
1193
1285
  getPublishedSiteTree,
1194
1286
  getReleaseHistory,
1287
+ hasPublishedContent,
1195
1288
  listAllPages,
1196
1289
  listMediaItems,
1197
1290
  listPageParamValues,
@@ -1201,6 +1294,7 @@ export const createStore = (db) => {
1201
1294
  regeneratePublishedPreviewKey,
1202
1295
  renamePage,
1203
1296
  revertRelease,
1297
+ seed,
1204
1298
  seedPublishedDocs,
1205
1299
  stagePageDelete
1206
1300
  };