@velastack/cms 0.5.1 → 0.6.1

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 (41) hide show
  1. package/README.md +38 -4
  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.svelte +11 -7
  7. package/dist/components/admin-bar/history-panel.svelte +4 -4
  8. package/dist/components/admin-bar/pages-panel.svelte +1 -1
  9. package/dist/components/admin-bar/theme.svelte.js +1 -1
  10. package/dist/components/cms/cms-store.svelte.js +8 -5
  11. package/dist/core/cms-fetch.d.ts +16 -0
  12. package/dist/core/cms-fetch.js +24 -0
  13. package/dist/core/shapes/nav.d.ts +1 -1
  14. package/dist/server/README.md +1 -1
  15. package/dist/server/api-adapter.js +1 -1
  16. package/dist/server/cms.d.ts +1 -1
  17. package/dist/server/cms.js +1 -1
  18. package/dist/server/load-cms.js +1 -1
  19. package/package.json +15 -11
  20. package/plugin/README.md +34 -4
  21. package/plugin/dist/adapter.d.ts +5 -5
  22. package/plugin/dist/adapter.js +21 -7
  23. package/plugin/dist/adapter.js.map +1 -1
  24. package/plugin/dist/build-state.d.ts +1 -0
  25. package/plugin/dist/build-state.js +10 -1
  26. package/plugin/dist/build-state.js.map +1 -1
  27. package/plugin/dist/build.d.ts +2 -2
  28. package/plugin/dist/build.js +15 -59
  29. package/plugin/dist/build.js.map +1 -1
  30. package/plugin/dist/index.js +5 -1
  31. package/plugin/dist/index.js.map +1 -1
  32. package/plugin/dist/manifest.d.ts +18 -0
  33. package/plugin/dist/manifest.js +78 -10
  34. package/plugin/dist/manifest.js.map +1 -1
  35. package/plugin/dist/parse-svelte.d.ts +7 -0
  36. package/plugin/dist/parse-svelte.js +8 -0
  37. package/plugin/dist/parse-svelte.js.map +1 -1
  38. package/plugin/dist/transforms.d.ts +4 -4
  39. package/plugin/dist/transforms.js +4 -4
  40. package/dist/cms.d.ts +0 -6
  41. package/dist/cms.js +0 -27
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:
@@ -370,7 +404,7 @@ A component is treated as a CMS component if **any** of the following hold:
370
404
 
371
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.
372
406
 
373
- 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.
374
408
 
375
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.
376
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}
@@ -1,9 +1,10 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
- import { browser } from '$app/environment';
4
- import { beforeNavigate, goto, invalidateAll, replaceState } from '$app/navigation';
3
+ import { browser } from '$app/env';
4
+ import { beforeNavigate, goto } from '$app/navigation';
5
5
  import { page } from '$app/state';
6
6
  import { cmsStore } from '../cms/cms-store.svelte.js';
7
+ import { cmsFetch } from '../../core/cms-fetch.js';
7
8
  import { adminBarTheme, type AdminBarTheme } from './theme.svelte.js';
8
9
 
9
10
  const STORAGE_KEY = 'cms.editEnabled';
@@ -53,7 +54,7 @@
53
54
  // `cms.editEnabled` flag keeps it open. We deliberately do NOT
54
55
  // react to `?edit` mid-session: a $effect on it would unconditionally
55
56
  // re-set `cms.editEnabled='1'` and `barEnabled=true` every time the
56
- // URL changed (including the replaceState we issue from teardown),
57
+ // URL changed (including the shallow `goto` we issue from teardown),
57
58
  // resurrecting the bar after `closeBar`/`onLogout`.
