includio-cms 0.16.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DOCS.md +1 -1
  3. package/README.md +62 -0
  4. package/dist/admin/api/rest/routes/collections.js +1 -1
  5. package/dist/admin/api/rest/routes/entries.js +1 -1
  6. package/dist/admin/api/rest/routes/singletons.js +1 -1
  7. package/dist/admin/remote/entry.remote.js +20 -3
  8. package/dist/admin/remote/invite.d.ts +1 -1
  9. package/dist/admin/remote/preview.remote.js +10 -2
  10. package/dist/admin/remote/reorder.js +1 -1
  11. package/dist/admin/remote/shop.remote.d.ts +18 -18
  12. package/dist/ai-claude/index.d.ts +9 -0
  13. package/dist/ai-claude/index.js +23 -7
  14. package/dist/ai-openai/index.d.ts +9 -0
  15. package/dist/ai-openai/index.js +28 -9
  16. package/dist/cms/runtime/api.d.ts +10 -6
  17. package/dist/cms/runtime/api.js +7 -7
  18. package/dist/components/ui/accordion/accordion.svelte.d.ts +1 -1
  19. package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
  20. package/dist/components/ui/command/command-dialog.svelte.d.ts +1 -1
  21. package/dist/components/ui/command/command-input.svelte.d.ts +1 -1
  22. package/dist/components/ui/command/command.svelte.d.ts +1 -1
  23. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte.d.ts +1 -1
  24. package/dist/components/ui/input/input.svelte.d.ts +1 -1
  25. package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
  26. package/dist/components/ui/input-group/input-group-textarea.svelte.d.ts +1 -1
  27. package/dist/components/ui/radio-group/radio-group.svelte.d.ts +1 -1
  28. package/dist/components/ui/sidebar/sidebar-input.svelte.d.ts +1 -1
  29. package/dist/components/ui/tabs/tabs.svelte.d.ts +1 -1
  30. package/dist/components/ui/textarea/textarea.svelte.d.ts +1 -1
  31. package/dist/components/ui/toggle-group/toggle-group-item.svelte.d.ts +1 -1
  32. package/dist/components/ui/toggle-group/toggle-group.svelte.d.ts +1 -1
  33. package/dist/core/server/entries/operations/create.js +1 -1
  34. package/dist/core/server/entries/operations/get.d.ts +20 -16
  35. package/dist/core/server/entries/operations/get.js +45 -214
  36. package/dist/core/server/entries/operations/resolveEntry.d.ts +94 -0
  37. package/dist/core/server/entries/operations/resolveEntry.js +210 -0
  38. package/dist/core/server/entries/operations/update.js +1 -1
  39. package/dist/core/server/fields/populateEntry.d.ts +9 -1
  40. package/dist/core/server/fields/populateEntry.js +22 -18
  41. package/dist/core/server/fields/resolveRelationFields.d.ts +2 -1
  42. package/dist/core/server/fields/resolveRelationFields.js +140 -34
  43. package/dist/core/server/fields/resolveRichtextLinks.d.ts +2 -1
  44. package/dist/core/server/fields/resolveRichtextLinks.js +2 -1
  45. package/dist/core/server/fields/resolveUrlFields.d.ts +2 -1
  46. package/dist/core/server/fields/resolveUrlFields.js +6 -5
  47. package/dist/core/server/generator/generator.js +17 -14
  48. package/dist/db-postgres/index.d.ts +4 -0
  49. package/dist/db-postgres/index.js +4 -0
  50. package/dist/email-nodemailer/index.d.ts +9 -0
  51. package/dist/email-nodemailer/index.js +28 -6
  52. package/dist/entity/index.js +1 -1
  53. package/dist/files-local/index.d.ts +4 -0
  54. package/dist/files-local/index.js +4 -0
  55. package/dist/paraglide/messages/_index.d.ts +3 -36
  56. package/dist/paraglide/messages/_index.js +3 -71
  57. package/dist/paraglide/messages/hello_world.d.ts +5 -0
  58. package/dist/paraglide/messages/hello_world.js +33 -0
  59. package/dist/paraglide/messages/login_hello.d.ts +16 -0
  60. package/dist/paraglide/messages/login_hello.js +34 -0
  61. package/dist/paraglide/messages/login_please_login.d.ts +16 -0
  62. package/dist/paraglide/messages/login_please_login.js +34 -0
  63. package/dist/shop/server/populate.d.ts +2 -1
  64. package/dist/shop/server/populate.js +2 -1
  65. package/dist/sveltekit/server/index.d.ts +1 -1
  66. package/dist/sveltekit/server/index.js +1 -1
  67. package/dist/types/adapters/ai.d.ts +8 -0
  68. package/dist/types/adapters/db.d.ts +9 -0
  69. package/dist/types/adapters/email.d.ts +6 -0
  70. package/dist/types/adapters/files.d.ts +5 -0
  71. package/dist/types/plugins.d.ts +6 -2
  72. package/dist/updates/0.18.0/index.d.ts +2 -0
  73. package/dist/updates/0.18.0/index.js +78 -0
  74. package/dist/updates/0.19.0/index.d.ts +2 -0
  75. package/dist/updates/0.19.0/index.js +40 -0
  76. package/dist/updates/index.js +3 -1
  77. package/package.json +14 -5
  78. package/dist/paraglide/messages/en.d.ts +0 -5
  79. package/dist/paraglide/messages/en.js +0 -14
  80. package/dist/paraglide/messages/pl.d.ts +0 -5
  81. package/dist/paraglide/messages/pl.js +0 -14
