@uniweb/build 0.14.10 → 0.14.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.14.10",
3
+ "version": "0.14.12",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -63,9 +63,9 @@
63
63
  "@uniweb/content-writer": "0.2.5"
64
64
  },
65
65
  "optionalDependencies": {
66
- "@uniweb/runtime": "0.8.16",
67
66
  "@uniweb/content-reader": "1.1.12",
68
- "@uniweb/schemas": "0.2.2"
67
+ "@uniweb/schemas": "0.2.3",
68
+ "@uniweb/runtime": "0.8.17"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -1234,6 +1234,12 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1234
1234
  title: pageConfig.title || extractH1(hierarchicalSections[0]?.content) || prettifySlug(pageName),
1235
1235
  description: pageConfig.description || '',
1236
1236
  label: pageConfig.label || null, // Short label for navigation (defaults to title)
1237
+ // Localized URL slug overrides: { <locale>: <segment> }. Compiled into
1238
+ // config.i18n.routeTranslations by collectSiteContent, then stripped from
1239
+ // the page payload (build is authoritative; runtime hydrates the map).
1240
+ ...(pageConfig.slug && typeof pageConfig.slug === 'object' && !Array.isArray(pageConfig.slug)
1241
+ ? { slug: pageConfig.slug }
1242
+ : {}),
1237
1243
  lastModified: lastModified?.toISOString(),
1238
1244
 
1239
1245
  // Dynamic route metadata
@@ -2133,11 +2139,33 @@ export async function collectSiteContent(sitePath, options = {}) {
2133
2139
  const intelligenceConfig = await readYamlFile(join(sitePath, 'intelligence.yml'))
2134
2140
  const hasIntelligence = intelligenceConfig && Object.keys(intelligenceConfig).length > 0
2135
2141
 
2142
+ // Compile per-page `slug:` maps into config.i18n.routeTranslations
2143
+ // (canonical route → per-locale display route). Producer-only: the runtime
2144
+ // (@uniweb/core website.js) and the sitemap generator already consume this
2145
+ // map; nothing else produces it on the file-based lane. The default locale
2146
+ // keeps the canonical (folder-based) route. Strip the build-time `slug` from
2147
+ // pages afterward — the runtime hydrates the precomputed map, not per-page
2148
+ // slugs (see "Minimize Runtime Payload").
2149
+ const defaultLocale =
2150
+ siteConfig.defaultLanguage ||
2151
+ (Array.isArray(siteConfig.languages) ? siteConfig.languages[0] : null) ||
2152
+ 'en'
2153
+ const routeTranslations = buildRouteTranslations(pages, {
2154
+ defaultLocale,
2155
+ languages: Array.isArray(siteConfig.languages) ? siteConfig.languages : null,
2156
+ })
2157
+ for (const page of pages) {
2158
+ if (page.slug) delete page.slug
2159
+ }
2160
+
2136
2161
  return {
2137
2162
  config: {
2138
2163
  ...siteConfig,
2139
2164
  fetch: parseFetchConfig(siteConfig.fetch),
2140
2165
  ...(hasIntelligence && { intelligence: intelligenceConfig }),
2166
+ ...(routeTranslations
2167
+ ? { i18n: { ...(siteConfig.i18n || {}), routeTranslations } }
2168
+ : {}),
2141
2169
  },
2142
2170
  theme: {
2143
2171
  ...processedTheme,
@@ -2162,7 +2190,115 @@ export async function collectSiteContent(sitePath, options = {}) {
2162
2190
  // than the flattened collector output but reuses these primitives so
2163
2191
  // markdown→ProseMirror, ordering, and mode detection stay consistent with a
2164
2192
  // normal build. Additive: no existing behavior changes.
2193
+ /**
2194
+ * Whether a value is a usable single URL path segment (no slashes, no
2195
+ * whitespace). Localized slug segments must satisfy this to be applied.
2196
+ */
2197
+ function isValidSlugSegment(s) {
2198
+ return typeof s === 'string' && s.length > 0 && !s.includes('/') && !/\s/.test(s)
2199
+ }
2200
+
2201
+ /**
2202
+ * Compose the full localized display route for a canonical route in a locale,
2203
+ * substituting the localized segment for any ancestor (or self) that declares
2204
+ * one in `slugByRoute`. Falls back to the canonical segment where no valid
2205
+ * localized slug exists. E.g. with /blog→blogue and /blog/my-post→mon-article,
2206
+ * `/blog/my-post` (fr) → `/blogue/mon-article`.
2207
+ */
2208
+ function composeLocalizedRoute(canonicalRoute, locale, slugByRoute) {
2209
+ if (!canonicalRoute || canonicalRoute === '/') return canonicalRoute || '/'
2210
+ const segments = canonicalRoute.split('/').filter(Boolean)
2211
+ let cumulative = ''
2212
+ const out = []
2213
+ for (const seg of segments) {
2214
+ cumulative += `/${seg}`
2215
+ const localized = slugByRoute.get(cumulative)?.[locale]
2216
+ out.push(isValidSlugSegment(localized) ? localized : seg)
2217
+ }
2218
+ return '/' + out.join('/')
2219
+ }
2220
+
2221
+ /**
2222
+ * Build `config.i18n.routeTranslations` from per-page `slug:` maps.
2223
+ *
2224
+ * Author surface: a page declares `slug: { <locale>: <segment> }` in its
2225
+ * page.yml. The canonical route stays the folder name; for each non-default
2226
+ * locale the localized URL substitutes the declared segment(s). The runtime
2227
+ * (`@uniweb/core` website.js: translateRoute/getLocaleUrl/getPageHierarchy) and
2228
+ * the build sitemap already consume this map — this is its only producer on the
2229
+ * file-based lane. Children without their own slug inherit a localized ancestor
2230
+ * via the runtime's prefix-cascade, so only pages that declare a slug get an
2231
+ * explicit entry.
2232
+ *
2233
+ * Pure + non-mutating. Returns `{ [locale]: { [canonicalRoute]: displayRoute } }`,
2234
+ * or null when no page declares a usable localized slug.
2235
+ *
2236
+ * @param {Array} pages - Flat page list (each with canonical `route` + optional `slug`).
2237
+ * @param {Object} [opts]
2238
+ * @param {string} [opts.defaultLocale='en'] - Default locale; its routes are the
2239
+ * canonical keys, so default-locale slugs are skipped (renaming the default
2240
+ * URL away from the folder name is a separate, heavier concern).
2241
+ * @param {string[]|null} [opts.languages=null] - Declared site locales; when an
2242
+ * array, slug locales outside it are skipped with a warning. null = no filter.
2243
+ * @returns {Object|null}
2244
+ */
2245
+ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null } = {}) {
2246
+ if (!Array.isArray(pages)) return null
2247
+
2248
+ // Index canonical route → { locale: segment } for pages declaring a slug map.
2249
+ const slugByRoute = new Map()
2250
+ for (const page of pages) {
2251
+ const slug = page?.slug
2252
+ if (slug && typeof slug === 'object' && !Array.isArray(slug) && page.route) {
2253
+ slugByRoute.set(page.route, slug)
2254
+ }
2255
+ }
2256
+ if (slugByRoute.size === 0) return null
2257
+
2258
+ const langSet = Array.isArray(languages) ? new Set(languages) : null
2259
+ const result = {}
2260
+
2261
+ for (const [canonicalRoute, localeMap] of slugByRoute) {
2262
+ for (const [locale, segment] of Object.entries(localeMap)) {
2263
+ // Default locale keeps the canonical (folder-based) route.
2264
+ if (locale === defaultLocale) continue
2265
+ if (langSet && !langSet.has(locale)) {
2266
+ console.warn(
2267
+ `[content-collector] slug locale '${locale}' on '${canonicalRoute}' is not in site languages — skipping`
2268
+ )
2269
+ continue
2270
+ }
2271
+ if (!isValidSlugSegment(segment)) {
2272
+ console.warn(
2273
+ `[content-collector] invalid slug '${segment}' for '${canonicalRoute}' (${locale}) — must be a single URL-safe path segment`
2274
+ )
2275
+ continue
2276
+ }
2277
+ const display = composeLocalizedRoute(canonicalRoute, locale, slugByRoute)
2278
+ ;(result[locale] ||= {})[canonicalRoute] = display
2279
+ }
2280
+ }
2281
+
2282
+ // Warn on within-locale display collisions (two canonical routes → same URL).
2283
+ for (const [locale, map] of Object.entries(result)) {
2284
+ const seen = new Map()
2285
+ for (const [canon, disp] of Object.entries(map)) {
2286
+ const prev = seen.get(disp)
2287
+ if (prev) {
2288
+ console.warn(
2289
+ `[content-collector] localized route collision in '${locale}': '${canon}' and '${prev}' both map to '${disp}'`
2290
+ )
2291
+ } else {
2292
+ seen.set(disp, canon)
2293
+ }
2294
+ }
2295
+ }
2296
+
2297
+ return Object.keys(result).length > 0 ? result : null
2298
+ }
2299
+
2165
2300
  export {
2301
+ buildRouteTranslations,
2166
2302
  extractItemName,
2167
2303
  parseWildcardArray,
2168
2304
  applyWildcardOrder,
@@ -96,7 +96,12 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
96
96
  // path, flushed to locales/collections/{locale}.json by the caller.
97
97
  const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
98
98
  if (!field.localized) return doc
99
- return localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
99
+ const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
100
+ // localizeContentDoc returns a BARE doc when there are no target locales. A
101
+ // localized field MUST ride as a `{ lang: value }` map on the wire — the
102
+ // schema-driven projector drops a localized field whose value isn't a map — so
103
+ // wrap the source doc, consistent with localizeScalar (which always wraps).
104
+ return isLocalizedContent(localized) ? localized : { [sourceLocale]: localized }
100
105
  }
101
106
  if (field.localized) {
102
107
  // A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
@@ -143,33 +148,52 @@ export function collectionRecordsToEntities({
143
148
  if (!declaration || !declaration.name) {
144
149
  throw new Error('uwx/collections: a declaration with a name is required')
145
150
  }
146
- // The brief is the section marked `brief: true` (the sections-tree has no
147
- // schema-level `brief:` back-reference); its `fields` is a map keyed by name.
148
- const briefEntry = Object.entries(declaration.sections || {}).find(([, s]) => s && s.brief === true)
151
+ // A record (one source file) maps to the Model's SINGLE sections in declared
152
+ // order — the brief (the card) plus any sibling single sections, e.g. a body
153
+ // section like `article_body`. Multi-section Models are the norm for `@std/*`
154
+ // types; the markdown body lands in the designated content field WHEREVER it is
155
+ // declared (the brief, or a non-brief body section). `multi` sections (repeating
156
+ // items) can't be expressed by one flat record and are skipped. The brief is the
157
+ // section marked `brief: true` (the sections-tree has no schema-level back-ref).
158
+ const sectionEntries = Object.entries(declaration.sections || {})
159
+ const briefEntry = sectionEntries.find(([, s]) => s && s.brief === true)
149
160
  const briefName = briefEntry?.[0]
150
- const brief = briefEntry?.[1]
151
- if (!brief) {
152
- throw new Error(
153
- `uwx/collections: Model ${declaration.name} has no brief section ` +
154
- 'v1 maps flat records to the brief single section only'
155
- )
161
+ if (!briefName) {
162
+ throw new Error(`uwx/collections: Model ${declaration.name} has no brief section`)
163
+ }
164
+ // The single sections a flat record can populate (the brief + sibling singles),
165
+ // and a global field→section map across them for distributing frontmatter and
166
+ // flagging unknown keys. Field names are unique across a Model's sections (the
167
+ // declaration's own convention); a collision keeps the first occurrence.
168
+ const recordSections = sectionEntries.filter(([, s]) => s && s.multiple !== true)
169
+ const fieldByKey = new Map()
170
+ for (const [, sec] of recordSections) {
171
+ for (const [key, field] of Object.entries(sec.fields || {})) {
172
+ if (!fieldByKey.has(key)) fieldByKey.set(key, field)
173
+ }
156
174
  }
157
- const briefFields = brief.fields || {}
158
- const fieldByKey = new Map(Object.entries(briefFields))
159
175
 
160
- // The markdown body of a `.md` collection record is the value of the brief's
161
- // CONTENT field — a markup `text` field (raw source string) or a `format:
162
- // prosemirror` json field (docs/reference/entity-content.md). One content field is
163
- // the body target; zero means a `.md` body has nowhere to go (warn per record).
164
- const contentEntries = Object.entries(briefFields).filter(([, f]) => isContentBodyField(f))
165
- const bodyFieldKey = contentEntries[0]?.[0] || null
176
+ // The markdown body of a `.md` record is the value of the Model's CONTENT body
177
+ // field — a markup `text` field (raw source string) or a `format: prosemirror`
178
+ // json field (docs/reference/entity-content.md) wherever it is declared (the
179
+ // brief, or a non-brief body section like `article_body.content`). encodeFieldValue
180
+ // does the md→ProseMirror conversion per field kind. One content field is the body
181
+ // target; zero means a `.md` body has nowhere to go (warn per record).
182
+ const contentMatches = []
183
+ for (const [secName, sec] of recordSections) {
184
+ for (const [key, field] of Object.entries(sec.fields || {})) {
185
+ if (isContentBodyField(field)) contentMatches.push({ secName, key })
186
+ }
187
+ }
188
+ const bodyTarget = contentMatches[0] || null
166
189
 
167
190
  const entities = []
168
191
  const warnings = []
169
- if (contentEntries.length > 1) {
192
+ if (contentMatches.length > 1) {
170
193
  warnings.push(
171
- `${collectionName}: ${declaration.name}.${briefName} has more than one content ` +
172
- `(markdown / html / prosemirror) field — the markdown body maps to "${bodyFieldKey}"`
194
+ `${collectionName}: ${declaration.name} has more than one content ` +
195
+ `(markdown / html / prosemirror) field — the markdown body maps to ` +
196
+ `"${bodyTarget.secName}.${bodyTarget.key}"`
173
197
  )
174
198
  }
175
199
  for (const record of records || []) {
@@ -185,43 +209,53 @@ export function collectionRecordsToEntities({
185
209
  const uuid = record.$uuid || null
186
210
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
187
211
 
188
- // Brief section data in schema-declared field order (the wire's canonical
189
- // order). An absent field is simply omitted an incomplete entity is a
190
- // valid stored state; the foundation copes at render time. The markdown body
191
- // fills the content body field unless the frontmatter already set it explicitly.
192
- const data = {}
193
- for (const [key, field] of Object.entries(briefFields)) {
194
- let value = record[key]
195
- if (value === undefined && key === bodyFieldKey && hasBody) value = record.$body
196
- if (value === undefined) continue
197
- const encoded = encodeFieldValue(value, field, sourceLocale, translations)
198
- if (encoded !== undefined) data[key] = encoded
212
+ // Per-section data in schema-declared field order (the wire's canonical order).
213
+ // Frontmatter keys land in their declaring section; the markdown body fills the
214
+ // designated content field (in whatever section declares it) unless frontmatter
215
+ // already set it explicitly. An absent field is simply omitted an incomplete
216
+ // entity is a valid stored state; the foundation copes at render time.
217
+ const sectionData = {}
218
+ for (const [secName, sec] of recordSections) {
219
+ const data = {}
220
+ for (const [key, field] of Object.entries(sec.fields || {})) {
221
+ let value = record[key]
222
+ if (value === undefined && bodyTarget && secName === bodyTarget.secName && key === bodyTarget.key && hasBody) {
223
+ value = record.$body
224
+ }
225
+ if (value === undefined) continue
226
+ const encoded = encodeFieldValue(value, field, sourceLocale, translations)
227
+ if (encoded !== undefined) data[key] = encoded
228
+ }
229
+ if (Object.keys(data).length) sectionData[secName] = data
199
230
  }
200
- // Warn for author keys that aren't on the Model. A real unknown key means the
231
+ // Warn for author keys not on ANY record section. A real unknown key means the
201
232
  // frontmatter doesn't match the collection's data schema — that SHOULD warn
202
233
  // (only identity/transport keys in SKIP_KEYS are silent).
203
234
  for (const key of Object.keys(record)) {
204
235
  if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
205
236
  warnings.push(
206
237
  `${collectionName}/${slug}: field "${key}" is not on ` +
207
- `${declaration.name}.${briefName} — not synced`
238
+ `${declaration.name} — not synced`
208
239
  )
209
240
  }
210
- if (hasBody && !bodyFieldKey) {
241
+ if (hasBody && !bodyTarget) {
211
242
  warnings.push(
212
243
  `${collectionName}/${slug}: markdown body present but ` +
213
- `${declaration.name}.${briefName} has no content body field — body not synced`
244
+ `${declaration.name} has no content body field — body not synced`
214
245
  )
215
246
  }
216
247
 
217
- // The `$`-document body, in canonical key order: `$uuid?`, `$id`, `$model`,
218
- // then the brief section. `$owner`/`$unit`/`$meta` are omitted — the backend
219
- // binds owner + unit on its side.
248
+ // The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, then each
249
+ // populated section in declared order (the brief always present as the card).
250
+ // `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on its side.
220
251
  const document = {}
221
252
  if (uuid) document.$uuid = uuid
222
253
  document.$id = id
223
254
  document.$model = declaration.name
224
- document[briefName] = data
255
+ document[briefName] = sectionData[briefName] || {}
256
+ for (const [secName] of recordSections) {
257
+ if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
258
+ }
225
259
 
226
260
  entities.push({
227
261
  id,
package/src/uwx/folder.js CHANGED
@@ -2,12 +2,15 @@
2
2
  //
3
3
  // A site sync carries the site-content entity, the collection-record entities, and
4
4
  // — when the site has collections — ONE `@uniweb/folder` entity describing how those
5
- // records are organized. The folder holds REFERENCES, never content:
6
- //
7
- // - a LEAF references one record entity, by `$ref: "<collection>/<slug>"` when the
8
- // record has no `$uuid` yet (resolved within this payload), or `entry: <uuid>`
9
- // when it was minted on an earlier sync (back-filled into the record's file).
10
- // - a BRANCH is a sub-folder (`path_segment` + nested `entries`).
5
+ // records are organized. `@uniweb/folder` is a normal section-keyed entity (the
6
+ // "structured content all the way down" invariant): its document is `{ info?, contents }`.
7
+ // - `contents` is the self-nesting tree (an array), nesting via `$children` the
8
+ // same mechanism site-content pages/sections use. Each node holds REFERENCES,
9
+ // never content:
10
+ // - a LEAF references one record entity: `{ kind: 'ref', path_segment, ... }`
11
+ // with `entry: <uuid>` once the record was minted (back-filled into its file),
12
+ // or `$ref: "<collection>/<slug>"` while brand-new (resolved within this payload).
13
+ // - a BRANCH is a sub-folder: `{ kind: 'branch', path_segment, name?, $children }`.
11
14
  //
12
15
  // Organization comes from `collections.yml::folders` (a VIRTUAL tree, decoupled from
13
16
  // the on-disk layout) when present; otherwise the default is one branch per
@@ -20,10 +23,18 @@
20
23
  export const FOLDER_MODEL_NAME = '@uniweb/folder'
21
24
  export const FOLDER_ENTITY_KEY = '@folder'
22
25
 
23
- // One record → a `ref` leaf. Known uuid `entry`; brand-new `$ref` handle.
26
+ // One record → a `ref` leaf. The folder's `contents` field is polymorphic (it can
27
+ // reference any Model), so the ref uses the entity_ref OPEN form `{ model, entity }`
28
+ // — not a bare uuid (a bare uuid is only valid when the field pins a single model).
29
+ // Known uuid → `entry: { model, entity: <uuid> }`; brand-new → `$ref` handle
30
+ // (resolved within this payload to the minted entity).
31
+ //
32
+ // TODO: the sync lane is uuid-keyed, so `model` should be the resolved Model UUID;
33
+ // it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
34
+ // resolution (a registry data-schema read) as a follow-up.
24
35
  function refLeaf(entity) {
25
36
  const leaf = { kind: 'ref', path_segment: entity.slug }
26
- if (entity.uuid) leaf.entry = entity.uuid
37
+ if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
27
38
  else leaf.$ref = entity.id // the `<collection>/<slug>` payload-local handle
28
39
  return leaf
29
40
  }
@@ -40,37 +51,37 @@ function groupByCollection(recordEntities) {
40
51
  }
41
52
 
42
53
  // Default org: one branch per collection (declaration order), records as leaves.
43
- function defaultEntries(groups) {
44
- const entries = []
54
+ function defaultContents(groups) {
55
+ const contents = []
45
56
  for (const [collection, records] of groups) {
46
- entries.push({
57
+ contents.push({
47
58
  kind: 'branch',
48
59
  path_segment: collection,
49
- entries: records.map(refLeaf),
60
+ $children: records.map(refLeaf),
50
61
  })
51
62
  }
52
- return entries
63
+ return contents
53
64
  }
54
65
 
55
66
  // Virtual org from `collections.yml::folders`. Each node is either a collection
56
67
  // NAME (string — expands to that collection's record leaves under a branch named
57
68
  // after it) or a `{ segment, label?, entries: [...] }` branch (recursively).
58
- function virtualEntries(folders, groups) {
69
+ function virtualContents(folders, groups) {
59
70
  const buildNode = (node) => {
60
71
  if (typeof node === 'string') {
61
72
  const records = groups.get(node) || []
62
73
  return {
63
74
  kind: 'branch',
64
75
  path_segment: node,
65
- entries: records.map(refLeaf),
76
+ $children: records.map(refLeaf),
66
77
  }
67
78
  }
68
79
  if (node && typeof node === 'object') {
69
80
  const segment = node.segment ?? node.path_segment
70
81
  const branch = { kind: 'branch', path_segment: segment }
71
- if (node.label !== undefined) branch.label = node.label
82
+ if (node.label !== undefined) branch.name = node.label
72
83
  const children = Array.isArray(node.entries) ? node.entries : []
73
- branch.entries = children.flatMap((child) => {
84
+ branch.$children = children.flatMap((child) => {
74
85
  // A bare collection name inside `entries:` expands to its leaves directly
75
86
  // (so the records sit in THIS branch, not a nested one).
76
87
  if (typeof child === 'string' && groups.has(child)) {
@@ -100,12 +111,12 @@ function virtualEntries(folders, groups) {
100
111
  export function buildFolderEntity({ recordEntities, folders = null }) {
101
112
  if (!Array.isArray(recordEntities) || recordEntities.length === 0) return null
102
113
  const groups = groupByCollection(recordEntities)
103
- const entries = folders ? virtualEntries(folders, groups) : defaultEntries(groups)
114
+ const contents = folders ? virtualContents(folders, groups) : defaultContents(groups)
104
115
 
105
116
  const document = {
106
117
  $id: FOLDER_ENTITY_KEY,
107
118
  $model: FOLDER_MODEL_NAME,
108
- entries,
119
+ contents,
109
120
  }
110
121
 
111
122
  return {
package/src/uwx/site.js CHANGED
@@ -132,7 +132,12 @@ function mapSectionData(section) {
132
132
  function buildPageData(config, ctx) {
133
133
  const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations } =
134
134
  ctx
135
- const data = { slug, mode } // both required by the entity type
135
+ // The page `slug` is the localized route source a `{lang: slug}` map (the
136
+ // site-content Model declares it localized; greenlit 2026-06-13). A single-locale
137
+ // site emits one entry (`{ en: "home" }`); per-locale slug overrides for
138
+ // multi-locale localized routes (from i18n.routeTranslations) are follow-on
139
+ // producer work. `mode` is the plain delivery mode.
140
+ const data = { slug: { [sourceLocale]: slug }, mode } // both required by the entity type
136
141
  setIf(data, 'stable_id', config.id)
137
142
  setIf(data, 'title', localizeScalar(config.title, sourceLocale, translations))
138
143
  setIf(data, 'description', localizeScalar(config.description, sourceLocale, translations))
@@ -148,11 +153,25 @@ function buildPageData(config, ctx) {
148
153
  setIf(data, 'rewrite', config.rewrite)
149
154
  setIf(data, 'layout', config.layout)
150
155
  setIf(data, 'seo', config.seo)
151
- const fetch =
156
+ let fetch =
152
157
  config.fetch ??
153
158
  (config.data
154
159
  ? { collection: Array.isArray(config.data) ? config.data[0] : config.data }
155
160
  : undefined)
161
+ // Resolve the build-time `collection:` shorthand to the runtime-fetchable
162
+ // `path: /data/<name>.json` (the static convention the default-fetcher uses).
163
+ // A shell/backend-hosted site renders client-side with NO prerender, so the
164
+ // runtime fetches this decl directly — and `collection:` is build-time-only, so
165
+ // it would never resolve at render (the static build resolves it the same way
166
+ // in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
167
+ // at `<base>/data/<name>.json`.
168
+ if (fetch && typeof fetch.collection === 'string') {
169
+ const { collection, ...rest } = fetch
170
+ // `schema` (the collection name) is BOTH the content.data key and part of the
171
+ // dataStore cache key (deriveCacheKey hashes {path,url,schema,…}; `collection`
172
+ // is ignored). Mirrors the static build's parseFetchConfig resolution.
173
+ fetch = { path: `/data/${collection}.json`, schema: collection, ...rest }
174
+ }
156
175
  setIf(data, 'fetch', fetch)
157
176
  if (isDynamic) {
158
177
  data.is_dynamic = true