includio-cms 0.37.2 → 0.37.4

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 (42) hide show
  1. package/API.md +3 -2
  2. package/CHANGELOG.md +19 -0
  3. package/DOCS.md +1 -1
  4. package/ROADMAP.md +21 -0
  5. package/dist/core/cms.d.ts +4 -1
  6. package/dist/core/cms.js +9 -2
  7. package/dist/core/fields/slugPath.d.ts +1 -1
  8. package/dist/core/fields/slugPath.js +3 -2
  9. package/dist/core/i18n/localeUrl.d.ts +20 -0
  10. package/dist/core/i18n/localeUrl.js +36 -0
  11. package/dist/core/server/entries/operations/get.js +1 -1
  12. package/dist/core/server/entries/operations/resolveEntry.d.ts +4 -2
  13. package/dist/core/server/entries/operations/resolveEntry.js +25 -43
  14. package/dist/core/server/entries/operations/versionResolve.d.ts +39 -0
  15. package/dist/core/server/entries/operations/versionResolve.js +52 -0
  16. package/dist/core/server/fields/populateSelect.d.ts +22 -0
  17. package/dist/core/server/fields/populateSelect.js +28 -0
  18. package/dist/core/server/fields/resolveRelationFields.d.ts +8 -8
  19. package/dist/core/server/fields/resolveRelationFields.js +135 -113
  20. package/dist/core/server/fields/resolveRichtextLinks.js +1 -1
  21. package/dist/core/server/fields/resolveUrlFields.js +1 -1
  22. package/dist/core/server/fields/slugResolver.d.ts +2 -2
  23. package/dist/core/server/fields/slugResolver.js +12 -4
  24. package/dist/core/server/generator/generator.js +60 -1
  25. package/dist/db-postgres/index.js +48 -0
  26. package/dist/db-postgres/schema/entry.js +2 -2
  27. package/dist/db-postgres/schema/entryVersion.js +5 -2
  28. package/dist/db-postgres/schema/mediaFileTag.js +4 -2
  29. package/dist/sveltekit/server/handle.js +5 -5
  30. package/dist/types/adapters/db.d.ts +13 -1
  31. package/dist/types/cms.d.ts +3 -2
  32. package/dist/types/cms.schema.d.ts +22 -0
  33. package/dist/types/cms.schema.js +15 -3
  34. package/dist/types/collections.d.ts +3 -0
  35. package/dist/types/entries.d.ts +2 -0
  36. package/dist/types/fields.d.ts +12 -0
  37. package/dist/types/languages.d.ts +13 -0
  38. package/dist/types/languages.js +2 -1
  39. package/dist/updates/0.37.4/index.d.ts +2 -0
  40. package/dist/updates/0.37.4/index.js +17 -0
  41. package/dist/updates/index.js +3 -1
  42. package/package.json +1 -1
@@ -2,53 +2,73 @@ import { walkInlineBlockNodes, cloneDoc } from '../../../admin/components/tiptap
2
2
  import { getCMS } from '../../cms.js';
3
3
  import { getFieldsFromConfig } from '../../fields/layoutUtils.js';
4
4
  import { getEntrySlugPath, getSlugFromEntryData, getEntryPath } from './slugResolver.js';
5
+ import { resolveEffectivePopulate, narrowFields, narrowData, populateConfigKey } from './populateSelect.js';
6
+ import { pickVersion as pickVersionBase, pickAndHydrateVersions } from '../entries/operations/versionResolve.js';
5
7
  /**
6
- * Version of a *related* entry to populate. Exported for tests.
7
- *
8
- * `draft` falls back to the published version: a related entry that has never been
9
- * drafted (only published versions) must still resolve, otherwise draft previews
10
- * silently drop every relation to `null` — see preview.remote.ts, which populates
11
- * form data with `status: 'draft'`.
8
+ * Wersja *populowanej relacji*: draft zawsze z fallbackiem na published — relacja,
9
+ * która nigdy nie była szkicowana (tylko published), musi się rozwiązać, inaczej
10
+ * podgląd szkicu cicho zeruje każdą relację (patrz preview.remote.ts). Eksport dla testów.
12
11
  */
13
12
  export function pickVersion(versions, status) {
14
- const sorted = versions.slice().sort((a, b) => b.versionNumber - a.versionNumber);
15
- const now = new Date();
16
- const published = () => sorted.find((v) => v.publishedAt != null && v.publishedAt <= now) ?? null;
17
- switch (status) {
18
- case 'published':
19
- return published();
20
- case 'draft':
21
- return sorted.find((v) => v.publishedAt == null) ?? published();
22
- case 'scheduled':
23
- return sorted.find((v) => v.publishedAt != null && v.publishedAt > now) ?? null;
24
- }
13
+ return pickVersionBase(versions, status, { draftFallback: true });
25
14
  }
