@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.
- package/README.md +107 -18
- package/dist/backend/index.d.ts +1 -1
- package/dist/backend/routes/seed.d.ts +6 -0
- package/dist/backend/routes/seed.js +96 -0
- package/dist/backend/routes/table.js +3 -0
- package/dist/backend/store/queries.d.ts +6 -1
- package/dist/backend/store/queries.js +89 -0
- package/dist/cms.js +13 -16
- package/dist/components/admin-bar/admin-bar-internal.svelte +4 -4
- package/dist/components/admin-bar/admin-bar.css +1 -1
- package/dist/components/admin-bar/field-input.svelte +220 -0
- package/dist/components/admin-bar/field-input.svelte.d.ts +29 -0
- package/dist/components/admin-bar/icons/pencil.svelte +19 -0
- package/dist/components/admin-bar/icons/pencil.svelte.d.ts +3 -0
- package/dist/components/admin-bar/keyboard-shortcuts-dialog.svelte +1 -1
- package/dist/components/admin-bar/locales-panel.svelte +42 -1
- package/dist/components/admin-bar/page-config.d.ts +8 -1
- package/dist/components/admin-bar/page-config.js +15 -2
- package/dist/components/admin-bar/reorder-buttons.svelte +52 -0
- package/dist/components/admin-bar/reorder-buttons.svelte.d.ts +10 -0
- package/dist/components/admin-bar/seo-panel.svelte +144 -189
- package/dist/components/admin-bar/site-options-panel.svelte +115 -0
- package/dist/components/admin-bar/site-options-panel.svelte.d.ts +7 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte +250 -0
- package/dist/components/admin-bar/structured-editor-popover.svelte.d.ts +48 -0
- package/dist/components/admin-bar/ui/checkbox/checkbox.svelte +36 -0
- package/dist/components/admin-bar/ui/checkbox/checkbox.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/checkbox/index.d.ts +2 -0
- package/dist/components/admin-bar/ui/checkbox/index.js +2 -0
- package/dist/components/admin-bar/ui/popover/index.d.ts +5 -0
- package/dist/components/admin-bar/ui/popover/index.js +7 -0
- package/dist/components/admin-bar/ui/popover/popover-close.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover-close.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/popover/popover-content.svelte +35 -0
- package/dist/components/admin-bar/ui/popover/popover-content.svelte.d.ts +10 -0
- package/dist/components/admin-bar/ui/popover/popover-trigger.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/popover/popover.svelte +7 -0
- package/dist/components/admin-bar/ui/popover/popover.svelte.d.ts +3 -0
- package/dist/components/admin-bar/ui/sheet/index.d.ts +10 -0
- package/dist/components/admin-bar/ui/sheet/index.js +12 -0
- package/dist/components/admin-bar/ui/sheet/sheet-close.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet-close.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-content.svelte +64 -0
- package/dist/components/admin-bar/ui/sheet/sheet-content.svelte.d.ts +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-description.svelte +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-description.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte +23 -0
- package/dist/components/admin-bar/ui/sheet/sheet-footer.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/sheet/sheet-header.svelte +20 -0
- package/dist/components/admin-bar/ui/sheet/sheet-header.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte +20 -0
- package/dist/components/admin-bar/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-title.svelte +17 -0
- package/dist/components/admin-bar/ui/sheet/sheet-title.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/sheet/sheet.svelte +7 -0
- package/dist/components/admin-bar/ui/sheet/sheet.svelte.d.ts +3 -0
- package/dist/components/admin-bar/ui/switch/index.d.ts +2 -0
- package/dist/components/admin-bar/ui/switch/index.js +2 -0
- package/dist/components/admin-bar/ui/switch/switch.svelte +27 -0
- package/dist/components/admin-bar/ui/switch/switch.svelte.d.ts +5 -0
- package/dist/components/admin-bar/ui/tabs/index.d.ts +5 -0
- package/dist/components/admin-bar/ui/tabs/index.js +7 -0
- package/dist/components/admin-bar/ui/tabs/tabs-content.svelte +17 -0
- package/dist/components/admin-bar/ui/tabs/tabs-content.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs-list.svelte +16 -0
- package/dist/components/admin-bar/ui/tabs/tabs-list.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte +20 -0
- package/dist/components/admin-bar/ui/tabs/tabs-trigger.svelte.d.ts +4 -0
- package/dist/components/admin-bar/ui/tabs/tabs.svelte +19 -0
- package/dist/components/admin-bar/ui/tabs/tabs.svelte.d.ts +4 -0
- package/dist/components/cms/cms-boolean.svelte +9 -18
- package/dist/components/cms/cms-boolean.svelte.d.ts +1 -0
- package/dist/components/cms/cms-date-time-editable.svelte +3 -3
- package/dist/components/cms/cms-date-time.svelte +17 -17
- package/dist/components/cms/cms-date-time.svelte.d.ts +2 -1
- package/dist/components/cms/cms-image-editable.svelte +13 -4
- package/dist/components/cms/cms-image.svelte +37 -30
- package/dist/components/cms/cms-image.svelte.d.ts +11 -4
- package/dist/components/cms/cms-link.svelte +26 -19
- package/dist/components/cms/cms-link.svelte.d.ts +2 -0
- package/dist/components/cms/cms-markdown.svelte +15 -16
- package/dist/components/cms/cms-markdown.svelte.d.ts +1 -0
- package/dist/components/cms/cms-number-editable.svelte +4 -2
- package/dist/components/cms/cms-number.svelte +18 -17
- package/dist/components/cms/cms-number.svelte.d.ts +2 -1
- package/dist/components/cms/cms-repeater.svelte +15 -18
- package/dist/components/cms/cms-repeater.svelte.d.ts +1 -0
- package/dist/components/cms/cms-rich-text.svelte +17 -22
- package/dist/components/cms/cms-rich-text.svelte.d.ts +1 -0
- package/dist/components/cms/cms-store.svelte.d.ts +14 -0
- package/dist/components/cms/cms-store.svelte.js +39 -2
- package/dist/components/cms/cms-text.svelte +23 -30
- package/dist/components/cms/cms-text.svelte.d.ts +3 -1
- package/dist/components/cms/scope.d.ts +25 -5
- package/dist/components/cms/structured-registry.d.ts +10 -0
- package/dist/components/cms/structured-registry.js +6 -0
- package/dist/components/cms/use-cms-field.svelte.d.ts +54 -0
- package/dist/components/cms/use-cms-field.svelte.js +83 -0
- package/dist/core/metadata.d.ts +44 -0
- package/dist/core/metadata.js +59 -0
- package/dist/core/structured.d.ts +108 -0
- package/dist/core/structured.js +184 -0
- package/dist/core/wire.d.ts +31 -1
- package/dist/editor.d.ts +22 -0
- package/dist/editor.js +21 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.js +13 -3
- package/dist/server/load-cms.js +1 -0
- package/package.json +12 -3
- package/plugin/README.md +22 -2
- package/plugin/dist/build.d.ts +74 -0
- package/plugin/dist/build.js +210 -0
- package/plugin/dist/build.js.map +1 -0
- package/plugin/dist/index.d.ts +10 -1
- package/plugin/dist/index.js +63 -4
- package/plugin/dist/index.js.map +1 -1
- package/plugin/dist/manifest.d.ts +31 -0
- package/plugin/dist/manifest.js +146 -41
- package/plugin/dist/manifest.js.map +1 -1
- package/plugin/dist/parse-svelte.d.ts +15 -0
- package/plugin/dist/parse-svelte.js +26 -16
- package/plugin/dist/parse-svelte.js.map +1 -1
- package/dist/components/admin-bar/site-settings-panel.svelte +0 -225
- 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
|
-
|
|
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
|
-
| `
|
|
210
|
-
| `
|
|
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.**
|
|
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;
|
|
336
|
-
- Call `
|
|
337
|
-
-
|
|
338
|
-
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/dist/backend/index.d.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
label: '
|
|
9
|
+
seo: {
|
|
10
|
+
label: 'Search & sharing',
|
|
9
11
|
fields: {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
label: '
|
|
20
|
+
locales: {
|
|
21
|
+
label: 'Languages',
|
|
17
22
|
fields: {
|
|
18
|
-
|
|
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-
|
|
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
|
|
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-
|
|
1666
|
-
<
|
|
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
|
|