@@ -0,0 +1,210 @@
1
+ import { getCMS } from '../../../cms.js';
2
+ import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
3
+ import { getEntrySlugPath, getSlugFromEntryData, getEntryPath } from '../../fields/slugResolver.js';
4
+ function pickVersion(versions, status) {
5
+ const sorted = versions.slice().sort((a, b) => b.versionNumber - a.versionNumber);
6
+ const now = new Date();
7
+ switch (status) {
8
+ case 'published':
9
+ return sorted.find((v) => v.publishedAt != null && v.publishedAt <= now) ?? null;
10
+ case 'draft':
11
+ return sorted.find((v) => v.publishedAt == null) ?? null;
12
+ case 'scheduled':
13
+ return sorted.find((v) => v.publishedAt != null && v.publishedAt > now) ?? null;
14
+ }
15
+ }
16
+ async function buildEntry(dbEntry, version, ctx) {
17
+ const cms = getCMS();
18
+ let config;
19
+ try {
20
+ config = cms.getBySlug(dbEntry.slug);
21
+ }
22
+ catch {
23
+ // Orphaned entry — slug removed from config
24
+ return null;
25
+ }
26
+ const fields = getFieldsFromConfig(config);
27
+ // Lazy import to avoid static circular dep with resolveRelationFields → _populate
28
+ const { _populate } = await import('../../fields/populateEntry.js');
29
+ const populated = await _populate(version.data, fields, ctx);
30
+ const slugPath = getEntrySlugPath(dbEntry.slug);
31
+ const slugValue = getSlugFromEntryData(version.data, slugPath, ctx.locale);
32
+ const _url = slugValue ? getEntryPath(dbEntry.slug, slugValue) : undefined;
33
+ const result = {
34
+ _id: dbEntry.id,
35
+ _slug: dbEntry.slug,
36
+ _type: dbEntry.type,
37
+ _publishedAt: version.publishedAt,
38
+ _url,
39
+ ...populated
40
+ };
41
+ if (config.type === 'collection' && config.orderable) {
42
+ result._sortOrder = dbEntry.sortOrder;
43
+ }
44
+ return result;
45
+ }
46
+ /**
47
+ * Fetch a single populated Entry.
48
+ *
49
+ * - At least one of `id` or `collection` must be provided.
50
+ * - `locale` defaults to `cms.languages[0]`. Strict — returns `null` when no version exists in the requested locale.
51
+ * - `status` defaults to `'published'`. See {@link ResolveStatus}.
52
+ * - `populate` controls relation depth + per-field opt-out. See {@link PopulateConfig}.
53
+ *
54
+ * @public
55
+ */
56
+ export async function resolveEntry(opts) {
57
+ if (!opts.id && !opts.collection) {
58
+ throw new Error('resolveEntry: must provide id or collection');
59
+ }
60
+ const cms = getCMS();
61
+ const locale = opts.locale ?? cms.languages[0];
62
+ const status = opts.status ?? 'published';
63
+ const populate = opts.populate ?? {};
64
+ const maxDepth = populate.maxDepth ?? 5;
65
+ const dbOpts = {};
66
+ if (opts.id)
67
+ dbOpts.ids = [opts.id];
68
+ if (opts.collection)
69
+ dbOpts.slug = opts.collection;
70
+ const dbEntries = await cms.databaseAdapter.getEntries(dbOpts);
71
+ const dbEntry = dbEntries.find((e) => e.archivedAt == null) ?? null;
72
+ if (!dbEntry)
73
+ return null;
74
+ if (opts.collection && dbEntry.slug !== opts.collection)
75
+ return null;
76
+ const versions = await cms.databaseAdapter.getEntryVersions({
77
+ entryIds: [dbEntry.id],
78
+ lang: locale
79
+ });
80
+ const picked = pickVersion(versions, status);
81
+ if (!picked)
82
+ return null;
83
+ const ctx = {
84
+ locale,
85
+ status,
86
+ depth: 0,
87
+ maxDepth,
88
+ visited: new Set([dbEntry.id]),
89
+ populate,
90
+ entryId: dbEntry.id
91
+ };
92
+ return buildEntry(dbEntry, picked, ctx);
93
+ }
94
+ /**
95
+ * Fetch a list of populated Entries from a collection (or singleton with multiple instances).
96
+ *
97
+ * - `collection` is required.
98
+ * - `locale` strict (excludes entries without a version in requested locale).
99
+ * - `filter.{dataValues, dataLike, dataILikeOr}` map onto adapter `getEntryVersions` filters.
100
+ * - `limit`/`offset` applied after status pick.
101
+ *
102
+ * @public
103
+ */
104
+ export async function resolveEntries(opts) {
105
+ if (!opts.collection) {
106
+ throw new Error('resolveEntries: collection is required');
107
+ }
108
+ const cms = getCMS();
109
+ const locale = opts.locale ?? cms.languages[0];
110
+ const status = opts.status ?? 'published';
111
+ const populate = opts.populate ?? {};
112
+ const maxDepth = populate.maxDepth ?? 5;
113
+ const dbEntries = await cms.databaseAdapter.getEntries({
114
+ ids: opts.ids,
115
+ slug: opts.collection,
116
+ orderBy: opts.orderBy
117
+ });
118
+ if (dbEntries.length === 0)
119
+ return [];
120
+ const filtered = dbEntries.filter((e) => e.archivedAt == null);
121
+ if (filtered.length === 0)
122
+ return [];
123
+ const entryIds = filtered.map((e) => e.id);
124
+ const allVersions = await cms.databaseAdapter.getEntryVersions({
125
+ entryIds,
126
+ lang: locale,
127
+ dataValues: opts.filter?.dataValues,
128
+ dataLike: opts.filter?.dataLike,
129
+ dataILikeOr: opts.filter?.dataILikeOr
130
+ });
131
+ const versionsByEntry = new Map();
132
+ for (const v of allVersions) {
133
+ const arr = versionsByEntry.get(v.entryId) ?? [];
134
+ arr.push(v);
135
+ versionsByEntry.set(v.entryId, arr);
136
+ }
137
+ const order = new Map(filtered.map((e, i) => [e.id, i]));
138
+ const picks = [];
139
+ for (const dbEntry of filtered) {
140
+ const vs = versionsByEntry.get(dbEntry.id) ?? [];
141
+ const picked = pickVersion(vs, status);
142
+ if (picked)
143
+ picks.push({ version: picked, dbEntry });
144
+ }
145
+ picks.sort((a, b) => (order.get(a.dbEntry.id) ?? 0) - (order.get(b.dbEntry.id) ?? 0));
146
+ const built = await Promise.all(picks.map(async ({ version, dbEntry }) => {
147
+ const ctx = {
148
+ locale,
149
+ status,
150
+ depth: 0,
151
+ maxDepth,
152
+ visited: new Set([dbEntry.id]),
153
+ populate,
154
+ entryId: dbEntry.id
155
+ };
156
+ try {
157
+ return await buildEntry(dbEntry, version, ctx);
158
+ }
159
+ catch (error) {
160
+ console.error(`[CMS] Failed to populate entry ${dbEntry.id} (${dbEntry.slug}):`, error);
161
+ return null;
162
+ }
163
+ }));
164
+ let results = built.filter((e) => e !== null);
165
+ if (opts.offset)
166
+ results = results.slice(opts.offset);
167
+ if (opts.limit)
168
+ results = results.slice(0, opts.limit);
169
+ return results;
170
+ }
171
+ /**
172
+ * Count entries matching the same filters as `resolveEntries`, without populating.
173
+ * @public
174
+ */
175
+ export async function countEntries(opts) {
176
+ if (!opts.collection) {
177
+ throw new Error('countEntries: collection is required');
178
+ }
179
+ const cms = getCMS();
180
+ const locale = opts.locale ?? cms.languages[0];
181
+ const status = opts.status ?? 'published';
182
+ const dbEntries = await cms.databaseAdapter.getEntries({
183
+ ids: opts.ids,
184
+ slug: opts.collection
185
+ });
186
+ const filtered = dbEntries.filter((e) => e.archivedAt == null);
187
+ if (filtered.length === 0)
188
+ return 0;
189
+ const entryIds = filtered.map((e) => e.id);
190
+ const allVersions = await cms.databaseAdapter.getEntryVersions({
191
+ entryIds,
192
+ lang: locale,
193
+ dataValues: opts.filter?.dataValues,
194
+ dataLike: opts.filter?.dataLike,
195
+ dataILikeOr: opts.filter?.dataILikeOr
196
+ });
197
+ const versionsByEntry = new Map();
198
+ for (const v of allVersions) {
199
+ const arr = versionsByEntry.get(v.entryId) ?? [];
200
+ arr.push(v);
201
+ versionsByEntry.set(v.entryId, arr);
202
+ }
203
+ let count = 0;
204
+ for (const dbEntry of filtered) {
205
+ const vs = versionsByEntry.get(dbEntry.id) ?? [];
206
+ if (pickVersion(vs, status))
207
+ count++;
208
+ }
209
+ return count;
210
+ }
@@ -2,7 +2,7 @@ import { getCMS } from '../../../cms.js';
2
2
  import { generateZodSchemaFromFields } from '../../../fields/fieldSchemaToTs.js';