58
59
  if (browser) {
59
60
  untrack(() => {
@@ -68,10 +69,10 @@
68
69
  localStorage.setItem(STORAGE_KEY, '1');
69
70
  barEnabled = true;
70
71
  // Strip `?edit` so reloads don't re-grant access without the persisted flag.
71
- const url = new URL(page.url);
72
+ const url = new URL(page.url.href);
72
73
  url.searchParams.delete('edit');
73
74
  const path = url.pathname + url.search + url.hash;
74
- setTimeout(() => replaceState(path, page.state), 0);
75
+ setTimeout(() => void goto(path, { shallow: true, replace: true, state: page.state }), 0);
75
76
  } else if (stored) {
76
77
  barEnabled = true;
77
78
  }
@@ -110,6 +111,7 @@
110
111
 
111
112
  // ── Unsaved-changes guard ─────────────────────────────────────────
112
113
  beforeNavigate((nav) => {
114
+ if (nav.shallow) return;
113
115
  if (!cmsStore.isDirty) return;
114
116
  if (!confirm('You have unsaved changes. Leave anyway?')) nav.cancel();
115
117
  });
@@ -152,7 +154,7 @@
152
154
 
153
155
  // ── Teardown (shared by closeBar + onLogout) ──────────────────────
154
156
  const stripUrlParams = (...keys: string[]) => {
155
- const url = new URL(page.url);
157
+ const url = new URL(page.url.href);
156
158
  let changed = false;
157
159
  for (const k of keys) {
158
160
  if (url.searchParams.has(k)) {
@@ -190,7 +192,7 @@
190
192
  const onLogout = async () => {
191
193
  if (cmsStore.isDirty && !confirm('Discard unsaved changes?')) return;
192
194
  try {
193
- await fetch(`${endpoint}/logout`, { method: 'POST', credentials: 'include' });
195
+ await cmsFetch(`${endpoint}/logout`, { method: 'POST' });
194
196
  } catch {
195
197
  // Server call failed; tear down locally regardless.
196
198
  }
@@ -206,9 +208,11 @@
206
208
  {:else if barEnabled && authState === 'unauthed'}
207
209
  <div class="cms-signin">
208
210
  <span>Sign in to edit this site</span>
211
+
209
212
  <button type="button" class="cms-signin__signin" onclick={() => (signinOpen = !signinOpen)}
210
213
  >Sign in</button
211
214
  >
215
+
212
216
  <button type="button" class="cms-signin__close" aria-label="Close" onclick={closeBar}
213
217
  >×</button
214
218
  >
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { goto } from '$app/navigation';
3
3
  import { page } from '$app/state';
4
+ import { cmsFetch } from '../../core/cms-fetch.js';
4
5
  import PanelFooter from './panel-footer.svelte';
5
6
  import PanelHeader from './panel-header.svelte';
6
7
  import Panel from './panel.svelte';
@@ -68,8 +69,7 @@
68
69
  return;
69
70
  reverting = release.id;
70
71
  try {
71
- const res = await fetch(`${endpoint}/release/history/${release.id}/revert`, {
72
- credentials: 'include',
72
+ const res = await cmsFetch(`${endpoint}/release/history/${release.id}/revert`, {
73
73
  method: 'POST'
74
74
  });
75
75
  if (!res.ok) return;
@@ -146,11 +146,11 @@
146
146
  );
147
147
 
148
148
  const onView = async (release: PublishedRelease) => {
149
- const url = new URL(page.url);
149
+ const url = new URL(page.url.href);
150
150
  url.searchParams.delete('preview');
151
151
  url.searchParams.delete('edit');
152
152
  url.searchParams.set('version', release.preview_key);
153
- await goto(url.pathname + url.search + url.hash, { keepFocus: true, noScroll: true });
153
+ await goto(url.pathname + url.search + url.hash, { reset: false });
154
154
  onClose();
155
155
  };
156
156
  </script>
@@ -219,7 +219,7 @@
219
219
  const key = cmsStore.openRelease?.preview_key;
220
220
  if (key) url.searchParams.set('preview', key);
221
221
  // onClose();
222
- await goto(url, { keepFocus: true, noScroll: true });
222
+ await goto(url, { reset: false });
223
223
  };
224
224
 
225
225
  const onView = async (routeId: string, params: Record<string, string>) => {
@@ -5,7 +5,7 @@
5
5
  * component's `<CssRoot>`, and the system-preference listener all read from
6
6
  * the same singleton so theme changes propagate everywhere immediately.
7
7
  */
8
- import { browser } from '$app/environment';
8
+ import { browser } from '$app/env';
9
9
  const STORAGE_KEY = 'cms.theme';
10
10
  const readStoredTheme = () => {
11
11
  if (typeof localStorage === 'undefined')
@@ -27,8 +27,9 @@
27
27
  * Locales panel passes an explicit locale to inspect other locales'
28
28
  * working-copy state without switching the page.
29
29
  */
30
- import { browser } from '$app/environment';
30
+ import { browser } from '$app/env';
31
31
  import { page } from '$app/state';
32
+ import { cmsFetch } from '../../core/cms-fetch.js';
32
33
  import { mergeLocaleDocs, mergeLocaleEntries } from './locale-merge.js';
33
34
  import { composeKey } from './overlay-sync.js';
34
35
  import { get, has, mergeTree, set } from '../../core/path.js';
@@ -543,12 +544,14 @@ class CmsStore {
543
544
  if (!res.ok)
544
545
  throw new Error(`List failed (${res.status})`);
545
546
  const data = (await res.json());
546
- return { ...data, items: data.items.map((i) => resolveMediaItem(i, endpoint)) };
547
+ return {
548
+ ...data,
549
+ items: data.items.map((i) => resolveMediaItem(i, endpoint))
550
+ };
547
551
  }
548
552
  async deleteMedia(endpoint, id) {
549
- const res = await fetch(`${endpoint}/media/${id}`, {
550
- method: 'DELETE',
551
- credentials: 'include'
553
+ const res = await cmsFetch(`${endpoint}/media/${id}`, {
554
+ method: 'DELETE'
552
555
  });
553
556
  if (!res.ok)
554
557
  throw new Error(`Delete failed (${res.status})`);
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `fetch` for the CMS endpoint's mutating routes, with the editor's session
3
+ * cookie (`credentials: 'include'`) unless the caller overrides it.
4
+ *
5
+ * SvelteKit 3 treats a cross-origin `POST`/`PUT`/`PATCH`/`DELETE` that has no
6
+ * `content-type` like a form submission, and rejects it with a 403 unless the
7
+ * site's origin is in the backend's `csrf.trustedOrigins`. The admin bar talks
8
+ * to a cross-origin endpoint whenever the CMS is hosted (velastack.dev), so a
9
+ * bodiless mutation gets `content-type: application/json`. That turns it into a
10
+ * CORS-preflighted request, which the backend's `cors` option already answers.
11
+ *
12
+ * A caller-set `content-type` is kept, and so is the browser's own for a
13
+ * `FormData`, `Blob` or `URLSearchParams` body: multipart uploads keep their
14
+ * boundary.
15
+ */
16
+ export declare const cmsFetch: (input: string | URL, init?: RequestInit) => Promise<Response>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `fetch` for the CMS endpoint's mutating routes, with the editor's session
3
+ * cookie (`credentials: 'include'`) unless the caller overrides it.
4
+ *
5
+ * SvelteKit 3 treats a cross-origin `POST`/`PUT`/`PATCH`/`DELETE` that has no
6
+ * `content-type` like a form submission, and rejects it with a 403 unless the
7
+ * site's origin is in the backend's `csrf.trustedOrigins`. The admin bar talks
8
+ * to a cross-origin endpoint whenever the CMS is hosted (velastack.dev), so a
9
+ * bodiless mutation gets `content-type: application/json`. That turns it into a
10
+ * CORS-preflighted request, which the backend's `cors` option already answers.
11
+ *
12
+ * A caller-set `content-type` is kept, and so is the browser's own for a
13
+ * `FormData`, `Blob` or `URLSearchParams` body: multipart uploads keep their
14
+ * boundary.
15
+ */
16
+ export const cmsFetch = (input, init = {}) => {
17
+ const method = (init.method ?? 'GET').toUpperCase();
18
+ const headers = new Headers(init.headers);
19
+ const bodyHasOwnType = init.body != null && typeof init.body !== 'string';
20
+ if (method !== 'GET' && method !== 'HEAD' && !headers.has('content-type') && !bodyHasOwnType) {
21
+ headers.set('content-type', 'application/json');
22
+ }
23
+ return fetch(input, { credentials: 'include', ...init, headers });
24
+ };
@@ -18,4 +18,4 @@ export declare const cmsNav: import("./form.js").FormShape<CmsNavValue>;
18
18
  /** Items with resolved hrefs, dropping items that have no label. */
19
19
  export declare const navView: (value: CmsNavValue) => NavItemView[];
20
20
  /** Whether `item` (or one of its children) points at the current page. */
21
- export declare const isActive: (item: NavItemView, url: URL | string) => boolean;
21
+ export declare const isActive: (item: NavItemView, url: Pick<URL, "pathname"> | string) => boolean;