@velastack/cms 0.5.0 → 0.6.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 (134) hide show
  1. package/README.md +104 -47
  2. package/dist/backend/index.d.ts +7 -5
  3. package/dist/backend/index.js +7 -5
  4. package/dist/backend/types.d.ts +1 -1
  5. package/dist/components/admin-bar/admin-bar-internal.svelte +54 -59
  6. package/dist/components/admin-bar/admin-bar.css +1 -1
  7. package/dist/components/admin-bar/admin-bar.svelte +11 -7
  8. package/dist/components/admin-bar/field-input.svelte +20 -1
  9. package/dist/components/admin-bar/field-input.svelte.d.ts +2 -1
  10. package/dist/components/admin-bar/fields-form.svelte +218 -0
  11. package/dist/components/admin-bar/fields-form.svelte.d.ts +16 -0
  12. package/dist/components/admin-bar/history-panel.svelte +4 -4
  13. package/dist/components/admin-bar/link-input.svelte +102 -0
  14. package/dist/components/admin-bar/link-input.svelte.d.ts +9 -0
  15. package/dist/components/admin-bar/list-field.svelte +93 -0
  16. package/dist/components/admin-bar/list-field.svelte.d.ts +20 -0
  17. package/dist/components/admin-bar/locales-panel.svelte +1 -1
  18. package/dist/components/admin-bar/page-options.d.ts +24 -0
  19. package/dist/components/admin-bar/page-options.js +53 -0
  20. package/dist/components/admin-bar/pages-panel.svelte +1 -1
  21. package/dist/components/admin-bar/rich-text-input.svelte +92 -0
  22. package/dist/components/admin-bar/rich-text-input.svelte.d.ts +9 -0
  23. package/dist/components/admin-bar/structured-editor-popover.svelte +15 -6
  24. package/dist/components/admin-bar/theme.svelte.js +1 -1
  25. package/dist/components/cms/cms-collection.svelte +46 -0
  26. package/dist/components/cms/cms-collection.svelte.d.ts +25 -0
  27. package/dist/components/cms/cms-contact.svelte +26 -0
  28. package/dist/components/cms/cms-contact.svelte.d.ts +14 -0
  29. package/dist/components/cms/cms-faq.svelte +22 -0
  30. package/dist/components/cms/cms-faq.svelte.d.ts +15 -0
  31. package/dist/components/cms/cms-gallery.svelte +31 -0
  32. package/dist/components/cms/cms-gallery.svelte.d.ts +16 -0
  33. package/dist/components/cms/cms-hours-editable.svelte +160 -0
  34. package/dist/components/cms/cms-hours-editable.svelte.d.ts +17 -0
  35. package/dist/components/cms/cms-hours.svelte +42 -0
  36. package/dist/components/cms/cms-hours.svelte.d.ts +19 -0
  37. package/dist/components/cms/cms-image.svelte +3 -25
  38. package/dist/components/cms/cms-image.svelte.d.ts +3 -11
  39. package/dist/components/cms/cms-link-editable.svelte +4 -60
  40. package/dist/components/cms/cms-link.svelte +3 -23
  41. package/dist/components/cms/cms-link.svelte.d.ts +3 -10
  42. package/dist/components/cms/cms-logos.svelte +29 -0
  43. package/dist/components/cms/cms-logos.svelte.d.ts +16 -0
  44. package/dist/components/cms/cms-nav.svelte +22 -0
  45. package/dist/components/cms/cms-nav.svelte.d.ts +15 -0
  46. package/dist/components/cms/cms-pricing.svelte +36 -0
  47. package/dist/components/cms/cms-pricing.svelte.d.ts +14 -0
  48. package/dist/components/cms/cms-schedule.svelte +35 -0
  49. package/dist/components/cms/cms-schedule.svelte.d.ts +16 -0
  50. package/dist/components/cms/cms-social-links.svelte +35 -0
  51. package/dist/components/cms/cms-social-links.svelte.d.ts +15 -0
  52. package/dist/components/cms/cms-stats.svelte +31 -0
  53. package/dist/components/cms/cms-stats.svelte.d.ts +16 -0
  54. package/dist/components/cms/cms-steps.svelte +31 -0
  55. package/dist/components/cms/cms-steps.svelte.d.ts +16 -0
  56. package/dist/components/cms/cms-store.svelte.js +8 -5
  57. package/dist/components/cms/cms-structured-editable.svelte +29 -0
  58. package/dist/components/cms/{cms-repeater-editable.svelte.d.ts → cms-structured-editable.svelte.d.ts} +12 -8
  59. package/dist/components/cms/cms-structured.svelte +50 -0
  60. package/dist/components/cms/cms-structured.svelte.d.ts +49 -0
  61. package/dist/components/cms/cms-team.svelte +22 -0
  62. package/dist/components/cms/cms-team.svelte.d.ts +15 -0
  63. package/dist/components/cms/cms-testimonials.svelte +36 -0
  64. package/dist/components/cms/cms-testimonials.svelte.d.ts +15 -0
  65. package/dist/components/cms/cms-timeline.svelte +35 -0
  66. package/dist/components/cms/cms-timeline.svelte.d.ts +16 -0
  67. package/dist/components/cms/index.d.ts +15 -1
  68. package/dist/components/cms/index.js +15 -1
  69. package/dist/core/cms-fetch.d.ts +16 -0
  70. package/dist/core/cms-fetch.js +24 -0
  71. package/dist/core/shapes/collection.d.ts +60 -0
  72. package/dist/core/shapes/collection.js +194 -0
  73. package/dist/core/shapes/contact.d.ts +70 -0
  74. package/dist/core/shapes/contact.js +214 -0
  75. package/dist/core/shapes/faq.d.ts +12 -0
  76. package/dist/core/shapes/faq.js +48 -0
  77. package/dist/core/shapes/form.d.ts +46 -0
  78. package/dist/core/shapes/form.js +7 -0
  79. package/dist/core/shapes/hours.d.ts +91 -0
  80. package/dist/core/shapes/hours.js +325 -0
  81. package/dist/core/shapes/image.d.ts +15 -0
  82. package/dist/core/shapes/image.js +27 -0
  83. package/dist/core/shapes/index.d.ts +31 -0
  84. package/dist/core/shapes/index.js +13 -0
  85. package/dist/core/shapes/link.d.ts +15 -0
  86. package/dist/core/shapes/link.js +42 -0
  87. package/dist/core/shapes/nav.d.ts +21 -0
  88. package/dist/core/shapes/nav.js +71 -0
  89. package/dist/core/shapes/presets.d.ts +65 -0
  90. package/dist/core/shapes/presets.js +152 -0
  91. package/dist/core/shapes/pricing.d.ts +40 -0
  92. package/dist/core/shapes/pricing.js +115 -0
  93. package/dist/core/shapes/social-links.d.ts +19 -0
  94. package/dist/core/shapes/social-links.js +83 -0
  95. package/dist/core/shapes/team.d.ts +22 -0
  96. package/dist/core/shapes/team.js +70 -0
  97. package/dist/core/shapes/testimonials.d.ts +24 -0
  98. package/dist/core/shapes/testimonials.js +86 -0
  99. package/dist/{components/cms → core}/structured-registry.d.ts +2 -2
  100. package/dist/core/structured.d.ts +33 -10
  101. package/dist/core/structured.js +98 -47
  102. package/dist/editor.d.ts +4 -0
  103. package/dist/editor.js +4 -0
  104. package/dist/index.d.ts +22 -8
  105. package/dist/index.js +24 -5
  106. package/dist/server/README.md +1 -1
  107. package/dist/server/api-adapter.js +1 -1
  108. package/dist/server/cms.d.ts +1 -1
  109. package/dist/server/cms.js +1 -1
  110. package/dist/server/load-cms.js +1 -1
  111. package/package.json +15 -11
  112. package/plugin/README.md +34 -4
  113. package/plugin/dist/adapter.d.ts +5 -5
  114. package/plugin/dist/adapter.js +21 -7
  115. package/plugin/dist/adapter.js.map +1 -1
  116. package/plugin/dist/build-state.d.ts +1 -0
  117. package/plugin/dist/build-state.js +10 -1
  118. package/plugin/dist/build-state.js.map +1 -1
  119. package/plugin/dist/build.d.ts +2 -2
  120. package/plugin/dist/build.js +8 -7
  121. package/plugin/dist/build.js.map +1 -1
  122. package/plugin/dist/index.js +5 -1
  123. package/plugin/dist/index.js.map +1 -1
  124. package/plugin/dist/manifest.d.ts +18 -0
  125. package/plugin/dist/manifest.js +78 -10
  126. package/plugin/dist/manifest.js.map +1 -1
  127. package/plugin/dist/transforms.d.ts +4 -4
  128. package/plugin/dist/transforms.js +4 -4
  129. package/dist/cms.d.ts +0 -6
  130. package/dist/cms.js +0 -27
  131. package/dist/components/cms/cms-repeater-editable.svelte +0 -137
  132. package/dist/components/cms/cms-repeater.svelte +0 -52
  133. package/dist/components/cms/cms-repeater.svelte.d.ts +0 -31
  134. /package/dist/{components/cms → core}/structured-registry.js +0 -0