26
15
  export async function resolveRelationFields(data, fields, ctx) {
27
- const entriesIds = [];
28
- const optedOutFields = new Set();
29
- // Collect ids skipping fields opted out at top level (raw passthrough).
16
+ const cms = getCMS();
17
+ // key `${id}::${configKey}` ten sam target pod różnymi configami populuje się osobno.
18
+ const configByKey = new Map();
19
+ /** Efektywny config dla pola relacji. `null` = denylist (surowe ID). */
20
+ const effForField = (field, topLevel) => {
21
+ const callOv = topLevel ? ctx.populate.fields?.[field.slug] : undefined;
22
+ if (callOv === false)
23
+ return null; // top-level denylist → raw passthrough
24
+ let collectionDefault;
25
+ try {
26
+ const target = cms.getBySlug(field.collection);
27
+ collectionDefault = target.defaultPopulate;
28
+ }
29
+ catch {
30
+ collectionDefault = undefined;
31
+ }
32
+ const cfg = resolveEffectivePopulate({
33
+ callOverride: callOv,
34
+ fieldPopulate: field.populate,
35
+ collectionDefault
36
+ });
37
+ const key = populateConfigKey(cfg);
38
+ configByKey.set(key, cfg);
39
+ return { cfg, key };
40
+ };
41
+ // Zbierz pary (id, configKey) do populacji.
42
+ const wanted = [];
43
+ const seenPair = new Set();
44
+ const addId = (id, key) => {
45
+ const pk = `${id}::${key}`;
46
+ if (seenPair.has(pk))
47
+ return;
48
+ seenPair.add(pk);
49
+ wanted.push({ id, key });
50
+ };
30
51
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
52
  const collectIds = (value, fields, topLevel) => {
32
53
  for (const field of fields) {
33
54
  const val = value?.[field.slug];
34
55
  if (val == null)
35
56
  continue;
36
- if (topLevel && ctx.populate.fields?.[field.slug] === false) {
37
- optedOutFields.add(field.slug);
38
- continue;
39
- }
40
57
  switch (field.type) {
41
58
  case 'relation': {
59
+ const eff = effForField(field, topLevel);
60
+ if (!eff)
61
+ break; // denylist → nie fetchuj
42
62
  // Skip empty strings — an unset optional relation stores '' and must
43
63
  // never reach the id query (Postgres rejects '' as uuid).
44
64
  if (field.multiple && Array.isArray(val)) {
45
65
  for (const id of val) {
46
66
  if (typeof id === 'string' && id !== '')
47
- entriesIds.push(id);
67
+ addId(id, eff.key);
48
68
  }
49
69
  }
50
70
  else if (typeof val === 'string' && val !== '') {
51
- entriesIds.push(val);
71
+ addId(val, eff.key);
52
72
  }
53
73
  break;
54
74
  }
@@ -86,92 +106,94 @@ export async function resolveRelationFields(data, fields, ctx) {
86
106
  }
87
107
  };
88
108
  collectIds(data, fields, true);
89
- if (entriesIds.length === 0)
109
+ if (wanted.length === 0)
90
110
  return data;
91
- // Filter: don't fetch already-visited (cycle) or beyond maxDepth those stay raw IDs
92
- const overDepth = ctx.depth >= ctx.maxDepth;
93
- const fetchableIds = overDepth
94
- ? []
95
- : [...new Set(entriesIds)].filter((id) => !ctx.visited.has(id));
96
- const cms = getCMS();
97
- // entriesMap: id → Entry (populated) | null (fetched but missing version/missing entry).
98
- // Absent key = id was NOT fetched (cycle, max depth, top-level opt-out) → falls back to raw id.
111
+ // Fetchowalność per (id, config): cap głębokości per-pole + brak cyklu.
112
+ const fetchable = wanted.filter(({ id, key }) => {
113
+ if (ctx.visited.has(id))
114
+ return false;
115
+ const cfg = configByKey.get(key) ?? null;
116
+ const fieldMax = cfg?.depth != null ? ctx.depth + 1 + cfg.depth : ctx.maxDepth;
117
+ return ctx.depth < fieldMax;
118
+ });
119
+ // entriesMap: `${id}::${key}` → Entry (populated) | null (fetched but missing).
120
+ // Brak klucza = nie fetchowano (cykl / max depth / denylist) → fallback do raw id.
99
121
  const entriesMap = {};
100
- if (fetchableIds.length > 0) {
101
- const dbEntries = await cms.databaseAdapter.getEntries({ ids: fetchableIds });
102
- const aliveDbEntries = dbEntries.filter((e) => e.archivedAt == null);
103
- const aliveIds = new Set(aliveDbEntries.map((e) => e.id));
104
- // Strict: missing entry / archived → null in nested
105
- for (const id of fetchableIds) {
106
- if (!aliveIds.has(id))
107
- entriesMap[id] = null;
108
- }
109
- if (aliveDbEntries.length > 0) {
110
- const versions = await cms.databaseAdapter.getEntryVersions({
111
- entryIds: aliveDbEntries.map((e) => e.id),
112
- lang: ctx.locale
113
- });
114
- const versionsByEntry = new Map();
115
- for (const v of versions) {
116
- const arr = versionsByEntry.get(v.entryId) ?? [];
117
- arr.push(v);
118
- versionsByEntry.set(v.entryId, arr);
122
+ const fetchIds = [...new Set(fetchable.map((p) => p.id))];
123
+ if (fetchIds.length > 0) {
124
+ const dbEntries = await cms.databaseAdapter.getEntries({ ids: fetchIds });
125
+ const aliveById = new Map(dbEntries.filter((e) => e.archivedAt == null).map((e) => [e.id, e]));
126
+ const versionMap = await pickAndHydrateVersions({
127
+ adapter: cms.databaseAdapter,
128
+ entryIds: [...aliveById.keys()],
129
+ lang: ctx.locale,
130
+ status: ctx.status,
131
+ draftFallback: true
132
+ });
133
+ // Lazy import to avoid static circular dep with populateEntry → resolveRelationFields
134
+ const { _populate } = await import('./populateEntry.js');
135
+ await Promise.all(fetchable.map(async ({ id, key }) => {
136
+ const mk = `${id}::${key}`;
137
+ const dbEntry = aliveById.get(id);
138
+ if (!dbEntry) {
139
+ entriesMap[mk] = null; // strict: missing / archived → null
140
+ return;
119
141
  }
120
- // Lazy import to avoid static circular dep with populateEntry → resolveRelationFields
121
- const { _populate } = await import('./populateEntry.js');
122
- await Promise.all(aliveDbEntries.map(async (dbEntry) => {
123
- const vs = versionsByEntry.get(dbEntry.id) ?? [];
124
- const picked = pickVersion(vs, ctx.status);
125
- if (!picked) {
126
- entriesMap[dbEntry.id] = null; // strict: missing version in locale → null
127
- return;
128
- }
129
- let config;
130
- try {
131
- config = cms.getBySlug(dbEntry.slug);
132
- }
133
- catch {
134
- entriesMap[dbEntry.id] = null;
135
- return;
136
- }
137
- const nestedFields = getFieldsFromConfig(config);
138
- const nestedCtx = {
139
- locale: ctx.locale,
140
- status: ctx.status,
141
- depth: ctx.depth + 1,
142
- maxDepth: ctx.maxDepth,
143
- visited: new Set([...ctx.visited, dbEntry.id]),
144
- populate: ctx.populate,
145
- entryId: dbEntry.id
142
+ const picked = versionMap.get(id);
143
+ if (!picked) {
144
+ entriesMap[mk] = null; // strict: missing version in locale → null
145
+ return;
146
+ }
147
+ let config;
148
+ try {
149
+ config = cms.getBySlug(dbEntry.slug);
150
+ }
151
+ catch {
152
+ entriesMap[mk] = null;
153
+ return;
154
+ }
155
+ const cfg = configByKey.get(key) ?? null;
156
+ const allFields = getFieldsFromConfig(config);
157
+ const nestedFields = cfg?.select ? narrowFields(allFields, cfg.select) : allFields;
158
+ const nestedData = cfg?.select ? narrowData(picked.data, cfg.select) : picked.data;
159
+ const nestedMaxDepth = cfg?.depth != null ? ctx.depth + 1 + cfg.depth : ctx.maxDepth;
160
+ const nestedCtx = {
161
+ locale: ctx.locale,
162
+ status: ctx.status,
163
+ depth: ctx.depth + 1,
164
+ maxDepth: nestedMaxDepth,
165
+ visited: new Set([...ctx.visited, dbEntry.id]),
166
+ populate: ctx.populate,
167
+ entryId: dbEntry.id
168
+ };
169
+ try {
170
+ const populated = await _populate(nestedData, nestedFields, nestedCtx);
171
+ const slugPath = getEntrySlugPath(dbEntry.slug);
172
+ const slugValue = getSlugFromEntryData(picked.data, slugPath, ctx.locale);
173
+ const _url = slugValue ? getEntryPath(dbEntry.slug, slugValue, ctx.locale) : undefined;
174
+ entriesMap[mk] = {
175
+ _id: dbEntry.id,
176
+ _slug: dbEntry.slug,
177
+ _type: dbEntry.type,
178
+ _publishedAt: picked.publishedAt,
179
+ _url,
180
+ ...(config.type === 'collection' && config.orderable
181
+ ? { _sortOrder: dbEntry.sortOrder }
182
+ : {}),
183
+ ...populated
146
184
  };
147
- try {
148
- const populated = await _populate(picked.data, nestedFields, nestedCtx);
149
- const slugPath = getEntrySlugPath(dbEntry.slug);
150
- const slugValue = getSlugFromEntryData(picked.data, slugPath, ctx.locale);
151
- const _url = slugValue ? getEntryPath(dbEntry.slug, slugValue) : undefined;
152
- entriesMap[dbEntry.id] = {
153
- _id: dbEntry.id,
154
- _slug: dbEntry.slug,
155
- _type: dbEntry.type,
156
- _publishedAt: picked.publishedAt,
157
- _url,
158
- ...(config.type === 'collection' && config.orderable
159
- ? { _sortOrder: dbEntry.sortOrder }
160
- : {}),
161
- ...populated
162
- };
163
- }
164
- catch (error) {
165
- console.error(`[CMS] Failed to populate nested entry ${dbEntry.id} (${dbEntry.slug}):`, error);
166
- }
167
- }));
168
- }
185
+ }
186
+ catch (error) {
187
+ console.error(`[CMS] Failed to populate nested entry ${dbEntry.id} (${dbEntry.slug}):`, error);
188
+ }
189
+ }));
169
190
  }
170
- const resolveRefId = (id) => {
191
+ const resolveRefId = (id, key) => {
192
+ const mk = `${id}::${key}`;
171
193
  // Has key (even if value is null) → use mapped value (Entry | null)
172
- if (Object.prototype.hasOwnProperty.call(entriesMap, id))
173
- return entriesMap[id];
174
- // Not fetched (cycle, max depth, top-level opt-out) → raw ID
194
+ if (Object.prototype.hasOwnProperty.call(entriesMap, mk))
195
+ return entriesMap[mk];
196
+ // Not fetched (cycle, max depth, denylist) → raw ID
175
197
  return id;
176
198
  };
177
199
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -184,18 +206,18 @@ export async function resolveRelationFields(data, fields, ctx) {
184
206
  result[field.slug] = val;
185
207
  continue;
186
208
  }
187
- // Per-field opt-out at top level → raw passthrough
188
- if (topLevel && optedOutFields.has(field.slug)) {
189
- result[field.slug] = val;
190
- continue;
191
- }
192
209
  switch (field.type) {
193
210
  case 'relation': {
211
+ const eff = effForField(field, topLevel);
212
+ if (!eff) {
213
+ result[field.slug] = val; // denylist → raw passthrough
214
+ break;
215
+ }
194
216
  if (field.multiple && Array.isArray(val)) {
195
- result[field.slug] = val.map((id) => typeof id === 'string' ? resolveRefId(id) : id);
217
+ result[field.slug] = val.map((id) => typeof id === 'string' ? resolveRefId(id, eff.key) : id);
196
218
  }
197
219
  else if (typeof val === 'string') {
198
- result[field.slug] = resolveRefId(val);
220
+ result[field.slug] = resolveRefId(val, eff.key);
199
221
  }
200
222
  else {
201
223
  result[field.slug] = val;
@@ -109,7 +109,7 @@ export async function resolveRichtextLinks(data, fields, ctx) {
109
109
  const slugPath = configSlug ? getEntrySlugPath(configSlug) : 'seo.slug';
110
110
  const slug = getSlugFromEntryData(rawData, slugPath, language);
111
111
  if (slug) {
112
- slugMap[entryId] = configSlug ? getEntryPath(configSlug, slug) : slug;
112
+ slugMap[entryId] = configSlug ? getEntryPath(configSlug, slug, language) : slug;
113
113
  }
114
114
  }
115
115
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -130,7 +130,7 @@ export async function resolveUrlFields(data, fields, ctx) {
130
130
  const slugPath = getEntrySlugPath(entry.slug);
131
131
  const slug = getSlugFromEntryData(version.data, slugPath, language);
132
132
  if (slug)
133
- slugMap[entry.id] = getEntryPath(entry.slug, slug);
133
+ slugMap[entry.id] = getEntryPath(entry.slug, slug, language);
134
134
  }
135
135
  }
136
136
  }
@@ -1,5 +1,5 @@
1
1
  export { getSlugFromEntryData } from '../../fields/slugPath.js';
2
2
  /** Get dot-path to slug field for a collection. Falls back to 'seo.slug'. */
3
3
  export declare function getEntrySlugPath(configSlug: string): string;
4
- /** Build full entry path using collection's pathTemplate. */
5
- export declare function getEntryPath(configSlug: string, slug: string): string;
4
+ /** Build full entry path using collection's pathTemplate, prefixed per locale. */
5
+ export declare function getEntryPath(configSlug: string, slug: string, locale?: string): string;
@@ -1,5 +1,6 @@
1
1
  import { getCMS } from '../../cms.js';
2
2
  import { getSlugFromEntryData, resolveSlugPath, applyPathTemplate } from '../../fields/slugPath.js';
3
+ import { pathPrefixFor } from '../../i18n/localeUrl.js';
3
4
  export { getSlugFromEntryData } from '../../fields/slugPath.js';
4
5
  /** Get dot-path to slug field for a collection. Falls back to 'seo.slug'. */
5
6
  export function getEntrySlugPath(configSlug) {
@@ -10,12 +11,19 @@ export function getEntrySlugPath(configSlug) {
10
11
  return resolveSlugPath(undefined);
11
12
  }
12
13
  }
13
- /** Build full entry path using collection's pathTemplate. */
14
- export function getEntryPath(configSlug, slug) {
14
+ /** Build full entry path using collection's pathTemplate, prefixed per locale. */
15
+ export function getEntryPath(configSlug, slug, locale) {
16
+ let prefix = '';
15
17
  try {
16
- return applyPathTemplate(getCMS().getBySlug(configSlug).pathTemplate, slug);
18
+ prefix = pathPrefixFor(getCMS().localeUrl, locale);
17
19
  }
18
20
  catch {
19
- return applyPathTemplate(undefined, slug);
21
+ prefix = '';
22
+ }
23
+ try {
24
+ return applyPathTemplate(getCMS().getBySlug(configSlug).pathTemplate, slug, prefix);
25
+ }
26
+ catch {
27
+ return applyPathTemplate(undefined, slug, prefix);
20
28
  }
21
29
  }
@@ -5,6 +5,8 @@ import { generateTsTypeFromFormFields } from './formFields.js';
5
5
  import { generateZodSchemaStringFromFormFieldsAsString } from './formFieldSchemaToString.js';
6
6
  import { toPascalCase, quoteKey } from './utils.js';
7
7
  import { getFieldsFromConfig } from '../../fields/layoutUtils.js';
8
+ import { buildLocaleUrl } from '../../i18n/localeUrl.js';
9
+ import { languageCode } from '../../../types/languages.js';
8
10
  function createCmsRuntimeDir() {
9
11
  const cmsDir = join(process.cwd(), 'src/lib/cms/runtime');
10
12
  mkdirSync(cmsDir, { recursive: true });
@@ -171,7 +173,7 @@ function generateTypes(config) {
171
173
  }
172
174
  }
173
175
  }
174
- code += `\n export type SiteLanguage = ${config.languages.map((lang) => JSON.stringify(lang)).join(' | ')};\n\n`;
176
+ code += `\n export type SiteLanguage = ${config.languages.map((lang) => JSON.stringify(languageCode(lang))).join(' | ')};\n\n`;
175
177
  writeIfChanged(filePath, code);
176
178
  }
177
179
  function generateAPI(config) {
@@ -307,6 +309,62 @@ function generateRemote(config) {
307
309
  });
308
310
  writeIfChanged(filePath, code);
309
311
  }
312
+ /**
313
+ * Emituje konfigurację app-owego Paraglide z `cms.config.languages` — jedno źródło prawdy i18n.
314
+ * `project.inlang/settings.json` (baseLocale=default, locales) + `src/lib/cms/runtime/i18n.ts`
315
+ * (`paraglideUrlPatterns` do vite.config + `reroute` do hooks). Konsument importuje te artefakty
316
+ * zamiast utrzymywać osobny, ręcznie synchronizowany config.
317
+ */
318
+ function generateI18n(config) {
319
+ const lu = buildLocaleUrl(config.languages);
320
+ const codes = config.languages.map((l) => typeof l === 'string' ? l : l.code);
321
+ // 1) project.inlang/settings.json
322
+ const settings = {
323
+ $schema: 'https://inlang.com/schema/project-settings',
324
+ modules: [
325
+ 'https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js',
326
+ 'https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js'
327
+ ],
328
+ 'plugin.inlang.messageFormat': { pathPattern: './messages/{locale}.json' },
329
+ baseLocale: lu.defaultLocale,
330
+ locales: codes
331
+ };
332
+ const inlangDir = join(process.cwd(), 'project.inlang');
333
+ mkdirSync(inlangDir, { recursive: true });
334
+ writeIfChanged(join(inlangDir, 'settings.json'), JSON.stringify(settings, null, '\t') + '\n');
335
+ createCmsRuntimeDir();
336
+ // 2) src/lib/cms/runtime/i18n.ts — CZYSTE dane urlPatterns (bez importów paraglide),
337
+ // żeby dało się importować w vite.config.ts (paraglide runtime jeszcze nie istnieje przy config-load).
338
+ // KOLEJNOŚĆ: prefiksowane locale NAJPIERW, domyślny (catch-all `/:path`) OSTATNI — inaczej
339
+ // catch-all złapałby `/en/...` przed wzorcem `/en`.
340
+ const ordered = [...codes].sort((a, b) => {
341
+ const aDef = a === lu.defaultLocale ? 1 : 0;
342
+ const bDef = b === lu.defaultLocale ? 1 : 0;
343
+ return aDef - bDef;
344
+ });
345
+ const localized = ordered
346
+ .map((c) => {
347
+ const e = lu.byCode[c];
348
+ const p = e && e.strategy === 'pathPrefix' ? e.pathPrefix : '';
349
+ return `\t\t\t['${c}', '${p}/:path(.*)?']`;
350
+ })
351
+ .join(',\n');
352
+ // Buduj specyfikator `$lib/...` przez konkatenację — inaczej svelte-package (build rdzenia)
353
+ // potraktuje literał jak import w tym pliku i przepisze go na relatywną ścieżkę RDZENIA.
354
+ const paraglideRuntime = '$' + 'lib/paraglide/runtime';
355
+ const i18n = `// AUTOGENEROWANE przez includio-cms (generateRuntime). Nie edytuj ręcznie.\n` +
356
+ `// \`import type\` jest wymazywany przy buildzie → można importować w vite.config.ts bez cyklu runtime.\n` +
357
+ `import type { Locale } from '${paraglideRuntime}';\n\n` +
358
+ `export const paraglideUrlPatterns: Array<{ pattern: string; localized: Array<[Locale, string]> }> = [\n` +
359
+ `\t{\n\t\tpattern: '/:path(.*)?',\n\t\tlocalized: [\n${localized}\n\t\t]\n\t}\n];\n`;
360
+ writeIfChanged(join(process.cwd(), 'src/lib/cms/runtime/i18n.ts'), i18n);
361
+ // 3) src/lib/cms/runtime/reroute.ts — reroute (importuje paraglide runtime), do wpięcia w hooks.ts
362
+ const reroute = `// AUTOGENEROWANE przez includio-cms (generateRuntime). Nie edytuj ręcznie.\n` +
363
+ `import { deLocalizeUrl } from '${paraglideRuntime}';\n` +
364
+ `import type { Reroute } from '@sveltejs/kit';\n\n` +
365
+ `export const reroute: Reroute = (request) => deLocalizeUrl(request.url).pathname;\n`;
366
+ writeIfChanged(join(process.cwd(), 'src/lib/cms/runtime/reroute.ts'), reroute);
367
+ }
310
368
  export function generateRuntime(config) {
311
369
  // Build custom fields map from plugins for type generation
312
370
  const customFields = new Map();
@@ -325,4 +383,5 @@ export function generateRuntime(config) {
325
383
  generateSchemas(config);
326
384
  generateDrizzleSchema(config);
327
385
  generateRemote(config);
386
+ generateI18n(config);
328
387
  }
@@ -494,6 +494,54 @@ export function pg(config) {
494
494
  }
495
495
  return await query;
496
496
  },
497
+ getEntryVersionsMeta: async (options) => {
498
+ const cols = {
499
+ id: schema.entryVersionsTable.id,
500
+ entryId: schema.entryVersionsTable.entryId,
501
+ lang: schema.entryVersionsTable.lang,
502
+ versionNumber: schema.entryVersionsTable.versionNumber,
503
+ createdAt: schema.entryVersionsTable.createdAt,
504
+ createdBy: schema.entryVersionsTable.createdBy,
505
+ publishedAt: schema.entryVersionsTable.publishedAt,
506
+ publishedBy: schema.entryVersionsTable.publishedBy
507
+ };
508
+ const query = db.select(cols).from(schema.entryVersionsTable);
509
+ if (options) {
510
+ const baseConditions = [
511
+ options.entryIds
512
+ ? inArray(schema.entryVersionsTable.entryId, options.entryIds)
513
+ : undefined,
514
+ options.ids ? inArray(schema.entryVersionsTable.id, options.ids) : undefined,
515
+ options.lang ? eq(schema.entryVersionsTable.lang, options.lang) : undefined
516
+ ].filter(Boolean);
517
+ const jsonConditions = options.dataValues ? buildJsonConditions(options.dataValues) : [];
518
+ const jsonLikeConditions = options.dataLike
519
+ ? buildJsonLikeConditions(options.dataLike)
520
+ : [];
521
+ const jsonILikeOrCondition = options.dataILikeOr
522
+ ? buildJsonILikeOrConditions(options.dataILikeOr)
523
+ : undefined;
524
+ const allConditions = [
525
+ ...baseConditions,
526
+ ...jsonConditions,
527
+ ...jsonLikeConditions,
528
+ ...(jsonILikeOrCondition ? [jsonILikeOrCondition] : [])
529
+ ];
530
+ if (allConditions.length > 0) {
531
+ query.where(and(...allConditions));
532
+ }
533
+ }
534
+ return await query;
535
+ },
536
+ getEntryVersionData: async ({ picks, lang }) => {
537
+ if (picks.length === 0)
538
+ return [];
539
+ const pairConds = picks.map((p) => and(eq(schema.entryVersionsTable.entryId, p.entryId), eq(schema.entryVersionsTable.versionNumber, p.versionNumber)));
540
+ return await db
541
+ .select()
542
+ .from(schema.entryVersionsTable)
543
+ .where(and(eq(schema.entryVersionsTable.lang, lang), or(...pairConds)));
544
+ },
497
545
  updateEntryVersion: async ({ id, data }) => {
498
546
  return db.transaction(async (tx) => {
499
547
  const [result] = await tx
@@ -1,4 +1,4 @@
1
- import { integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
1
+ import { index, integer, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
2
  export const entriesTable = pgTable('entry', {
3
3
  id: uuid('id').primaryKey().defaultRandom(),
4
4
  slug: text('slug').notNull(),
@@ -9,4 +9,4 @@ export const entriesTable = pgTable('entry', {
9
9
  archivedAt: timestamp('archived_at'), // soft delete / archiwum
10
10
  // Manual ordering
11
11
  sortOrder: integer('sort_order')
12
- });
12
+ }, (t) => [index('entry_slug_idx').on(t.slug), index('entry_type_idx').on(t.type)]);
@@ -1,4 +1,4 @@
1
- import { integer, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
1
+ import { index, integer, jsonb, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
2
2
  import { entriesTable } from './entry.js';
3
3
  import {} from '../../types/entries.js';
4
4
  export const entryVersionsTable = pgTable('entry_version', {
@@ -13,4 +13,7 @@ export const entryVersionsTable = pgTable('entry_version', {
13
13
  createdBy: text('created_by'),
14
14
  publishedAt: timestamp('published_at'),
15
15
  publishedBy: text('published_by')
16
- });
16
+ }, (t) => [
17
+ index('entry_version_entry_lang_ver_idx').on(t.entryId, t.lang, t.versionNumber),
18
+ index('entry_version_entry_published_idx').on(t.entryId, t.publishedAt)
19
+ ]);
@@ -1,4 +1,4 @@
1
- import { pgTable, primaryKey, uuid } from 'drizzle-orm/pg-core';
1
+ import { index, pgTable, primaryKey, uuid } from 'drizzle-orm/pg-core';
2
2
  import { mediaFilesTable } from './mediaFile.js';
3
3
  import { mediaTagsTable } from './mediaTag.js';
4
4
  export const mediaFileTagsTable = pgTable('media_file_tag', {
@@ -8,4 +8,6 @@ export const mediaFileTagsTable = pgTable('media_file_tag', {
8
8
  tagId: uuid('tag_id')
9
9
  .notNull()
10
10
  .references(() => mediaTagsTable.id, { onDelete: 'cascade' })
11
- }, (t) => [primaryKey({ columns: [t.fileId, t.tagId] })]);
11
+ },
12
+ // PK (file_id, tag_id) już pokrywa lookup po file_id; brakuje leading tag_id.
13
+ (t) => [primaryKey({ columns: [t.fileId, t.tagId] }), index('media_file_tag_tag_idx').on(t.tagId)]);
@@ -7,7 +7,8 @@ import { building } from '$app/environment';
7
7
  import { csrfGuard } from '../../server/security/csrf.js';
8
8
  import { rateLimitGuard } from '../../server/security/rate-limit.js';
9
9
  import { buildCspHeader } from '../../server/security/csp.js';
10
- import { baseLocale, extractLocaleFromRequest, isLocale } from '../../paraglide/runtime.js';
10
+ import { baseLocale } from '../../paraglide/runtime.js';
11
+ import { localeFromPath } from '../../core/i18n/localeUrl.js';
11
12
  const adminGuard = async ({ event, resolve }) => {
12
13
  const { user, session } = event.locals;
13
14
  // Secure the admin routes
@@ -62,12 +63,11 @@ const handleAuth = async ({ event, resolve }) => {
62
63
  return svelteKitHandler({ event, resolve, auth, building });
63
64
  };
64
65
  const paraglideLang = async ({ event, resolve }) => {
66
+ // Locale storefrontu z URL (prefiks z cms.config.languages), config-driven, bez cookie.
67
+ // Trasy bez prefiksu (np. /admin) → defaultLocale.
65
68
  let locale = baseLocale;
66
69
  try {
67
- const detected = extractLocaleFromRequest(event.request);
68
- if (detected && isLocale(detected)) {
69
- locale = detected;
70
- }
70
+ locale = localeFromPath(getCMS().localeUrl, event.url.pathname);
71
71
  }
72
72
  catch {
73
73
  // fall back to baseLocale
@@ -1,5 +1,5 @@
1
1
  import type { ConsentLogData, ConsentLogRecord, GetConsentLogsFilters } from '../consent.js';
2
- import type { DbEntry, DbEntryInsert, DbEntryVersion, DbEntryVersionInsert, GetDbEntriesOptions, GetDbEntryVersionsOptions, GetPaginatedEntriesOptions, PaginatedEntryRow, PaginationOptions } from '../entries.js';
2
+ import type { DbEntry, DbEntryInsert, DbEntryVersion, DbEntryVersionInsert, DbEntryVersionMeta, GetDbEntriesOptions, GetDbEntryVersionsOptions, GetPaginatedEntriesOptions, PaginatedEntryRow, PaginationOptions } from '../entries.js';
3
3
  import type { ImageFieldStyle } from '../fields.js';
4
4
  import type { FormSubmission } from '../forms.js';
5
5
  import type { ImageStyle, MediaFile, MediaTag, UploadedMediaFile, VideoStyle, VideoStyleStatus } from '../media.js';
@@ -23,6 +23,10 @@ export interface DatabaseAdapter {
23
23
  createEntryVersion: CreateEntryVersion;
24
24
  updateEntryVersion: UpdateEntryVersion;
25
25
  getEntryVersions: GetEntryVersions;
26
+ /** Metadane wersji bez blobu `data` — do wyboru najnowszej wersji bez over-fetchu. */
27
+ getEntryVersionsMeta: GetEntryVersionsMeta;
28
+ /** Hydratacja `data` tylko dla wybranych par (entryId, versionNumber). */
29
+ getEntryVersionData: GetEntryVersionData;
26
30
  deleteEntryVersion: DeleteEntryVersion;
27
31
  createFormSubmission: CreateFormSubmission;
28
32
  getFormSubmissions: GetFormSubmissions;
@@ -88,6 +92,14 @@ export type ArchiveEntry = (data: {
88
92
  }) => Promise<DbEntry>;
89
93
  export type CreateEntryVersion = (data: DbEntryVersionInsert) => Promise<DbEntryVersion>;
90
94
  export type GetEntryVersions = (data: GetDbEntryVersionsOptions) => Promise<DbEntryVersion[]>;
95
+ export type GetEntryVersionsMeta = (data: GetDbEntryVersionsOptions) => Promise<DbEntryVersionMeta[]>;
96
+ export type GetEntryVersionData = (data: {
97
+ picks: {
98
+ entryId: string;
99
+ versionNumber: number;
100
+ }[];
101
+ lang: string;
102
+ }) => Promise<DbEntryVersion[]>;
91
103
  export type UpdateEntryVersion = (data: {
92
104
  id: string;
93
105
  data: Partial<DbEntryVersion>;
@@ -1,7 +1,7 @@
1
1
  import type { DatabaseAdapter } from './adapters/db.js';
2
2
  import type { FilesAdapter } from './adapters/files.js';
3
3
  import type { CollectionConfig, CollectionConfigWithType } from './collections.js';
4
- import type { Language } from './languages.js';
4
+ import type { Language, LanguageEntry } from './languages.js';
5
5
  import type { SingleConfig, SingleConfigWithType } from './singles.js';
6
6
  import type { CustomFieldDefinition, IconSetPlugin, Plugin } from './plugins.js';
7
7
  import type { FormConfig } from './forms.js';
@@ -144,7 +144,8 @@ export interface AdminConfig {
144
144
  extraNavItems?: import('../admin/types.js').AdminNavItem[];
145
145
  }
146
146
  export interface CMSConfig {
147
- languages: Language[];
147
+ /** Kod ISO albo obiekt `{ code, default?, url? }`. Znormalizowane do kodów w `CMS.languages`. */
148
+ languages: LanguageEntry[];
148
149
  collections?: CollectionConfig[];
149
150
  singles?: SingleConfig[];
150
151
  forms?: FormConfig[];