includio-cms 0.13.2 → 0.13.3

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 (45) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/ROADMAP.md +12 -2
  3. package/dist/admin/api/replace.js +4 -0
  4. package/dist/admin/api/rest/middleware/apiKey.js +7 -1
  5. package/dist/admin/api/upload.js +4 -0
  6. package/dist/admin/client/collection/collection-entries.svelte +8 -4
  7. package/dist/admin/client/collection/grid-view.svelte +1 -1
  8. package/dist/admin/client/entry/entry-header.svelte +37 -44
  9. package/dist/admin/client/entry/entry-header.svelte.d.ts +1 -2
  10. package/dist/admin/client/entry/entry-version.svelte +9 -3
  11. package/dist/admin/client/entry/entry.svelte +20 -1
  12. package/dist/admin/components/fields/seo-field.svelte +30 -16
  13. package/dist/admin/remote/entry.remote.js +3 -4
  14. package/dist/admin/state/content-language.svelte.d.ts +0 -3
  15. package/dist/admin/state/content-language.svelte.js +7 -11
  16. package/dist/admin/utils/entryLabel.js +2 -3
  17. package/dist/cms/runtime/api.d.ts +5 -0
  18. package/dist/cms/runtime/types.d.ts +13 -8
  19. package/dist/core/cms.js +3 -0
  20. package/dist/core/fields/layoutUtils.d.ts +2 -2
  21. package/dist/core/fields/layoutUtils.js +3 -10
  22. package/dist/core/server/entries/operations/get.js +2 -2
  23. package/dist/core/server/media/mimeBlocklist.d.ts +1 -0
  24. package/dist/core/server/media/mimeBlocklist.js +31 -0
  25. package/dist/paraglide/messages/_index.d.ts +3 -36
  26. package/dist/paraglide/messages/_index.js +3 -71
  27. package/dist/paraglide/messages/hello_world.d.ts +5 -0
  28. package/dist/paraglide/messages/hello_world.js +33 -0
  29. package/dist/paraglide/messages/login_hello.d.ts +16 -0
  30. package/dist/paraglide/messages/login_hello.js +34 -0
  31. package/dist/paraglide/messages/login_please_login.d.ts +16 -0
  32. package/dist/paraglide/messages/login_please_login.js +34 -0
  33. package/dist/sveltekit/server/handle.js +8 -0
  34. package/dist/updates/0.13.3/index.d.ts +2 -0
  35. package/dist/updates/0.13.3/index.js +21 -0
  36. package/dist/updates/index.js +2 -1
  37. package/package.json +1 -1
  38. package/dist/admin/utils/translationStatus.d.ts +0 -17
  39. package/dist/admin/utils/translationStatus.js +0 -133
  40. package/dist/demo/reset.d.ts +0 -1
  41. package/dist/demo/reset.js +0 -26
  42. package/dist/paraglide/messages/en.d.ts +0 -5
  43. package/dist/paraglide/messages/en.js +0 -14
  44. package/dist/paraglide/messages/pl.d.ts +0 -5
  45. package/dist/paraglide/messages/pl.js +0 -14
package/CHANGELOG.md CHANGED
@@ -3,6 +3,26 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.13.3 — 2026-03-23
7
+
8
+ Security hardening, per-language content fixes
9
+
10
+ ### Added
11
+ - Timing-safe API key comparison (prevents timing attacks)
12
+ - MIME type blocklist on upload and file replace endpoints
13
+ - Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy
14
+ - CMS constructor validates that at least one language is configured
15
+
16
+ ### Fixed
17
+ - Form submission rate limiting applies to all requests (not just multipart uploads)
18
+ - Form submission uses SvelteKit getClientAddress() instead of spoofable x-forwarded-for header
19
+ - Removed demo reset endpoint with hardcoded fallback secret
20
+ - Added .catch() handlers for unhandled promise rejections in admin components
21
+ - Per-language content language state and entry label fixes
22
+
23
+ ### Breaking
24
+ - CMS config with empty languages array now throws an error at initialization
25
+
6
26
  ## 0.13.2 — 2026-03-20
7
27
 
8
28
  Upload limits, stable reordering, relation filters, image style optimization