package/README.md CHANGED
@@ -42,6 +42,8 @@ CMS identity therefore can't be `(component file + field name)`. It has to be `(
42
42
  npm install @velastack/cms
43
43
  ```
44
44
 
45
+ Requires SvelteKit 3, Svelte 5.57.1+, Vite 8.0.12+ and Node 22.17+. SvelteKit 2 projects stay on `@velastack/cms@^0.5`.
46
+
45
47
  ## Quick start
46
48
 
47
49
  ### 1. Register the Vite plugin
@@ -57,9 +59,20 @@ export default defineConfig({
57
59
  });
58
60
  ```
59
61
 
60
- The plugin reads `src/routes/` and `src/lib/` by default. Override with `cms({ routesDir, libDir })` if your project is laid out differently.
62
+ The plugin reads `src/routes/` and `src/lib/` by default. Override with `cms({ routesDir, libDir })` if your project is laid out differently. Imports through `#lib/…` (and any other `#` subpath import in your `package.json` `imports`) are followed, as are older `$lib/…` ones.
63
+
64
+ ### 2. Create the CMS in `src/lib/cms.ts`
61
65
 
62
- ### 2. Create the CMS in `$lib/cms.ts`
66
+ SvelteKit 3 projects import `src/lib` through the `#lib` subpath import, which `sv create` (and `sv migrate sveltekit-3`) declares in `package.json`:
67
+
68
+ ```json
69
+ {
70
+ "imports": {
71
+ "#lib": "./src/lib/index.js",
72
+ "#lib/*": "./src/lib/*"
73
+ }
74
+ }
75
+ ```
63
76
 