3
3
  import { getFieldsFromConfig } from '../../../fields/layoutUtils.js';
4
4
  import z from 'zod';
5
- import { getDbEntryOrThrow, getDbEntryVersionOrThrow, getDbEntryVersions } from './get.js';
5
+ import { _getDbEntryOrThrow as getDbEntryOrThrow, _getDbEntryVersionOrThrow as getDbEntryVersionOrThrow, _getDbEntryVersions as getDbEntryVersions } from './get.js';
6
6
  import { createEntryVersion } from './create.js';
7
7
  export const updateEntrySchema = z.object({
8
8
  archivedAt: z.date().nullable().optional(),
@@ -1,3 +1,11 @@
1
1
  import type { EntryData, PopulatedEntryData } from '../../../types/entries.js';
2
2
  import type { Field } from '../../../types/fields.js';
3
- export declare function populateEntryData(data: EntryData, fields: Field[], language: string, entryId?: string): Promise<PopulatedEntryData>;
3
+ import type { PopulateCtx } from '../entries/operations/resolveEntry.js';
4
+ /**
5
+ * Internal populate chain: relations → urls → media → richtext → custom → shop → typography orphans.
6
+ * Carries `ctx` (locale, status, depth, visited, populate config, current entryId) so every nested
7
+ * field resolver can cascade locale + recursion guards consistently.
8
+ *
9
+ * Not part of the public API. Userland uses `resolveEntry`/`resolveEntries`.
10
+ */
11
+ export declare function _populate(data: EntryData, fields: Field[], ctx: PopulateCtx): Promise<PopulatedEntryData>;
@@ -5,11 +5,8 @@ import { resolveUrlFields } from './resolveUrlFields.js';
5
5
  import { resolveTypographyOrphans } from './resolveTypographyOrphans.js';
6
6
  import { getCMS } from '../../cms.js';
7
7
  import { resolveShopFields } from '../../../shop/server/populate.js';
8
- async function resolveCustomFields(data, fields) {
9
- // Check if any custom fields exist before accessing CMS
10
- const hasCustom = fields.some((f) => f.type === 'custom' ||
11
- f.type === 'object' ||
12
- f.type === 'blocks');
8
+ async function resolveCustomFields(data, fields, ctx) {
9
+ const hasCustom = fields.some((f) => f.type === 'custom' || f.type === 'object' || f.type === 'blocks');
13
10
  if (!hasCustom)
14
11
  return data;
15
12
  let cms;
@@ -26,13 +23,14 @@ async function resolveCustomFields(data, fields) {
26
23
  case 'custom': {
27
24
  const def = cms.customFields.get(field.fieldType);
28
25
  if (def?.populateResolver && val != null) {
29
- result[field.slug] = await def.populateResolver(val, field);
26
+ // Plugin populateResolver receives ctx as 3rd arg (breaking in 0.17.0; plugin API @experimental)
27
+ result[field.slug] = await def.populateResolver(val, field, ctx);
30
28
  }
31
29
  break;
32
30
  }
33
31
  case 'object':
34
32
  if (val && typeof val === 'object') {
35
- result[field.slug] = await resolveCustomFields(val, field.fields);
33
+ result[field.slug] = await resolveCustomFields(val, field.fields, ctx);
36
34
  }
37
35
  break;
38
36
  case 'blocks':
@@ -40,7 +38,7 @@ async function resolveCustomFields(data, fields) {
40
38
  result[field.slug] = await Promise.all(val.map(async (item) => {
41
39
  const blockDef = field.of.find((d) => d.slug === item._slug);
42
40
  if (blockDef) {
43
- return await resolveCustomFields(item, blockDef.fields);
41
+ return await resolveCustomFields(item, blockDef.fields, ctx);
44
42
  }
45
43
  return item;
46
44
  }));
@@ -50,14 +48,20 @@ async function resolveCustomFields(data, fields) {
50
48
  }
51
49
  return result;
52
50
  }
53
- export async function populateEntryData(data, fields, language, entryId) {
54
- let populatedData = await resolveRelationFields(data, fields, language);
55
- populatedData = await resolveUrlFields(populatedData, fields, language);
56
- populatedData = await resolveMediaFields(populatedData, fields);
57
- populatedData = await resolveRichtextLinks(populatedData, fields, language);
58
- populatedData = (await resolveCustomFields(populatedData, fields));
59
- populatedData = (await resolveShopFields(populatedData, fields, entryId));
60
- // Typography orphan fix — enabled by default, opt-out via config
51
+ /**
52
+ * Internal populate chain: relations → urls → media → richtext → custom → shop → typography orphans.
53
+ * Carries `ctx` (locale, status, depth, visited, populate config, current entryId) so every nested
54
+ * field resolver can cascade locale + recursion guards consistently.
55
+ *
56
+ * Not part of the public API. Userland uses `resolveEntry`/`resolveEntries`.
57
+ */
58
+ export async function _populate(data, fields, ctx) {
59
+ let populated = await resolveRelationFields(data, fields, ctx);
60
+ populated = await resolveUrlFields(populated, fields, ctx);
61
+ populated = await resolveMediaFields(populated, fields);
62
+ populated = await resolveRichtextLinks(populated, fields, ctx);
63
+ populated = (await resolveCustomFields(populated, fields, ctx));
64
+ populated = (await resolveShopFields(populated, fields, ctx));
61
65
  let fixOrphans = true;
62
66
  try {
63
67
  fixOrphans = getCMS().typographyConfig.fixOrphans !== false;
@@ -66,7 +70,7 @@ export async function populateEntryData(data, fields, language, entryId) {
66
70
  // CMS not initialized — keep default
67
71
  }
68
72
  if (fixOrphans) {
69
- populatedData = resolveTypographyOrphans(populatedData, fields);
73
+ populated = resolveTypographyOrphans(populated, fields);
70
74
  }
71
- return populatedData;
75
+ return populated;
72
76
  }
@@ -1,3 +1,4 @@
1
1
  import type { EntryData, PopulatedEntryData } from '../../../types/entries.js';
2
2
  import type { Field } from '../../../types/fields.js';
3
- export declare function resolveRelationFields(data: EntryData, fields: Field[], language: string): Promise<PopulatedEntryData>;
3
+ import type { PopulateCtx } from '../entries/operations/resolveEntry.js';
4
+ export declare function resolveRelationFields(data: EntryData, fields: Field[], ctx: PopulateCtx): Promise<PopulatedEntryData>;
@@ -1,53 +1,72 @@
1
1
  import { walkInlineBlockNodes, cloneDoc } from '../../../admin/components/tiptap/structured-content-utils.js';
2
- import z from 'zod';
3
- export async function resolveRelationFields(data, fields, language) {
2
+ import { getCMS } from '../../cms.js';
3
+ import { getFieldsFromConfig } from '../../fields/layoutUtils.js';
4
+ import { getEntrySlugPath, getSlugFromEntryData, getEntryPath } from './slugResolver.js';
5
+ function pickVersion(versions, status) {
6
+ const sorted = versions.slice().sort((a, b) => b.versionNumber - a.versionNumber);
7
+ const now = new Date();
8
+ switch (status) {
9
+ case 'published':
10
+ return sorted.find((v) => v.publishedAt != null && v.publishedAt <= now) ?? null;
11
+ case 'draft':
12
+ return sorted.find((v) => v.publishedAt == null) ?? null;
13
+ case 'scheduled':
14
+ return sorted.find((v) => v.publishedAt != null && v.publishedAt > now) ?? null;
15
+ }
16
+ }
17
+ export async function resolveRelationFields(data, fields, ctx) {
4
18
  const entriesIds = [];
19
+ const optedOutFields = new Set();
20
+ // Collect ids skipping fields opted out at top level (raw passthrough).
5
21
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
- const collectIds = (value, fields) => {
22
+ const collectIds = (value, fields, topLevel) => {
7
23
  for (const field of fields) {
8
24
  const val = value?.[field.slug];
9
25
  if (val == null)
10
26
  continue;
27
+ if (topLevel && ctx.populate.fields?.[field.slug] === false) {
28
+ optedOutFields.add(field.slug);
29
+ continue;
30
+ }
11
31
  switch (field.type) {
12
32
  case 'relation': {
13
33
  if (field.multiple && Array.isArray(val)) {
14
- val.forEach((id) => {
15
- if (z.string().uuid().safeParse(id).success) {
34
+ for (const id of val) {
35
+ if (typeof id === 'string')
16
36
  entriesIds.push(id);
17
- }
18
- });
37
+ }
19
38
  }
20
39
  else if (typeof val === 'string') {
21
- if (z.string().uuid().safeParse(val).success) {
22
- entriesIds.push(val);
23
- }
40
+ entriesIds.push(val);
24
41
  }
25
42
  break;
26
43
  }
27
44
  case 'object':
28
- collectIds(val, field.fields);
45
+ collectIds(val, field.fields, false);
29
46
  break;
30
47
  case 'blocks':
31
48
  if (Array.isArray(val)) {
32
49
  val.forEach((item) => {
33
50
  const objectDef = field.of.find((objDef) => objDef.slug === item._slug);
34
51
  if (objectDef) {
35
- collectIds(item, objectDef.fields);
52
+ collectIds(item, objectDef.fields, false);
36
53
  }
37
54
  });
38
55
  }
39
56
  break;
40
57
  case 'content': {
41
58
  const cf = field;
42
- // Content is now a single doc, not Record<lang, doc>
43
- if (val && typeof val === 'object' && val.type === 'doc' && cf.inlineBlocks?.length) {
59
+ if (val &&
60
+ typeof val === 'object' &&
61
+ val.type === 'doc' &&
62
+ cf.inlineBlocks?.length) {
44
63
  walkInlineBlockNodes(val, (node) => {
45
64
  const bd = node.attrs?.blockData;
46
65
  if (!bd || typeof bd !== 'object')
47
66
  return;
48
67
  const def = cf.inlineBlocks.find((b) => b.slug === node.attrs?.blockType);
49
68
  if (def)
50
- collectIds(bd, def.fields);
69
+ collectIds(bd, def.fields, false);
51
70
  });
52
71
  }
53
72
  break;
@@ -55,20 +74,97 @@ export async function resolveRelationFields(data, fields, language) {
55
74
  }
56
75
  }
57
76
  };
58
- collectIds(data, fields);
77
+ collectIds(data, fields, true);
59
78
  if (entriesIds.length === 0)
60
79
  return data;
61
- // Import getEntries dynamically to avoid circular dependency
62
- const { getEntries } = await import('../entries/operations/get.js');
63
- // Get fully populated entries
64
- const entries = await getEntries({
65
- ids: entriesIds,
66
- language
67
- });
68
- const entriesMap = Object.fromEntries(entries.map((e) => [e._id, e]));
80
+ // Filter: don't fetch already-visited (cycle) or beyond maxDepth — those stay raw IDs
81
+ const overDepth = ctx.depth >= ctx.maxDepth;
82
+ const fetchableIds = overDepth
83
+ ? []
84
+ : [...new Set(entriesIds)].filter((id) => !ctx.visited.has(id));
85
+ const cms = getCMS();
86
+ // entriesMap: id → Entry (populated) | null (fetched but missing version/missing entry).
87
+ // Absent key = id was NOT fetched (cycle, max depth, top-level opt-out) → falls back to raw id.
88
+ const entriesMap = {};
89
+ if (fetchableIds.length > 0) {
90
+ const dbEntries = await cms.databaseAdapter.getEntries({ ids: fetchableIds });
91
+ const aliveDbEntries = dbEntries.filter((e) => e.archivedAt == null);
92
+ const aliveIds = new Set(aliveDbEntries.map((e) => e.id));
93
+ // Strict: missing entry / archived → null in nested
94
+ for (const id of fetchableIds) {
95
+ if (!aliveIds.has(id))
96
+ entriesMap[id] = null;
97
+ }
98
+ if (aliveDbEntries.length > 0) {
99
+ const versions = await cms.databaseAdapter.getEntryVersions({
100
+ entryIds: aliveDbEntries.map((e) => e.id),
101
+ lang: ctx.locale
102
+ });
103
+ const versionsByEntry = new Map();
104
+ for (const v of versions) {
105
+ const arr = versionsByEntry.get(v.entryId) ?? [];
106
+ arr.push(v);
107
+ versionsByEntry.set(v.entryId, arr);
108
+ }
109
+ // Lazy import to avoid static circular dep with populateEntry → resolveRelationFields
110
+ const { _populate } = await import('./populateEntry.js');
111
+ await Promise.all(aliveDbEntries.map(async (dbEntry) => {
112
+ const vs = versionsByEntry.get(dbEntry.id) ?? [];
113
+ const picked = pickVersion(vs, ctx.status);
114
+ if (!picked) {
115
+ entriesMap[dbEntry.id] = null; // strict: missing version in locale → null
116
+ return;
117
+ }
118
+ let config;
119
+ try {
120
+ config = cms.getBySlug(dbEntry.slug);
121
+ }
122
+ catch {
123
+ entriesMap[dbEntry.id] = null;
124
+ return;
125
+ }
126
+ const nestedFields = getFieldsFromConfig(config);
127
+ const nestedCtx = {
128
+ locale: ctx.locale,
129
+ status: ctx.status,
130
+ depth: ctx.depth + 1,
131
+ maxDepth: ctx.maxDepth,
132
+ visited: new Set([...ctx.visited, dbEntry.id]),
133
+ populate: ctx.populate,
134
+ entryId: dbEntry.id
135
+ };
136
+ try {
137
+ const populated = await _populate(picked.data, nestedFields, nestedCtx);
138
+ const slugPath = getEntrySlugPath(dbEntry.slug);
139
+ const slugValue = getSlugFromEntryData(picked.data, slugPath, ctx.locale);
140
+ const _url = slugValue ? getEntryPath(dbEntry.slug, slugValue) : undefined;
141
+ entriesMap[dbEntry.id] = {
142
+ _id: dbEntry.id,
143
+ _slug: dbEntry.slug,
144
+ _type: dbEntry.type,
145
+ _publishedAt: picked.publishedAt,
146
+ _url,
147
+ ...(config.type === 'collection' && config.orderable
148
+ ? { _sortOrder: dbEntry.sortOrder }
149
+ : {}),
150
+ ...populated
151
+ };
152
+ }
153
+ catch (error) {
154
+ console.error(`[CMS] Failed to populate nested entry ${dbEntry.id} (${dbEntry.slug}):`, error);
155
+ }
156
+ }));
157
+ }
158
+ }
159
+ const resolveRefId = (id) => {
160
+ // Has key (even if value is null) → use mapped value (Entry | null)
161
+ if (Object.prototype.hasOwnProperty.call(entriesMap, id))
162
+ return entriesMap[id];
163
+ // Not fetched (cycle, max depth, top-level opt-out) → raw ID
164
+ return id;
165
+ };
69
166
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
- const resolveValues = (value, fields) => {
71
- // Start with a copy of all original data to preserve non-field properties
167
+ const resolveValues = (value, fields, topLevel) => {
72
168
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
169
  const result = { ...value };
74
170
  for (const field of fields) {
@@ -77,33 +173,43 @@ export async function resolveRelationFields(data, fields, language) {
77
173
  result[field.slug] = val;
78
174
  continue;
79
175
  }
176
+ // Per-field opt-out at top level → raw passthrough
177
+ if (topLevel && optedOutFields.has(field.slug)) {
178
+ result[field.slug] = val;
179
+ continue;
180
+ }
80
181
  switch (field.type) {
81
182
  case 'relation': {
82
183
  if (field.multiple && Array.isArray(val)) {
83
- result[field.slug] = val.map((id) => entriesMap[id] ?? null);
184
+ result[field.slug] = val.map((id) => typeof id === 'string' ? resolveRefId(id) : id);
185
+ }
186
+ else if (typeof val === 'string') {
187
+ result[field.slug] = resolveRefId(val);
84
188
  }
85
189
  else {
86
- result[field.slug] = entriesMap[val] ?? null;
190
+ result[field.slug] = val;
87
191
  }
88
192
  break;
89
193
  }
90
194
  case 'object':
91
- result[field.slug] = resolveValues(val, field.fields);
195
+ result[field.slug] = resolveValues(val, field.fields, false);
92
196
  break;
93
197
  case 'blocks':
94
198
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
95
199
  result[field.slug] = val.map((item) => {
96
200
  const objectDef = field.of.find((objDef) => objDef.slug === item._slug);
97
201
  if (objectDef) {
98
- return resolveValues(item, objectDef.fields);
202
+ return resolveValues(item, objectDef.fields, false);
99
203
  }
100
204
  return item;
101
205
  });
102
206
  break;
103
207
  case 'content': {
104
208
  const cf = field;
105
- // Content is now a single doc, not Record<lang, doc>
106
- if (val && typeof val === 'object' && val.type === 'doc' && cf.inlineBlocks?.length) {
209
+ if (val &&
210
+ typeof val === 'object' &&
211
+ val.type === 'doc' &&
212
+ cf.inlineBlocks?.length) {
107
213
  const cloned = cloneDoc(val);
108
214
  walkInlineBlockNodes(cloned, (node) => {
109
215
  const bd = node.attrs?.blockData;
@@ -112,7 +218,7 @@ export async function resolveRelationFields(data, fields, language) {
112
218
  const def = cf.inlineBlocks.find((b) => b.slug === node.attrs?.blockType);
113
219
  if (!def)
114
220
  return;
115
- node.attrs.blockData = resolveValues(bd, def.fields);
221
+ node.attrs.blockData = resolveValues(bd, def.fields, false);
116
222
  });
117
223
  result[field.slug] = cloned;
118
224
  }
@@ -124,5 +230,5 @@ export async function resolveRelationFields(data, fields, language) {
124
230
  }
125
231
  return result;
126
232
  };
127
- return resolveValues(data, fields);
233
+ return resolveValues(data, fields, true);
128
234
  }
@@ -1,3 +1,4 @@
1
1
  import type { EntryData, PopulatedEntryData } from '../../../types/entries.js';
2
2
  import type { Field } from '../../../types/fields.js';
3
- export declare function resolveRichtextLinks(data: EntryData, fields: Field[], language: string): Promise<PopulatedEntryData>;
3
+ import type { PopulateCtx } from '../entries/operations/resolveEntry.js';
4
+ export declare function resolveRichtextLinks(data: EntryData, fields: Field[], ctx: PopulateCtx): Promise<PopulatedEntryData>;
@@ -34,7 +34,8 @@ function resolveContentDoc(doc, slugMap) {
34
34
  });
35
35
  return cloned;
36
36
  }
37
- export async function resolveRichtextLinks(data, fields, language) {
37
+ export async function resolveRichtextLinks(data, fields, ctx) {
38
+ const language = ctx.locale;
38
39
  const entriesIds = [];
39
40
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
41
  const collectIds = (value, fields) => {
@@ -1,3 +1,4 @@
1
1
  import type { EntryData, PopulatedEntryData } from '../../../types/entries.js';
2
2
  import type { Field } from '../../../types/fields.js';
3
- export declare function resolveUrlFields(data: EntryData, fields: Field[], language?: string): Promise<PopulatedEntryData>;
3
+ import type { PopulateCtx } from '../entries/operations/resolveEntry.js';
4
+ export declare function resolveUrlFields(data: EntryData, fields: Field[], ctx: PopulateCtx): Promise<PopulatedEntryData>;
@@ -1,6 +1,6 @@
1
1
  import { urlFieldDataSchema, urlFieldDataWithRelationSchema } from '../../../schemas/field/url.js';
2
2
  import { walkInlineBlockNodes, cloneDoc } from '../../../admin/components/tiptap/structured-content-utils.js';
3
- import { getDbEntries, getDbEntryVersions } from '../entries/operations/get.js';
3
+ import { _getDbEntries, _getDbEntryVersions } from '../entries/operations/get.js';
4
4
  import { getEntrySlugPath, getSlugFromEntryData, getEntryPath } from './slugResolver.js';
5
5
  import { isExternalUrl, mergeRel } from '../../fields/urlUtils.js';
6
6
  const FLAT_KEY = '_flat';
@@ -34,7 +34,8 @@ function applyExternalAutoDetect(resolvedUrl, extras) {
34
34
  }
35
35
  }
36
36
  }
37
- export async function resolveUrlFields(data, fields, language) {
37
+ export async function resolveUrlFields(data, fields, ctx) {
38
+ const language = ctx.locale;
38
39
  const entriesIds = [];
39
40
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
41
  const collectIds = (value, fields) => {
@@ -102,12 +103,12 @@ export async function resolveUrlFields(data, fields, language) {
102
103
  collectIds(data, fields);
103
104
  const slugMap = {};
104
105
  if (entriesIds.length > 0) {
105
- // Use raw DB calls to avoid recursive populateEntryData → resolveUrlFields loop
106
- const dbEntries = await getDbEntries({ ids: entriesIds });
106
+ // Use raw DB calls to avoid recursive _populate → resolveUrlFields loop
107
+ const dbEntries = await _getDbEntries({ ids: entriesIds });
107
108
  const entryIds = dbEntries.map((e) => e.id);
108
109
  if (entryIds.length > 0) {
109
110
  // Get published versions for the target language
110
- const versions = await getDbEntryVersions({
111
+ const versions = await _getDbEntryVersions({
111
112
  entryIds,
112
113
  lang: language
113
114
  });