package/ROADMAP.md CHANGED
@@ -229,6 +229,16 @@
229
229
  - [x] `[fix]` `[P1]` Concurrent image style generation with buffer reuse (download once, parallel limit 3) <!-- files: src/lib/core/server/media/styles/operations/generateDefaultStyles.ts, src/lib/core/server/media/styles/sharp/generateImageStyle.ts -->
230
230
  - [x] `[fix]` `[P1]` Image style resolution gracefully skips missing styles <!-- files: src/lib/core/server/fields/utils/imageStyles.ts -->
231
231
 
232
+ ## 0.13.3 — Security hardening
233
+
234
+ - [x] `[fix]` `[P0]` Timing-safe API key comparison (prevents timing attacks) <!-- files: src/lib/admin/api/rest/middleware/apiKey.ts -->
235
+ - [x] `[fix]` `[P0]` Remove demo reset endpoint with hardcoded fallback secret <!-- files: src/routes/api/demo/reset/ -->
236
+ - [x] `[fix]` `[P1]` Form rate limit: all requests + getClientAddress() instead of x-forwarded-for <!-- files: src/routes/api/forms/[slug]/submit/+server.ts -->
237
+ - [x] `[feature]` `[P1]` MIME blocklist on upload/replace endpoints <!-- files: src/lib/core/server/media/mimeBlocklist.ts -->
238
+ - [x] `[feature]` `[P1]` Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy <!-- files: src/lib/sveltekit/server/handle.ts -->
239
+ - [x] `[fix]` `[P1]` CMS constructor validates non-empty languages array <!-- files: src/lib/core/cms.ts -->
240
+ - [x] `[fix]` `[P2]` Unhandled promise rejection handlers in admin components
241
+
232
242
  ## 0.14.0 — SEO module
233
243
 
234
244
  - [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
@@ -246,8 +256,8 @@
246
256
  - [ ] `[feature]` `[P1]` `sanitizeHTML` utility — general HTML sanitization outside richtext (text fields, SEO fields)
247
257
  - [ ] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware
248
258
  - [ ] `[feature]` `[P1]` CSRF protection — tokens for mutating operations
249
- - [ ] `[feature]` `[P1]` Rate limiting — API/auth endpoints
250
- - [ ] `[chore]` `[P1]` Security audit — review `{@html}` usage, innerHTML, injection vectors
259
+ - [x] `[feature]` `[P1]` Rate limiting — form submit endpoints (done in 0.13.3; admin/auth endpoints remaining)
260
+ - [x] `[chore]` `[P1]` Security audit — timing attacks, MIME validation, demo endpoint, rate limiting (done in 0.13.3; `{@html}` review remaining)
251
261
  - [ ] `[chore]` `[P1]` Input sanitization audit — review all fields for XSS
252
262
 
253
263
  ## Backlog
@@ -2,6 +2,7 @@ import { requireAuth } from '../remote/middleware/auth.js';
2
2
  import { replaceFile } from '../../core/server/media/operations/replaceFile.js';
3
3
  import { json } from '@sveltejs/kit';
4
4
  import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
5
+ import { isBlockedMimeType } from '../../core/server/media/mimeBlocklist.js';
5
6
  const MAX_UPLOAD_SIZE = getMaxUploadSize();
6
7
  export const POST = async ({ request }) => {
7
8
  requireAuth();
@@ -14,6 +15,9 @@ export const POST = async ({ request }) => {
14
15
  if (file.size > MAX_UPLOAD_SIZE) {
15
16
  return new Response('File too large', { status: 413 });
16
17
  }
18
+ if (isBlockedMimeType(file.type, file.name)) {
19
+ return new Response('File type not allowed', { status: 415 });
20
+ }
17
21
  if (!fileId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(fileId)) {
18
22
  return new Response('Invalid fileId', { status: 400 });
19
23
  }
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from 'node:crypto';
1
2
  import { getCMS } from '../../../../core/cms.js';
2
3
  export function extractApiKey(event) {
3
4
  const authHeader = event.request.headers.get('authorization');
@@ -6,9 +7,14 @@ export function extractApiKey(event) {
6
7
  }
7
8
  return event.request.headers.get('x-api-key');
8
9
  }
10
+ function safeEqual(a, b) {
11
+ if (a.length !== b.length)
12
+ return false;
13
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
14
+ }
9
15
  export function validateApiKey(key) {
10
16
  const cms = getCMS();
11
- return cms.apiKeys.find((k) => k.key === key) ?? null;
17
+ return cms.apiKeys.find((k) => safeEqual(k.key, key)) ?? null;
12
18
  }
13
19
  export function setSyntheticUser(event, apiKey) {
14
20
  const name = apiKey.name || 'api';
@@ -3,6 +3,7 @@ import { uploadFile } from '../../core/server/media/operations/uploadFile.js';
3
3
  import { getCMS } from '../../core/cms.js';
4
4
  import { json } from '@sveltejs/kit';
5
5
  import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
6
+ import { isBlockedMimeType } from '../../core/server/media/mimeBlocklist.js';
6
7
  const MAX_UPLOAD_SIZE = getMaxUploadSize();
7
8
  export const POST = async ({ request }) => {
8
9
  requireAuth();
@@ -15,6 +16,9 @@ export const POST = async ({ request }) => {
15
16
  if (file.size > MAX_UPLOAD_SIZE) {
16
17
  return new Response('File too large', { status: 413 });
17
18
  }
19
+ if (isBlockedMimeType(file.type, file.name)) {
20
+ return new Response('File type not allowed', { status: 415 });
21
+ }
18
22
  const dbFile = await uploadFile(file);
19
23
  if (tagIdsRaw) {
20
24
  try {
@@ -155,7 +155,7 @@
155
155
  ...relationFilterOptions,
156
156
  [field.slug]: labels.map((l) => ({ value: l.id, label: l.label }))
157
157
  };
158
- });
158
+ }).catch(() => {});
159
159
  }
160
160
  });