64
77
  ```ts
65
78
  // src/lib/cms.ts
@@ -84,7 +97,7 @@ Optionally include `svelte-meta-tags` for easy page metadata handling, but it is
84
97
  // src/routes/+layout.server.ts
85
98
  import { error, redirect, type ServerLoad } from '@sveltejs/kit';
86
99
  import { defineBaseMetaTags } from 'svelte-meta-tags';
87
- import { loadCms } from '$lib/cms.js';
100
+ import { loadCms } from '#lib/cms.js';
88
101
 
89
102
  export const load: ServerLoad = async (event) => {
90
103
  const { baseMetaTags } = defineBaseMetaTags({
@@ -199,6 +212,27 @@ Only URLs that flow through `apiAdapter` responses are rewritten. If you build a
199
212
 
200
213
  If `endpoint` is omitted (e.g. when using `mockAdapter` for tests/demos), the plugin's media steps are no-ops — builds proceed exactly as before.
201
214
 
215
+ ### Static sites: `@velastack/cms/adapter`
216
+
217
+ A fully static site uses the bundled adapter, a wrapper around `@sveltejs/adapter-static` (same options). SvelteKit 3 takes the adapter in `sveltekit({...})`:
218
+
219
+ ```ts
220
+ // vite.config.ts
221
+ import { defineConfig } from 'vite';
222
+ import { sveltekit } from '@sveltejs/kit/vite';
223
+ import { cms } from '@velastack/cms/vite';
224
+ import cmsAdapter from '@velastack/cms/adapter';
225
+
226
+ export default defineConfig({
227
+ plugins: [
228
+ cms({ endpoint: 'https://cms.example.com/v1/projects/p1/cms' }),
229
+ sveltekit({ adapter: cmsAdapter({ fallback: '200.html' }) })
230
+ ]
231
+ });
232
+ ```
233
+
234
+ After adapter-static writes the site, it adds what the admin bar needs to preview and create pages that were not prerendered: a `__fallback.json` next to each parameterized route (`rooms/__fallback.json` for `/(marketing)/rooms/[slug]`), and a `__velastack_manifest` of the creatable routes' prerendered entries injected into the `fallback` page. The manifest needs `fallback` set and the `cms()` plugin in the same build; the adapter warns if the plugin never reported.
235
+
202
236
  ## CMS components
203
237
 
204
238
  Every CMS component shares the same prop shape:
@@ -238,70 +272,93 @@ Rendered with `{@html}`. Edit mode swaps for a `<textarea>`.
238
272
 
239
273
  Renders an `<img>`. Edit mode shows the current image plus a URL input.
240
274
 
241
- ### `<CmsRepeater />`
275
+ ## Structured components
276
+
277
+ Hours, navigation, a team, a price list: one value with a fixed shape, edited in a slot-anchored popover and rendered by the template through a snippet. Every structured component is headless: it reads the value at `name`, normalizes it, derives the view a template needs and hands it to `children`. It never emits markup of its own.
242
278
 
243
279
  ```svelte
244
- <CmsRepeater name="gallery.items">
245
- {#snippet children(item)}
246
- <figure>
247
- <CmsImage name="src" value={item.src} alt={item.caption} />
248
- <figcaption>
249
- <CmsText name="caption" value={item.caption} />
250
- </figcaption>
251
- </figure>
280
+ <script lang="ts">
281
+ import { CmsHours, CmsNav, isActive } from '@velastack/cms';
282
+ </script>
283
+
284
+ <!-- In the root layout: site-wide by construction. -->
285
+ <CmsNav name="nav.primary">
286
+ {#snippet children(items)}
287
+ {#each items as item (item.id)}
288
+ <a href={item.href} aria-current={isActive(item, page.url) ? 'page' : undefined}
289
+ >{item.label}</a
290
+ >
291
+ {/each}
252
292
  {/snippet}
253
- </CmsRepeater>
293
+ </CmsNav>
294
+
295
+ <!-- On the contact page: the same value, read and written through the root scope. -->
296
+ <CmsHours name="hours" scope="root">
297
+ {#snippet children(h)}
298
+ <p>{h.today.label}: {h.today.text} · {h.status}</p>
299
+ {#each h.rows as row (row.id)}<dt>{row.days}</dt>
300
+ <dd>{row.text}</dd>{/each}
301
+ {/snippet}
302
+ </CmsHours>
254
303
  ```
255
304
 
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.
305
+ | Component | Value | Snippet receives | Helpers |
306
+ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------- |
307
+ | `CmsNav` | `{ items: [{ id, label, link, children }] }` | items with resolved `href` | `isActive(item, url)` |
308
+ | `CmsHours` | week grid, `exceptions`, `note`, `labels`, `timezone` | `rows`, `today`, `isOpenNow`, `status`, `exceptionRows` in the visitor's locale | `toOpeningHoursSpecification` |
309
+ | `CmsContact` | `name`, `address`, `phones`, `emails`, `whatsapp`, `directionsUrl`, `geo`, `labels` | plus `telHref`, `mailto`, `whatsappHref`, `mapsHref`, `formattedAddress` | `toPostalAddress` |
310
+ | `CmsSocialLinks` | `{ items: [{ id, platform, url, label }] }` | items with `name` (label or platform name) | `toSameAs`, `SOCIAL_PLATFORMS` |
311
+ | `CmsCollection` | rooms, services, tours: title, summary, body, image, gallery, price, link, tags, features, `details` key-value list, featured, hidden | visible items with `href`; props `limit`, `featured`, `tag` slice them | `formatCollectionPrice`, `collectionTags`, `toItemList` |
312
+ | `CmsTeam` | name, role, rich bio, photo, email, phone, profile links | members | |
313
+ | `CmsTestimonials` | rich quote, author, role, company, photo, rating, source, date | testimonials | `toReview` |
314
+ | `CmsPricing` | `currency`, `labels.custom`, tiers with a feature checklist and a CTA | tiers with `priceText` and `href` | `formatPrice` |
315
+ | `CmsFaq` | question, rich answer | questions | `toFaqPage` |
316
+ | `CmsStats`, `CmsSteps`, `CmsTimeline`, `CmsGallery`, `CmsLogos`, `CmsSchedule` | fixed list presets | items | `logosView`, `scheduleByDay` |
257
317
 
258
- ## Structured components
318
+ Templates use only these exports; there is no open item schema. A shape outside the presets is a new preset in the package. `fallback` takes the full value or a bare item list and is normalized like a stored value, so a hand-written seed with missing fields is safe. Prices, times, dates and day names format in the visitor's locale; `isOpenNow` is computed when the page renders, so a prerendered page shows the state at build time.
259
319
 
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.
320
+ Every value type (`CmsHoursValue`, `NavItem`, …), schema (`cmsHours`, …) and JSON-LD helper is exported from the package root.
261
321
 
262
- ```ts
263
- // hours.ts — the value's rules
264
- import { defineStructured, registerStructured, asItems, asString } from '@velastack/cms';
322
+ ### Building one
323
+
324
+ The package ships the pieces a structured component is made of, and the components above are built from nothing else.
265
325
 
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
- );
326
+ ```ts
327
+ // The value's rules: a versioned shape, its translatable strings, and the form the popover draws.
328
+ import { defineForm, asItems, asString } from '@velastack/cms';
329
+
330
+ export const cmsFaq = defineForm<CmsFaqValue>({
331
+ component: 'CmsFaq', // canonical export name, recorded in the manifest
332
+ label: 'FAQ', // popover header
333
+ version: 1, // written as `v` on every value
334
+ translatable: [], // root fields that translate (dotted paths allowed: 'labels.closed')
335
+ items: { key: 'items', translatable: ['question', 'answer'] }, // lists and their translatable fields; nest `items` for lists inside items
336
+ normalize: (raw) => ({ v: 1, items: asItems(raw?.items, …) }),
337
+ empty: () => ({ v: 1, items: [] }),
338
+ fields: [{ key: 'items', type: 'list', label: 'Questions', itemLabel: 'question', titleKey: 'question', blank: () => ({ question: '', answer: '' }), fields: [
339
+ { key: 'question', label: 'Question', type: 'text' },
340
+ { key: 'answer', label: 'Answer', type: 'html' }
341
+ ] }]
342
+ });
276
343
  ```
277
344
 
278
345
  ```svelte
279
- <!-- cms-hours.svelte — the display component -->
346
+ <!-- The display component, inside the package: the generic one, given the schema and a view function. -->
280
347
  <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
- );
348
+ import Structured from './cms-structured.svelte'; // src/lib/components/cms, inside the package
349
+ let { name, scope, fallback, value, children } = $props();
287
350
  </script>
288
351
 
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}
352
+ <Structured schema={cmsFaq} {name} {scope} {fallback} {value} view={faqView} {children} />
296
353
  ```
297
354
 
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.
355
+ Field types: `text`, `long-string`, `html` (TipTap), `number`, `boolean`, `date`, `time`, `url`, `image` and `images` (media picker, alt text), `enum`, `link` (page picker or URL, new tab), `strings` (one per line), `rating`, `list` (collapsible cards with reorder). `group` puts a field in a tab; `half` shares a row with the next field. A bespoke editor (the hours week grid) wraps `StructuredEditorPopover` from `@velastack/cms/editor` and renders `FieldsForm` for the rest; `FieldInput`, `ListField`, `LinkInput`, `RichTextInput`, `ReorderButtons` and the Popover / Tabs / Switch / Checkbox / Sheet primitives are exported there too. The popover commits on **Done**; edit mode is page-wide and the bar's Save flushes the draft.
299
356
 
300
357
  Rules every structured value follows (`core/structured.ts`):
301
358
 
302
359
  - **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.
360
+ - **List items carry a stable `id`** (`newItemId()`) at every nesting level; reorder and delete work because arrays replace wholesale on merge.
361
+ - **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, `field` may be a dotted path, and a string array translates per index as `tags.0`). `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.
305
362
 
306
363
  ### `<CmsEntries />`
307
364
 
@@ -347,7 +404,7 @@ A component is treated as a CMS component if **any** of the following hold:
347
404
 
348
405
  Use it from any route: `<CmsLink name="hero.cta" />`. The plugin picks up `hero.cta` as a field on the page's scope; no plugin config edit required.
349
406
 
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..
407
+ 2. **It's a named export from your local `src/lib/components/cms/index.{ts,js}` barrel** (imported as `#lib/components/cms/index.js`). Re-export your component there if you prefer a single import path.
351
408
 
352
409
  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.
353
410
 
@@ -7,7 +7,7 @@
7
7
  * export const cms = createCmsBackend();
8
8
  *
9
9
  * // src/routes/cms/[...path]/+server.ts
10
- * import { cms } from '../server/cms';
10
+ * import { cms } from '../server/cms.js';
11
11
  * export const prerender = false;
12
12
  * export const fallback = cms.handler;
13
13
  * ```
@@ -25,10 +25,12 @@
25
25
  * default; enabling it with an `origin` predicate that returns `true`
26
26
  * unconditionally lets any site drive the mutating endpoints with a signed-in
27
27
  * editor's cookie, because SvelteKit's CSRF check only guards form content
28
- * types and these are JSON. And SvelteKit *does* guard `multipart/form-data`,
29
- * so a cross-origin `POST /media` needs the site's origin in
30
- * `kit.csrf.trustedOrigins` — that check is production-only, so it will not
31
- * show up in `vela dev`.
28
+ * types (and, since Kit 3, requests with no content type) and these are JSON.
29
+ * The admin bar sends every mutation with a JSON content type for that reason.
30
+ * SvelteKit *does* guard `multipart/form-data`, so a cross-origin
31
+ * `POST /media` needs the site's origin in the backend app's
32
+ * `sveltekit({ csrf: { trustedOrigins: ['https://site.example'] } })` — that
33
+ * check is production-only, so it will not show up in `vela dev`.
32
34
  *
33
35
  * The session cookie is scoped to the mount path (`/cms`, or
34
36
  * `/v1/projects/<id>/cms`), so several projects on one origin hold independent
@@ -7,7 +7,7 @@
7
7
  * export const cms = createCmsBackend();
8
8
  *
9
9
  * // src/routes/cms/[...path]/+server.ts
10
- * import { cms } from '../server/cms';
10
+ * import { cms } from '../server/cms.js';
11
11
  * export const prerender = false;
12
12
  * export const fallback = cms.handler;
13
13
  * ```
@@ -25,10 +25,12 @@
25
25
  * default; enabling it with an `origin` predicate that returns `true`
26
26
  * unconditionally lets any site drive the mutating endpoints with a signed-in
27
27
  * editor's cookie, because SvelteKit's CSRF check only guards form content
28
- * types and these are JSON. And SvelteKit *does* guard `multipart/form-data`,
29
- * so a cross-origin `POST /media` needs the site's origin in
30
- * `kit.csrf.trustedOrigins` — that check is production-only, so it will not
31
- * show up in `vela dev`.
28
+ * types (and, since Kit 3, requests with no content type) and these are JSON.
29
+ * The admin bar sends every mutation with a JSON content type for that reason.
30
+ * SvelteKit *does* guard `multipart/form-data`, so a cross-origin
31
+ * `POST /media` needs the site's origin in the backend app's
32
+ * `sveltekit({ csrf: { trustedOrigins: ['https://site.example'] } })` — that
33
+ * check is production-only, so it will not show up in `vela dev`.
32
34
  *
33
35
  * The session cookie is scoped to the mount path (`/cms`, or
34
36
  * `/v1/projects/<id>/cms`), so several projects on one origin hold independent
@@ -138,7 +138,7 @@ export type CmsBackendOptions = {
138
138
  *
139
139
  * Enabling this is a security decision, not a convenience one: the session
140
140
  * cookie is credentialed and the JSON mutation endpoints are not covered by
141
- * SvelteKit's CSRF check (it only guards form content types). An `origin`
141
+ * SvelteKit's CSRF check (it only guards form and missing content types). An `origin`
142
142
  * predicate that returns `true` unconditionally lets any site on the
143
143
  * internet drive those endpoints with a signed-in editor's cookie. Allowlist
144
144
  * the origins you actually serve.
@@ -1,11 +1,5 @@
1
1
  <script lang="ts">
2
- import {
3
- afterNavigate,
4
- beforeNavigate,
5
- goto,
6
- invalidateAll,
7
- replaceState
8
- } from '$app/navigation';
2
+ import { afterNavigate, beforeNavigate, goto, refreshAll } from '$app/navigation';
9
3
  import { page } from '$app/state';
10
4
  import { pages } from 'virtual:vela-cms/pages';
11
5
  import { cmsStore, type PageDeleteOutcome } from '../cms/cms-store.svelte.js';
@@ -14,6 +8,7 @@
14
8
  import CssRoot from './css-root.svelte';
15
9
  import { adminBarTheme, type AdminBarTheme } from './theme.svelte.js';
16
10
  import type { CmsDeployState } from '../../core/wire.js';
11
+ import { cmsFetch } from '../../core/cms-fetch.js';
17
12
  import DeletePageDialog, {
18
13
  type DeletePageMode,
19
14
  type RedirectTarget
@@ -186,7 +181,7 @@
186
181
 
187
182
  const previewUrl = $derived.by(() => {
188
183
  if (versionKey) {
189
- const url = new URL(page.url);
184
+ const url = new URL(page.url.href);
190
185
  url.searchParams.delete('edit');
191
186
  url.searchParams.delete('preview');
192
187
  url.searchParams.set('version', versionKey);
@@ -194,7 +189,7 @@
194
189
  }
195
190
  const key = previewKey;
196
191
  if (!key) return '';
197
- const url = new URL(page.url);
192
+ const url = new URL(page.url.href);
198
193
  url.searchParams.delete('edit');
199
194
  url.searchParams.set('preview', key);
200
195
  return url.toString();
@@ -211,7 +206,7 @@
211
206
  };
212
207
 
213
208
  const setPreviewParam = async (key: string | null, opts: { replace?: boolean } = {}) => {
214
- const url = new URL(page.url);
209
+ const url = new URL(page.url.href);
215
210
  const current = url.searchParams.get('preview');
216
211
  url.searchParams.delete('edit');
217
212
  if (key) {
@@ -223,14 +218,14 @@
223
218
  }
224
219
  const path = url.pathname + url.search + url.hash;
225
220
  if (opts.replace) {
226
- replaceState(path, page.state);
221
+ await goto(path, { shallow: true, replace: true, state: page.state });
227
222
  } else {
228
- await goto(path, { keepFocus: true, noScroll: true });
223
+ await goto(path, { reset: false });
229
224
  }
230
225
  };
231
226
 
232
227
  const setVersionParam = async (key: string | null, opts: { replace?: boolean } = {}) => {
233
- const url = new URL(page.url);
228
+ const url = new URL(page.url.href);
234
229
  const current = url.searchParams.get('version');
235
230
  url.searchParams.delete('edit');
236
231
  url.searchParams.delete('preview');
@@ -243,14 +238,14 @@
243
238
  }
244
239
  const path = url.pathname + url.search + url.hash;
245
240
  if (opts.replace) {
246
- replaceState(path, page.state);
241
+ await goto(path, { shallow: true, replace: true, state: page.state });
247
242
  } else {
248
- await goto(path, { keepFocus: true, noScroll: true });
243
+ await goto(path, { reset: false });
249
244
  // Force the server load to re-fetch. SvelteKit's URL tracking
250
245
  // doesn't always pick up our `?version=` access (it lives behind
251
246
  // the `loadCms` helper) — visible on Exit, where we'd otherwise
252
247
  // keep showing the past release's `page.data.cms.docs`.
253
- await invalidateAll();
248
+ await refreshAll();
254
249
  }
255
250
  };
256
251
 
@@ -264,7 +259,7 @@
264
259
  // (and on static-export sites with no server load to re-run). Like the
265
260
  // other gating params, we strip the param when `null`.
266
261
  const setLocaleParam = async (locale: string | null, opts: { replace?: boolean } = {}) => {
267
- const url = new URL(page.url);
262
+ const url = new URL(page.url.href);
268
263
  const current = url.searchParams.get('locale');
269
264
  if (locale) {
270
265
  if (current === locale) return;
@@ -275,18 +270,18 @@
275
270
  }
276
271
  const path = url.pathname + url.search + url.hash;
277
272
  if (opts.replace) {
278
- replaceState(path, page.state);
273
+ await goto(path, { shallow: true, replace: true, state: page.state });
279
274
  return;
280
275
  }
281
276
  if (!locale) {
282
277
  clearingLocale = true;
283
278
  try {
284
- await goto(path, { keepFocus: true, noScroll: true });
279
+ await goto(path, { reset: false });
285
280
  } finally {
286
281
  clearingLocale = false;
287
282
  }
288
283
  } else {
289
- await goto(path, { keepFocus: true, noScroll: true });
284
+ await goto(path, { reset: false });
290
285
  }
291
286
  };
292
287
 
@@ -333,7 +328,7 @@
333
328
  // Until then, the overlay-sync effect must NOT strip `?preview=` from the
334
329
  // URL — `cmsStore.openRelease` is null pre-fetch, but that's "unknown",
335
330
  // not "no draft". Without this gate, a hard refresh on `?preview=<key>`
336
- // would treat the param as stale and replaceState it away.
331
+ // would treat the param as stale and shallow-replace it away.
337
332
  let openReleaseFetched = $state(false);
338
333
 
339
334
  // On mount and on subsequent URL/store changes: derive what to do (URL sync,
@@ -411,6 +406,7 @@
411
406
  // appended; already-correct nav targets pass through unchanged. Full-page
412
407
  // unloads (`willUnload`) and external/hash-only navs bypass.
413
408
  beforeNavigate((nav) => {
409
+ if (nav.shallow) return;
414
410
  if (nav.type === 'leave' || nav.willUnload) return;
415
411
  if (!nav.to) return;
416
412
  if (nav.to.url.origin !== location.origin) return;
@@ -434,10 +430,7 @@
434
430
  nav.cancel();
435
431
  const url = new URL(nav.to.url);
436
432
  carryLocaleOnto(url);
437
- void goto(url.pathname + url.search + url.hash, {
438
- keepFocus: true,
439
- noScroll: true
440
- });
433
+ void goto(url.pathname + url.search + url.hash, { reset: false });
441
434
  return;
442
435
  }
443
436
  // Target has no `?version=`. If we're exiting on purpose, let the nav
@@ -448,10 +441,7 @@
448
441
  url.searchParams.delete('preview');
449
442
  url.searchParams.set('version', versionKey);
450
443
  carryLocaleOnto(url);
451
- void goto(url.pathname + url.search + url.hash, {
452
- keepFocus: true,
453
- noScroll: true
454
- });
444
+ void goto(url.pathname + url.search + url.hash, { reset: false });
455
445
  return;
456
446
  }
457
447
 
@@ -462,10 +452,7 @@
462
452
  nav.cancel();
463
453
  const url = new URL(nav.to.url);
464
454
  carryLocaleOnto(url);
465
- void goto(url.pathname + url.search + url.hash, {
466
- keepFocus: true,
467
- noScroll: true
468
- });
455
+ void goto(url.pathname + url.search + url.hash, { reset: false });
469
456
  return;
470
457
  }
471
458
 
@@ -477,23 +464,26 @@
477
464
  const url = new URL(nav.to.url);
478
465
  if (key) url.searchParams.set('preview', key);
479
466
  carryLocaleOnto(url);
480
- void goto(url.pathname + url.search + url.hash, {
481
- keepFocus: true,
482
- noScroll: true
483
- });
467
+ void goto(url.pathname + url.search + url.hash, { reset: false });
484
468
  });
485
469
 
486
470
  // Re-apply the overlay on every internal navigation. SvelteKit may strip
487
471
  // the gating param when nav targets aren't preserve-params links, so we
488
- // reapply it via replaceState too. Merge (not reset) so unchanged-layout
472
+ // reapply it via a shallow replace too. Merge (not reset) so unchanged-layout
489
473
  // overlays keep showing while new-page-scope fetches resolve.
490
- afterNavigate(() => {
474
+ afterNavigate(({ shallow }) => {
475
+ if (shallow) return;
476
+
491
477
  if (versionKey) {
492
- const url = new URL(page.url);
478
+ const url = new URL(page.url.href);
493
479
  if (url.searchParams.get('version') !== versionKey) {
494
480
  url.searchParams.delete('preview');
495
481
  url.searchParams.set('version', versionKey);
496
- replaceState(url.pathname + url.search + url.hash, page.state);
482
+ void goto(url.pathname + url.search + url.hash, {
483
+ shallow: true,
484
+ replace: true,
485
+ state: page.state
486
+ });
497
487
  }
498
488
  // `reset` so a nav from one release to another wipes the prior
499
489
  // snapshot's overlay before merging in the new one — otherwise
@@ -522,10 +512,14 @@
522
512
  cmsStore.clearOverlay();
523
513
  return;
524
514
  }
525
- const url = new URL(page.url);
515
+ const url = new URL(page.url.href);
526
516
  if (url.searchParams.get('preview') !== key) {
527
517
  url.searchParams.set('preview', key);
528
- replaceState(url.pathname + url.search + url.hash, page.state);
518
+ void goto(url.pathname + url.search + url.hash, {
519
+ shallow: true,
520
+ replace: true,
521
+ state: page.state
522
+ });
529
523
  }
530
524
  void cmsStore.loadAndApplyOverlay(endpoint, currentScopes(), key, { locale: currentLocale });
531
525
  void cmsStore.loadAndApplyEntriesOverlay(endpoint, currentEntriesRouteIds(), key, {
@@ -684,7 +678,7 @@
684
678
  deploying = true;
685
679
  deployError = null;
686
680
  try {
687
- const res = await fetch(`${endpoint}/deploy`, { method: 'POST', credentials: 'include' });
681
+ const res = await cmsFetch(`${endpoint}/deploy`, { method: 'POST' });
688
682
  if (!res.ok) {
689
683
  deployError = (await res.text()) || 'Could not start the deploy.';
690
684
  return;
@@ -925,9 +919,8 @@
925
919
 
926
920
  const onRegeneratePreviewKey = async () => {
927
921
  if (versionKey && versionRelease) {
928
- const res = await fetch(`${endpoint}/release/history/${versionRelease.id}/preview-key`, {
929
- method: 'POST',
930
- credentials: 'include'
922
+ const res = await cmsFetch(`${endpoint}/release/history/${versionRelease.id}/preview-key`, {
923
+ method: 'POST'
931
924
  });
932
925
  if (!res.ok) return;
933
926
  const data = (await res.json()) as { preview_key: string };
@@ -948,9 +941,8 @@
948
941
  return;
949
942
  }
950
943
 
951
- const res = await fetch(`${endpoint}/release/preview-key`, {
952
- method: 'POST',
953
- credentials: 'include'
944
+ const res = await cmsFetch(`${endpoint}/release/preview-key`, {
945
+ method: 'POST'
954
946
  });
955
947
  if (!res.ok) return;
956
948
  await cmsStore.fetchOpenRelease(endpoint);
@@ -959,9 +951,8 @@
959
951
 
960
952
  const onDiscardAllChanges = async () => {
961
953
  if (!confirm('Discard all unpublished changes in your working copy?')) return;
962
- const res = await fetch(`${endpoint}/release/discard`, {
963
- method: 'POST',
964
- credentials: 'include'
954
+ const res = await cmsFetch(`${endpoint}/release/discard`, {
955
+ method: 'POST'
965
956
  });
966
957
  if (!res.ok) return;
967
958
  cmsStore.setOpenRelease(null);
@@ -1171,6 +1162,7 @@
1171
1162
  const sourceContents: Record<string, unknown> = res.ok
1172
1163
  ? ((await res.json()) as { contents: Record<string, unknown> }).contents
1173
1164
  : {};
1165
+
1174
1166
  const { metadata: meta, ...rest } = sourceContents as { metadata?: unknown } & Record<
1175
1167
  string,
1176
1168
  unknown
@@ -1179,6 +1171,7 @@
1179
1171
  meta && typeof meta === 'object' && !Array.isArray(meta)
1180
1172
  ? (meta as Record<string, unknown>)
1181
1173
  : {};
1174
+
1182
1175
  closeAllPanels();
1183
1176
  duplicateSource = { tree: rest, metadata: sourceMetadata };
1184
1177
  newDialog = config;
@@ -1261,7 +1254,7 @@
1261
1254
  duplicateSource = null;
1262
1255
  pagesOpen = false;
1263
1256
  cmsStore.isEditing = true;
1264
- await goto(url, { keepFocus: true, noScroll: true });
1257
+ await goto(url, { reset: false });
1265
1258
  } finally {
1266
1259
  newPageCreating = false;
1267
1260
  }
@@ -1320,7 +1313,7 @@
1320
1313
  <KbdShortcut keys="⌘E" class={menuShortcutClass} />
1321
1314
  </Menubar.Item>
1322
1315
  <Menubar.Item class={menuItemClass} onSelect={onOpenSeo}>
1323
- SEO &amp; Metadata
1316
+ SEO & Metadata
1324
1317
  <KbdShortcut keys="⌘I" class={menuShortcutClass} />
1325
1318
  </Menubar.Item>
1326
1319
  <Menubar.Separator class={menuSeparatorClass} />
@@ -1394,7 +1387,7 @@
1394
1387
  onSelect={onOpenPublish}
1395
1388
  disabled={counts.total === 0}
1396
1389
  >
1397
- Review &amp; Publish…
1390
+ Review & Publish…
1398
1391
  {#if counts.total > 0}
1399
1392
  <span class="vela:ml-auto vela:flex vela:items-center vela:gap-2">
1400
1393
  <span
@@ -1410,9 +1403,11 @@
1410
1403
  <KbdShortcut keys="⌘P" class={menuShortcutClass} />
1411
1404
  {/if}
1412
1405
  </Menubar.Item>
1413
- <Menubar.Item class={menuItemClass} onSelect={onShareLink} disabled={!previewUrl}>
1414
- Share Preview Link…
1415
- </Menubar.Item>
1406
+
1407
+ <Menubar.Item class={menuItemClass} onSelect={onShareLink} disabled={!previewUrl}
1408
+ >Share Preview Link…</Menubar.Item
1409
+ >
1410
+
1416
1411
  <Menubar.Item
1417
1412
  class={menuItemDestructiveClass}
1418
1413
  onSelect={onDiscardAllChanges}