161
161
 
@@ -250,7 +250,7 @@
250
250
  if (entriesQuery.current) {
251
251
  fetchRelationLabels(entriesQuery.current.entries).then((l) => {
252
252
  labelLookup = l;
253
- });
253
+ }).catch(() => {});
254
254
  }
255
255
  });
256
256
 
@@ -286,7 +286,7 @@
286
286
  return renderComponent(EntryLink, {
287
287
  name: info.row.original.name,
288
288
  url: info.row.original.url,
289
- slug: info.row.original.slug?.[interfaceLanguage.current]
289
+ slug: info.row.original.slug
290
290
  });
291
291
  }
292
292
  },
@@ -534,7 +534,11 @@
534
534
  return {
535
535
  id: entry.id,
536
536
  name: getRawCollectionEntryLabel(entry, collection, contentLanguage.current),
537
- slug: extractSlug(entry),
537
+ slug: (() => {
538
+ const s = extractSlug(entry);
539
+ if (!s) return undefined;
540
+ return collection.pathTemplate ? collection.pathTemplate.replace('{slug}', s) : s;
541
+ })(),
538
542
  url: `/admin/entries/${entry.id}`,
539
543
  status: getEntryStatus(entry),
540
544
  createdAt: new Date(entry.createdAt),
@@ -85,7 +85,7 @@
85
85
  </h3>
86
86
  {#if item.slug}
87
87
  <div class="text-muted-foreground truncate font-mono text-[10px]">
88
- /{item.slug?.[interfaceLanguage.current]}
88
+ /{item.slug}
89
89
  </div>
90
90
  {/if}
91
91
  <div class="mt-auto flex flex-col items-start gap-1.5 pt-2">
@@ -11,12 +11,11 @@
11
11
  import LayoutSidebar from '@tabler/icons-svelte/icons/layout-sidebar';
12
12
  import SendIcon from '@tabler/icons-svelte/icons/send';
13
13
  import ChevronDownIcon from '@tabler/icons-svelte/icons/chevron-down';
14
- import LanguageIcon from '@tabler/icons-svelte/icons/language';
14
+ import CopyIcon from '@tabler/icons-svelte/icons/copy';
15
15
  import * as DropdownMenu from '../../../components/ui/dropdown-menu/index.js';
16
16
  import { hasHybridContext, getHybridContext } from './hybrid/hybrid-context.svelte.js';
17
17
  import { getEntryStatus } from './utils.js';
18
18
  import { onMount } from 'svelte';
19
- import type { LangStatus } from '../../utils/translationStatus.js';
20
19
 
21
20
  const contentLanguage = getContentLanguage();
22
21
  const interfaceLanguage = useInterfaceLanguage();
@@ -27,17 +26,20 @@
27
26
  publish: string;
28
27
  update: string;
29
28
  saveDraft: string;
29
+ copyFrom: string;
30
30
  }
31
31
  > = {
32
32
  en: {
33
33
  publish: 'Publish',
34
34
  update: 'Update',
35
- saveDraft: 'Save draft'
35
+ saveDraft: 'Save draft',
36
+ copyFrom: 'Copy from'
36
37
  },
37
38
  pl: {
38
39
  publish: 'Publikuj',
39
40
  update: 'Aktualizuj',
40
- saveDraft: 'Zapisz szkic'
41
+ saveDraft: 'Zapisz szkic',
42
+ copyFrom: 'Kopiuj z'
41
43
  }
42
44
  };
43
45
 
@@ -54,7 +56,7 @@
54
56
  saveStatus?: SaveStatus;
55
57
  isArchived?: boolean;
56
58
  onScrollToIssue?: (fieldSlug: string, nodePos: number) => void;
57
- translationStatus?: Record<string, LangStatus> | null;
59
+ onCopyFromLang?: (lang: string) => void;
58
60
  };
59
61
 
60
62
  let {
@@ -68,7 +70,7 @@
68
70
  saveStatus = 'idle',
69
71
  isArchived = false,
70
72
  onScrollToIssue,
71
- translationStatus = null
73
+ onCopyFromLang
72
74
  }: Props = $props();
73
75
  let { collection } = entry;
74
76
 
@@ -93,29 +95,8 @@
93
95
  });
94
96
  const shortcutLabel = $derived(isMac ? '⌘S' : 'Ctrl+S');
95
97
 
96
- function statusDotClass(status: LangStatus | undefined): string {
97
- if (!status) return 'bg-[var(--text-light)]';
98
- if (status.status === 'complete') return 'bg-[var(--success)]';
99
- if (status.status === 'partial') return 'bg-[var(--warning)]';
100
- return 'bg-[var(--text-light)]';
101
- }
102
-
103
- function statusLabel(lang: string, status: LangStatus | undefined): string {
104
- if (!status) return lang.toUpperCase();
105
- if (status.status === 'complete') return `${lang.toUpperCase()} \u2713`;
106
- if (status.status === 'partial') return `${lang.toUpperCase()} \u26A0`;
107
- return lang.toUpperCase();
108
- }
109
-
110
- function statusTooltip(lang: string, status: LangStatus | undefined): string {
111
- if (!status) return lang.toUpperCase();
112
- if (status.status === 'complete') return `${lang.toUpperCase()}: 100%`;
113
- const missing = status.missingFields.map((f) => f.label).join(', ');
114
- return `${lang.toUpperCase()}: ${status.percentage}% — ${interfaceLanguage.current === 'pl' ? 'brakuje' : 'missing'}: ${missing}`;
115
- }
116
-
117
- const isNonDefaultLang = $derived(
118
- contentLanguage.all.length > 1 && contentLanguage.current !== contentLanguage.all[0]
98
+ const otherLanguages = $derived(
99
+ contentLanguage.all.filter((l) => l !== contentLanguage.current)
119
100
  );
120
101
  </script>
121
102
 
@@ -134,34 +115,46 @@
134
115
  {#if contentLanguage.all.length > 1}
135
116
  <div class="border-border bg-muted inline-flex overflow-hidden rounded-lg border">
136
117
  {#each contentLanguage.all as lang}
137
- {@const langStatus = translationStatus?.[lang]}
138
118
  <button
139
119
  type="button"
140
120
  role="tab"
141
121
  aria-selected={lang === contentLanguage.current}
142
- title={statusTooltip(lang, langStatus)}
143
- class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-semibold transition-colors {lang ===
122
+ title={lang.toUpperCase()}
123
+ class="inline-flex items-center px-2.5 py-1 text-xs font-semibold transition-colors {lang ===
144
124
  contentLanguage.current
145
125
  ? 'text-primary bg-white shadow-sm ring-1 ring-primary/20'
146
126
  : 'text-muted-foreground hover:text-foreground bg-transparent'}"
147
127
  onclick={() => (contentLanguage.current = lang)}
148
128
  >
149
- <span class="size-2 shrink-0 rounded-full {statusDotClass(langStatus)}"></span>
150
- {statusLabel(lang, langStatus)}
129
+ {lang.toUpperCase()}
151
130
  </button>
152
131
  {/each}
153
132
  </div>
154
133
 
155
- <!-- Reference mode toggle -->
156
- <Button
157
- variant="ghost"
158
- size="icon-sm"
159
- class={contentLanguage.referenceMode ? 'text-primary bg-[var(--lavender-lighter)]' : ''}
160
- onclick={() => (contentLanguage.referenceMode = !contentLanguage.referenceMode)}
161
- title={interfaceLanguage.current === 'pl' ? 'Tryb referencyjny' : 'Reference mode'}
162
- >
163
- <LanguageIcon class="size-4" />
164
- </Button>
134
+ <!-- Copy from other language -->
135
+ {#if onCopyFromLang && otherLanguages.length > 0}
136
+ <DropdownMenu.Root>
137
+ <DropdownMenu.Trigger>
138
+ {#snippet child({ props })}
139
+ <button
140
+ {...props}
141
+ type="button"
142
+ class="text-muted-foreground hover:text-foreground inline-flex items-center rounded-md p-1.5 transition-colors hover:bg-[var(--lavender-lighter)]"
143
+ title={lang[interfaceLanguage.current].copyFrom}
144
+ >
145
+ <CopyIcon class="size-4" />
146
+ </button>
147
+ {/snippet}
148
+ </DropdownMenu.Trigger>
149
+ <DropdownMenu.Content align="end" class="w-48">
150
+ {#each otherLanguages as sourceLang}
151
+ <DropdownMenu.Item onclick={() => onCopyFromLang?.(sourceLang)}>
152
+ {lang[interfaceLanguage.current].copyFrom} {sourceLang.toUpperCase()}
153
+ </DropdownMenu.Item>
154
+ {/each}
155
+ </DropdownMenu.Content>
156
+ </DropdownMenu.Root>
157
+ {/if}
165
158
 
166
159
  <div class="bg-border mx-1 h-5 w-px shrink-0"></div>
167
160
  {/if}
@@ -1,7 +1,6 @@
1
1
  import type { DbEntryVersion, RawEntry } from '../../../types/entries.js';
2
2
  import type { UpdateEntryVersionCommandType } from '../../../core/server/entries/operations/update.js';
3
3
  import type { Field } from '../../../types/fields.js';
4
- import type { LangStatus } from '../../utils/translationStatus.js';
5
4
  type SaveStatus = 'idle' | 'saving' | 'saved' | 'unsaved' | 'error';
6
5
  type Props = {
7
6
  entry: RawEntry;
@@ -14,7 +13,7 @@ type Props = {
14
13
  saveStatus?: SaveStatus;
15
14
  isArchived?: boolean;
16
15
  onScrollToIssue?: (fieldSlug: string, nodePos: number) => void;
17
- translationStatus?: Record<string, LangStatus> | null;
16
+ onCopyFromLang?: (lang: string) => void;
18
17
  };
19
18
  declare const EntryHeader: import("svelte").Component<Props, {}, "">;
20
19
  type EntryHeader = ReturnType<typeof EntryHeader>;
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import { page } from '$app/state';
3
+ import { getContentLanguage } from '../../state/content-language.svelte.js';
3
4
  import type { RawEntry } from '../../../types/entries.js';
4
5
  import Entry from './entry.svelte';
5
6
 
@@ -9,11 +10,16 @@
9
10
 
10
11
  let { entry }: Props = $props();
11
12
 
13
+ const contentLanguage = getContentLanguage();
12
14
  const version = $derived(page.url.searchParams.get('version') || '');
13
15
 
14
- const editingEntry = $derived(
15
- entry.versions.find((ver) => ver.id === version) || entry.versions[0]
16
- );
16
+ const editingEntry = $derived.by(() => {
17
+ if (version) {
18
+ return entry.versions.find((ver) => ver.id === version) || entry.versions[0];
19
+ }
20
+ const lang = contentLanguage.current;
21
+ return entry.publishedVersions[lang] || entry.draftVersions[lang] || entry.versions[0];
22
+ });
17
23
  </script>
18
24
 
19
25
  {#key editingEntry.id}
@@ -26,7 +26,7 @@
26
26
  import { getFieldsFromConfig, hasLayout } from '../../../core/fields/layoutUtils.js';
27
27
  import type { ValidationErrors } from 'sveltekit-superforms';
28
28
  import { createHybridContext } from './hybrid/hybrid-context.svelte.js';
29
- import { onMount } from 'svelte';
29
+ import { onMount, setContext } from 'svelte';
30
30
  import { get } from 'svelte/store';
31
31
  const contentLanguage = getContentLanguage();
32
32
  const remotes = getRemotes();
@@ -140,6 +140,9 @@
140
140
  let { collection } = entry;
141
141
  const isArchived = $derived(!!entry.archivedAt);
142
142
 
143
+ setContext('cms-path-template', collection.pathTemplate || null);
144
+ setContext('cms-entry-published', entry.publishedVersions[contentLanguage.current] != null);
145
+
143
146
  // Create form once at component level — localized: false since data is flat single-language
144
147
  const collectionSchema = generateZodSchemaFromFields(
145
148
  getFieldsFromConfig(collection),
@@ -539,6 +542,21 @@
539
542
 
540
543
  const t = $derived(lang[interfaceLanguage.current]);
541
544
  const isHybrid = $derived(hybridContext.mode === 'hybrid' && !!collection.previewUrl);
545
+
546
+ function onCopyFromLang(sourceLang: string) {
547
+ const sourceVersion =
548
+ entry.draftVersions[sourceLang] ?? entry.publishedVersions[sourceLang];
549
+ if (!sourceVersion?.data) return;
550
+
551
+ const confirmMsg =
552
+ interfaceLanguage.current === 'pl'
553
+ ? `Nadpisze aktualne dane danymi z wersji ${sourceLang.toUpperCase()}. Kontynuować?`
554
+ : `This will overwrite current data with data from ${sourceLang.toUpperCase()} version. Continue?`;
555
+
556
+ if (!confirm(confirmMsg)) return;
557
+
558
+ form.form.set(sourceVersion.data);
559
+ }
542
560
  </script>
543
561
 
544
562
  <div class={isHybrid ? 'flex h-full flex-col overflow-hidden' : ''}>
@@ -553,6 +571,7 @@
553
571
  fields={getFieldsFromConfig(collection)}
554
572
  getFormData={() => get(form.form)}
555
573
  onScrollToIssue={scrollToIssue}
574
+ {onCopyFromLang}
556
575
  />
557
576
 
558
577
  {#if validationErrors.length > 0}
@@ -8,15 +8,22 @@
8
8
  MediaField,
9
9
  SeoField,
10
10
  SeoFieldData,
11
+ SlugField as SlugFieldType,
11
12
  TextField
12
13
  } from '../../../types/fields.js';
13
- import { untrack } from 'svelte';
14
+ import { formFieldProxy, type FormPathLeaves } from 'sveltekit-superforms';
15
+ import Input from '../../../components/ui/input/input.svelte';
16
+ import * as Form from '../../../components/ui/form/index.js';
17
+ import { getContext, untrack } from 'svelte';
14
18
  import slugify from '../../imports/slugify.js';
15
19
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
16
20
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
17
21
  import { Switch } from '../../../components/ui/switch/index.js';
18
22
 
19
23
  const interfaceLanguage = useInterfaceLanguage();
24
+ const pathTemplate = getContext<string | null>('cms-path-template');
25
+ const pathPrefix = pathTemplate ? pathTemplate.replace('{slug}', '') : '/';
26
+ const wasPublished = getContext<boolean>('cms-entry-published') ?? false;
20
27
 
21
28
  type Props = {
22
29
  field: SeoField;
@@ -172,6 +179,10 @@
172
179
  return 'text-destructive';
173
180
  }
174
181
 
182
+ // Slug field proxy for direct input binding
183
+ const slugPath = joinPath(String(path), 'slug');
184
+ const { value: slugValue } = formFieldProxy(form, slugPath as FormPathLeaves<Record<string, unknown>>);
185
+
175
186
  // Auto-gen: track last auto-generated value
176
187
  let lastAutoSlug = '';
177
188
  let lastAutoTitle = '';
@@ -179,6 +190,7 @@
179
190
  // Auto slug toggle
180
191
  let autoSlug = $state((() => {
181
192
  if (!field.slugSource) return false;
193
+ if (wasPublished) return false;
182
194
  const sourceRaw = ($formData as Record<string, unknown>)[field.slugSource];
183
195
  if (!sourceRaw || typeof sourceRaw !== 'string') return true;
184
196
  const slugPath = joinPath(String(path), 'slug');
@@ -238,21 +250,23 @@
238
250
  </script>
239
251
 
240
252
  <div class="space-y-4">
241
- <!-- Slug field with auto/manual toggle -->
242
- <div>
243
- {#if field.slugSource}
244
- <div class="mb-1.5 flex items-center gap-2">
245
- <span class="text-sm font-medium text-muted-foreground">Auto</span>
246
- <Switch bind:checked={autoSlug} onCheckedChange={onAutoSlugToggle} />
247
- </div>
248
- {/if}
249
- <FieldRenderer
250
- field={slugField}
251
- {form}
252
- path={joinPath(path, 'slug')}
253
- readonly={autoSlug}
254
- />
255
- </div>
253
+ <!-- Slug field with auto/manual toggle + path prefix -->
254
+ <Form.Field {form} name={slugPath} class="space-y-1">
255
+ <div class="flex items-center justify-between">
256
+ <Form.Label>{getLocalizedLabel(labels.slug.label, interfaceLanguage.current)}</Form.Label>
257
+ {#if field.slugSource}
258
+ <div class="flex items-center gap-2">
259
+ <span class="text-sm font-medium text-muted-foreground">Auto</span>
260
+ <Switch bind:checked={autoSlug} onCheckedChange={onAutoSlugToggle} />
261
+ </div>
262
+ {/if}
263
+ </div>
264
+ <div class="flex">
265
+ <span class="border-input bg-muted text-muted-foreground flex h-9 shrink-0 items-center rounded-l-md border border-r-0 px-2.5 font-mono text-sm">{pathPrefix}</span>
266
+ <Input bind:value={$slugValue} readonly={autoSlug} class="rounded-l-none border-l-0" />
267
+ </div>
268
+ <Form.Description>{getLocalizedLabel(labels.slug.description, interfaceLanguage.current)}</Form.Description>
269
+ </Form.Field>
256
270
  {#each fields as f}
257
271
  <div>
258
272
  <FieldRenderer
@@ -1,4 +1,5 @@
1
1
  import { command, query } from '$app/server';
2
+ import { getAtPath } from '../utils/objectPath.js';
2
3
  import { createEntry as createEntryOperation, createEntrySchema, createEntryVersion } from '../../core/server/entries/operations/create.js';
3
4
  import { getRawEntries as getRawEntriesOperation, countRawEntries as countRawEntriesOperation, getRawEntry as getRawEntryOperation, getRawEntryOrThrow, getDbEntry, getDbEntryOrThrow, getEntries as getEntriesOperation, getEntry as getEntryOperation, getEntryVersion as getEntryVersionOperation, getEntryLabels as getEntryLabelsOperation } from '../../core/server/entries/operations/get.js';
4
5
  import { getCMS } from '../../core/cms.js';
@@ -208,8 +209,7 @@ export const getRecentEntries = query(z.number().default(6), async (limit) => {
208
209
  const latestVersion = entry.versions[0];
209
210
  let label = null;
210
211
  if (config && config.type === 'collection' && config.entryAdminTitle && latestVersion) {
211
- const titleData = latestVersion.data[config.entryAdminTitle];
212
- // Data is flat — titleData is the string directly
212
+ const titleData = getAtPath(latestVersion.data, config.entryAdminTitle);
213
213
  if (typeof titleData === 'string') {
214
214
  label = titleData || '';
215
215
  }
@@ -257,8 +257,7 @@ export const getRecentActivity = query(z.number().default(10), async (limit) =>
257
257
  const config = getCMS().getBySlug(entry.slug);
258
258
  let label = null;
259
259
  if (config && config.type === 'collection' && config.entryAdminTitle) {
260
- const titleData = latestVersion.data[config.entryAdminTitle];
261
- // Data is flat — titleData is the string directly
260
+ const titleData = getAtPath(latestVersion.data, config.entryAdminTitle);
262
261
  if (typeof titleData === 'string') {
263
262
  label = titleData || null;
264
263
  }
@@ -2,7 +2,6 @@ export declare const getContentLanguage: () => ContentLanguage, setContentLangua
2
2
  type _ContentLanguage = {
3
3
  all: string[];
4
4
  current: string;
5
- referenceMode: boolean;
6
5
  };
7
6
  export declare class ContentLanguage {
8
7
  #private;
@@ -10,7 +9,5 @@ export declare class ContentLanguage {
10
9
  get all(): string[];
11
10
  get current(): _ContentLanguage["current"];
12
11
  set current(value: _ContentLanguage['current']);
13
- get referenceMode(): boolean;
14
- set referenceMode(value: boolean);
15
12
  }
16
13
  export {};
@@ -1,27 +1,23 @@
1
1
  import { createContext } from 'svelte';
2
+ import { PersistedState } from 'runed';
2
3
  export const [getContentLanguage, setContentLanguage] = createContext();
3
4
  export class ContentLanguage {
4
5
  #all;
5
6
  #current;
6
- #referenceMode;
7
7
  constructor(all, current) {
8
8
  this.#all = $state(all);
9
- this.#current = $state(current);
10
- this.#referenceMode = $state(false);
9
+ this.#current = new PersistedState('content-language', current);
10
+ if (!all.includes(this.#current.current)) {
11
+ this.#current.current = current;
12
+ }
11
13
  }
12
14
  get all() {
13
15
  return this.#all;
14
16
  }
15
17
  get current() {
16
- return this.#current;
18
+ return this.#current.current;
17
19
  }
18
20
  set current(value) {
19
- this.#current = value;
20
- }
21
- get referenceMode() {
22
- return this.#referenceMode;
23
- }
24
- set referenceMode(value) {
25
- this.#referenceMode = value;
21
+ this.#current.current = value;
26
22
  }
27
23
  }
@@ -2,15 +2,14 @@ import { getAtPath } from './objectPath.js';
2
2
  export function getRawCollectionEntryLabel(entry, config, language) {
3
3
  const publishedVersion = entry.publishedVersions[language];
4
4
  if (publishedVersion) {
5
- // Data is flat — entryAdminTitle value is directly a string
6
5
  return config.entryAdminTitle
7
- ? String(publishedVersion.data[config.entryAdminTitle] || entry.id)
6
+ ? String(getAtPath(publishedVersion.data, config.entryAdminTitle) || entry.id)
8
7
  : entry.id;
9
8
  }
10
9
  const draftVersion = entry.draftVersions[language];
11
10
  if (draftVersion) {
12
11
  return config.entryAdminTitle
13
- ? String(draftVersion.data[config.entryAdminTitle] || entry.id)
12
+ ? String(getAtPath(draftVersion.data, config.entryAdminTitle) || entry.id)
14
13
  : entry.id;
15
14
  }
16
15
  return entry.id;
@@ -8,10 +8,15 @@ interface GetEntryOptions {
8
8
  interface GetEntriesOptions extends GetEntryOptions {
9
9
  ids?: string[];
10
10
  dataLike?: Record<string, unknown>;
11
+ dataILikeOr?: Record<string, unknown>;
11
12
  orderBy?: {
12
13
  column: 'createdAt' | 'updatedAt' | 'sortOrder';
13
14
  direction: 'asc' | 'desc';
14
15
  };
16
+ dataOrderBy?: {
17
+ field: string;
18
+ direction: 'asc' | 'desc';
19
+ };
15
20
  limit?: number;
16
21
  offset?: number;
17
22
  }