@uniweb/core 0.5.14 → 0.5.15

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/core",
3
- "version": "0.5.14",
3
+ "version": "0.5.15",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
@@ -30,8 +30,8 @@
30
30
  "jest": "^29.7.0"
31
31
  },
32
32
  "dependencies": {
33
- "@uniweb/semantic-parser": "1.1.7",
34
- "@uniweb/theming": "0.1.2"
33
+ "@uniweb/theming": "0.1.2",
34
+ "@uniweb/semantic-parser": "1.1.7"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
package/src/block.js CHANGED
@@ -148,6 +148,45 @@ export default class Block {
148
148
  Object.seal(this)
149
149
  }
150
150
 
151
+ /**
152
+ * The resolved URL path of the current page (e.g. /blog or /blog/1).
153
+ * Use this to build child links: `${block.path}/${item.id}`
154
+ *
155
+ * Uses page.getNavRoute() which returns the path with its leading slash intact
156
+ * and normalizes /index suffixes to the folder route. getNormalizedRoute() is
157
+ * intentionally NOT used here — it strips the leading slash (designed for route
158
+ * comparison), which would produce relative paths and cause double-segment URLs.
159
+ *
160
+ * Works in all scenarios:
161
+ * - Static pages: page.route (/blog)
162
+ * - Dynamic pages: concrete route (/blog/1), set by _createDynamicPage
163
+ * - Editor: set by the editor to the page being previewed
164
+ * - SSR/prerender: set from page data at build time
165
+ */
166
+ get path() {
167
+ return this.page.getNavRoute()
168
+ }
169
+
170
+ /**
171
+ * The parent page's URL path, one level up from the current page.
172
+ * Use this for "Back" links in detail pages: /blog/1 → /blog
173
+ *
174
+ * For dynamic pages uses templateRoute (/blog/:id → /blog) rather than
175
+ * the concrete route, so it correctly points to the index page regardless
176
+ * of the param value.
177
+ */
178
+ get parentPath() {
179
+ // Dynamic page: derive parent from the route template, not the concrete URL
180
+ // e.g. templateRoute = '/blog/:id' → parent = '/blog'
181
+ if (this.dynamicContext?.templateRoute) {
182
+ const tmpl = this.dynamicContext.templateRoute
183
+ return tmpl.split('/').slice(0, -1).join('/') || '/'
184
+ }
185
+ // Static page: go one level up from the normalized route
186
+ const p = this.path
187
+ return p.split('/').slice(0, -1).join('/') || '/'
188
+ }
189
+
151
190
  /**
152
191
  * Parse content into structured format using semantic-parser
153
192
  * Supports multiple content formats:
package/src/datastore.js CHANGED
@@ -25,10 +25,23 @@ export default class DataStore {
25
25
  this._cache = new Map()
26
26
  this._inflight = new Map()
27
27
  this._fetcher = null
28
+ this._transforms = new Map()
29
+ this._listeners = new Set()
28
30
 
29
31
  Object.seal(this)
30
32
  }
31
33
 
34
+ /**
35
+ * Subscribe to data updates. Returns an unsubscribe function.
36
+ * Called by PageRenderer to re-render when dynamic page data arrives.
37
+ * @param {Function} fn - Called whenever new data is stored
38
+ * @returns {Function} unsubscribe
39
+ */
40
+ onUpdate(fn) {
41
+ this._listeners.add(fn)
42
+ return () => this._listeners.delete(fn)
43
+ }
44
+
32
45
  /**
33
46
  * Register the fetcher function (called by runtime at startup).
34
47
  * @param {Function} fn - (config) => Promise<{ data, error? }>
@@ -37,6 +50,16 @@ export default class DataStore {
37
50
  this._fetcher = fn
38
51
  }
39
52
 
53
+ /**
54
+ * Register a named transform function.
55
+ * Named transforms are applied after the fetcher returns, before caching.
56
+ * @param {string} name - Transform name (e.g. 'profiles')
57
+ * @param {Function} fn - (data, config) => transformedData
58
+ */
59
+ registerTransform(name, fn) {
60
+ this._transforms.set(name, fn)
61
+ }
62
+
40
63
  /**
41
64
  * Check whether data for this config is cached.
42
65
  * @param {Object} config - Fetch config
@@ -63,6 +86,7 @@ export default class DataStore {
63
86
  */
64
87
  set(config, data) {
65
88
  this._cache.set(cacheKey(config), data)
89
+ this._listeners.forEach((fn) => fn())
66
90
  }
67
91
 
68
92
  /**
@@ -95,10 +119,22 @@ export default class DataStore {
95
119
  // Miss — execute fetch
96
120
  const promise = this._fetcher(config).then((result) => {
97
121
  this._inflight.delete(key)
98
- if (result.data !== undefined && result.data !== null) {
99
- this._cache.set(key, result.data)
122
+ let data = result.data
123
+ // Apply named transform if registered (dot-path transforms
124
+ // are handled by the fetcher itself via getNestedValue)
125
+ if (
126
+ data !== undefined &&
127
+ data !== null &&
128
+ config.transform &&
129
+ this._transforms.has(config.transform)
130
+ ) {
131
+ data = this._transforms.get(config.transform)(data, config)
132
+ }
133
+ if (data !== undefined && data !== null) {
134
+ this._cache.set(key, data)
135
+ this._listeners.forEach((fn) => fn())
100
136
  }
101
- return result
137
+ return { ...result, data }
102
138
  })
103
139
 
104
140
  this._inflight.set(key, promise)
@@ -111,5 +147,6 @@ export default class DataStore {
111
147
  clear() {
112
148
  this._cache.clear()
113
149
  this._inflight.clear()
150
+ this._transforms.clear()
114
151
  }
115
152
  }
@@ -22,6 +22,49 @@ export default class EntityStore {
22
22
  Object.seal(this)
23
23
  }
24
24
 
25
+ /**
26
+ * Whether a component wants detail-URL resolution on dynamic pages.
27
+ * Default true. Set to false via `data: { inherit: true, detail: false }`
28
+ * to receive the full collection (minus the active item) instead.
29
+ *
30
+ * @param {Object} meta
31
+ * @returns {boolean}
32
+ */
33
+ _shouldInheritDetail(meta, block) {
34
+ // Block-level fetch inherit override takes priority over meta
35
+ const bf = block?.fetch
36
+ if (bf?.inherit === true && bf?.detail !== undefined) return bf.detail !== false
37
+ if (!meta) return true
38
+ return meta.inheritDetail !== false
39
+ }
40
+
41
+ _inheritLimit(meta, block) {
42
+ // Block-level fetch inherit override takes priority over meta
43
+ const bf = block?.fetch
44
+ if (bf?.inherit === true && bf?.limit > 0) return bf.limit
45
+ return (meta?.inheritLimit > 0) ? meta.inheritLimit : null
46
+ }
47
+
48
+ _inheritOrder(block) {
49
+ const bf = block?.fetch
50
+ if (bf?.inherit === true && bf?.order?.orderBy) return bf.order
51
+ return null
52
+ }
53
+
54
+ _sortItems(items, order) {
55
+ if (!order?.orderBy || !Array.isArray(items) || items.length === 0) return items
56
+ const { orderBy, sortOrder = 'ASC' } = order
57
+ const desc = sortOrder === 'DESC'
58
+ return [...items].sort((a, b) => {
59
+ const av = a[orderBy] ?? ''
60
+ const bv = b[orderBy] ?? ''
61
+ const cmp = typeof av === 'string' && typeof bv === 'string'
62
+ ? av.localeCompare(bv)
63
+ : (av > bv ? 1 : av < bv ? -1 : 0)
64
+ return desc ? -cmp : cmp
65
+ })
66
+ }
67
+
25
68
  /**
26
69
  * Determine which schemas a component requests.
27
70
  *
@@ -78,8 +121,8 @@ export default class EntityStore {
78
121
 
79
122
  const sources = []
80
123
 
81
- // 1. Block-level fetch
82
- if (block.fetch) {
124
+ // 1. Block-level fetch (skip inherit-merge configs — they have no URL, only override props)
125
+ if (block.fetch && !block.fetch.inherit) {
83
126
  sources.push(block.fetch)
84
127
  }
85
128
 
@@ -140,7 +183,13 @@ export default class EntityStore {
140
183
 
141
184
  if (detail === 'rest') {
142
185
  // REST convention: {baseUrl}/{paramValue}
143
- detailUrl = `${baseUrl.replace(/\/$/, '')}/${encodeURIComponent(paramValue)}`
186
+ // Preserve query string (auth params like token, profileLang) — only insert
187
+ // the param value before the '?', not after.
188
+ const [basePath, queryString] = baseUrl.split('?')
189
+ const cleanBase = basePath.replace(/\/$/, '')
190
+ detailUrl = queryString
191
+ ? `${cleanBase}/${encodeURIComponent(paramValue)}?${queryString}`
192
+ : `${cleanBase}/${encodeURIComponent(paramValue)}`
144
193
  } else if (detail === 'query') {
145
194
  // Query param convention: {baseUrl}?{paramName}={paramValue}
146
195
  const sep = baseUrl.includes('?') ? '&' : '?'
@@ -199,34 +248,87 @@ export default class EntityStore {
199
248
  * @returns {{ status: 'ready'|'pending'|'none', data: Object|null }}
200
249
  */
201
250
  resolve(block, meta) {
202
- const requested = this._getRequestedSchemas(meta)
251
+ let requested = this._getRequestedSchemas(meta)
252
+
253
+ // If the component doesn't declare inheritData but the block itself
254
+ // has a fetch config (e.g. data_source_info converted to fetch),
255
+ // use the block's schema directly. Block-level fetch is an explicit
256
+ // data assignment, not inheritance.
257
+ if (requested === null && block.fetch) {
258
+ const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
259
+ const schemas = blockFetchList
260
+ .filter((cfg) => cfg.schema)
261
+ .map((cfg) => cfg.schema)
262
+ if (schemas.length > 0) {
263
+ requested = schemas
264
+ }
265
+ }
266
+
203
267
  if (requested === null) {
204
268
  return { status: 'none', data: null }
205
269
  }
206
270
 
207
271
  // Walk hierarchy for fetch configs
208
272
  const configs = this._findFetchConfigs(block, requested)
273
+
209
274
  if (configs.size === 0) {
210
275
  return { status: 'none', data: null }
211
276
  }
212
277
 
213
278
  // Check DataStore cache for each config
214
279
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
280
+ const inheritDetail = this._shouldInheritDetail(meta, block)
281
+ const limit = this._inheritLimit(meta, block)
282
+ const order = this._inheritOrder(block)
215
283
  const data = {}
216
284
  let allCached = true
217
285
 
218
286
  for (const [schema, cfg] of configs) {
219
- if (this.dataStore.has(cfg)) {
220
- data[schema] = this.dataStore.get(cfg)
287
+ if (dynamicContext && cfg.detail && !inheritDetail) {
288
+ // detail: false — return collection minus the active item (sidebar/related use case)
289
+ if (this.dataStore.has(cfg)) {
290
+ const { paramName, paramValue } = dynamicContext
291
+ const items = this.dataStore.get(cfg)
292
+ let filtered = Array.isArray(items)
293
+ ? items.filter((item) => String(item[paramName]) !== String(paramValue))
294
+ : items
295
+ if (order) filtered = this._sortItems(filtered, order)
296
+ data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
297
+ } else {
298
+ allCached = false
299
+ }
221
300
  } else if (dynamicContext && cfg.detail) {
222
- // Detail query: check if the single-entity result is cached
223
- const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
224
- if (detailCfg && this.dataStore.has(detailCfg)) {
301
+ // Collection-first detail resolution:
302
+ // The collection acts as the access gate — the item must exist in the
303
+ // cached collection before we'll serve (or fetch) its detail data.
304
+ if (this.dataStore.has(cfg)) {
305
+ const collectionItems = this.dataStore.get(cfg)
306
+ const { paramName, paramValue } = dynamicContext
225
307
  const singularKey = singularize(schema) || schema
226
- data[singularKey] = this.dataStore.get(detailCfg)
308
+ const match = Array.isArray(collectionItems)
309
+ ? collectionItems.find((item) => String(item[paramName]) === String(paramValue))
310
+ : null
311
+
312
+ if (!match) {
313
+ // Item not in collection — definitive "not found" (content gate).
314
+ data[singularKey] = null
315
+ } else {
316
+ // Item is valid. Check if the detail result is already cached.
317
+ const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
318
+ if (detailCfg && this.dataStore.has(detailCfg)) {
319
+ data[singularKey] = this.dataStore.get(detailCfg)
320
+ } else if (detailCfg) {
321
+ allCached = false // Collection cached, item valid, detail still needed
322
+ } else {
323
+ data[singularKey] = match // No detail URL — use collection item directly
324
+ }
325
+ }
227
326
  } else {
228
- allCached = false
327
+ allCached = false // Collection not yet cached — must fetch it first
229
328
  }
329
+ } else if (this.dataStore.has(cfg)) {
330
+ const items = this.dataStore.get(cfg)
331
+ data[schema] = order ? this._sortItems(items, order) : items
230
332
  } else {
231
333
  allCached = false
232
334
  }
@@ -248,7 +350,19 @@ export default class EntityStore {
248
350
  * @returns {Promise<{ data: Object|null }>}
249
351
  */
250
352
  async fetch(block, meta) {
251
- const requested = this._getRequestedSchemas(meta)
353
+ let requested = this._getRequestedSchemas(meta)
354
+
355
+ // Same block-level fetch fallback as resolve()
356
+ if (requested === null && block.fetch) {
357
+ const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
358
+ const schemas = blockFetchList
359
+ .filter((cfg) => cfg.schema)
360
+ .map((cfg) => cfg.schema)
361
+ if (schemas.length > 0) {
362
+ requested = schemas
363
+ }
364
+ }
365
+
252
366
  if (requested === null) {
253
367
  return { data: null }
254
368
  }
@@ -258,42 +372,82 @@ export default class EntityStore {
258
372
  return { data: null }
259
373
  }
260
374
 
261
- // Fetch all missing configs in parallel
375
+ // Fetch all missing configs
262
376
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
377
+ const inheritDetail = this._shouldInheritDetail(meta, block)
378
+ const limit = this._inheritLimit(meta, block)
379
+ const order = this._inheritOrder(block)
380
+
263
381
  const data = {}
264
- const fetchPromises = []
382
+ const parallelFetches = []
265
383
 
266
384
  for (const [schema, cfg] of configs) {
267
- // Detail query optimization: on template pages, if the collection
268
- // isn't cached and a detail convention is defined, fetch just the
269
- // single entity instead of the full collection.
270
- if (dynamicContext && cfg.detail && !this.dataStore.has(cfg)) {
385
+ if (dynamicContext && cfg.detail && !inheritDetail) {
386
+ // detail: false fetch collection and return it minus the active item
387
+ // (sidebar / related-posts use case on a dynamic page)
388
+ let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
389
+ if (collectionItems === null) {
390
+ const result = await this.dataStore.fetch(cfg)
391
+ collectionItems = Array.isArray(result.data) ? result.data : null
392
+ }
393
+ const { paramName, paramValue } = dynamicContext
394
+ let filtered = Array.isArray(collectionItems)
395
+ ? collectionItems.filter((item) => String(item[paramName]) !== String(paramValue))
396
+ : (collectionItems ?? [])
397
+ if (order) filtered = this._sortItems(filtered, order)
398
+ data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
399
+ } else if (dynamicContext && cfg.detail) {
400
+ // Collection-first detail resolution:
401
+ // 1. Ensure the collection is in DataStore (fetching if needed).
402
+ // 2. Validate that paramValue exists in the collection (content gate).
403
+ // 3. Only then fetch the detail URL for richer item data.
404
+ const { paramName, paramValue } = dynamicContext
405
+ const singularKey = singularize(schema) || schema
406
+
407
+ // Step 1: ensure collection is cached (sequential — needed for validation)
408
+ let collectionItems = this.dataStore.has(cfg) ? this.dataStore.get(cfg) : null
409
+ if (collectionItems === null) {
410
+ const result = await this.dataStore.fetch(cfg)
411
+ collectionItems = Array.isArray(result.data) ? result.data : null
412
+ }
413
+
414
+ // Step 2: validate paramValue is in the collection (content gate)
415
+ const match = collectionItems?.find(
416
+ (item) => String(item[paramName]) === String(paramValue)
417
+ ) ?? null
418
+
419
+ if (!match) {
420
+ data[singularKey] = null // Not in collection — content gate
421
+ continue
422
+ }
423
+
424
+ // Step 3: fetch detail URL for richer data; fall back to collection item
271
425
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
272
426
  if (detailCfg) {
273
- fetchPromises.push(
427
+ parallelFetches.push(
274
428
  this.dataStore.fetch(detailCfg).then((result) => {
275
- if (result.data !== undefined && result.data !== null) {
276
- const singularKey = singularize(schema) || schema
277
- data[singularKey] = result.data
278
- }
429
+ data[singularKey] = (result.data !== undefined && result.data !== null)
430
+ ? result.data
431
+ : match // fallback: collection item is still valid
279
432
  })
280
433
  )
281
- continue // skip collection fetch for this schema
434
+ } else {
435
+ data[singularKey] = match // No detail URL — collection item is enough
282
436
  }
437
+ } else {
438
+ // Default: fetch the full collection
439
+ parallelFetches.push(
440
+ this.dataStore.fetch(cfg).then((result) => {
441
+ if (result.data !== undefined && result.data !== null) {
442
+ data[schema] = result.data
443
+ }
444
+ })
445
+ )
283
446
  }
284
-
285
- // Default: fetch the full collection
286
- fetchPromises.push(
287
- this.dataStore.fetch(cfg).then((result) => {
288
- if (result.data !== undefined && result.data !== null) {
289
- data[schema] = result.data
290
- }
291
- })
292
- )
293
447
  }
294
448
 
295
- if (fetchPromises.length > 0) {
296
- await Promise.all(fetchPromises)
449
+ if (parallelFetches.length > 0) {
450
+ await Promise.all(parallelFetches)
297
451
  }
298
452
  const resolved = this._resolveSingularItem(data, dynamicContext)
299
453
  return { data: resolved }
package/src/website.js CHANGED
@@ -33,7 +33,8 @@ export default class Website {
33
33
 
34
34
  // Store 404 page (for SPA routing)
35
35
  // Convention: pages/404/ directory
36
- this.notFoundPage = notFound || pages.find((p) => p.route === '/404') || null
36
+ const notFoundData = notFound || pages.find((p) => p.route === '/404') || null
37
+ this.notFoundPage = notFoundData ? new Page(notFoundData, 'notFound', this) : null
37
38
 
38
39
  // Filter out 404 from regular pages array
39
40
  const regularPages = pages.filter((page) => page.route !== '/404')
@@ -49,6 +50,7 @@ export default class Website {
49
50
  // Cache for dynamically created page instances
50
51
  this._dynamicPageCache = new Map()
51
52
 
53
+
52
54
  this.pages = regularPages.map(
53
55
  (page, index) => new Page(page, index, this)
54
56
  )
@@ -66,7 +68,11 @@ export default class Website {
66
68
  this.config = config
67
69
 
68
70
  // Locale configuration
69
- this.defaultLocale = config.defaultLanguage || 'en'
71
+ // siteDefaultLocale: the site's true default language (for route translations)
72
+ // defaultLocale: effective default for URL prefix logic — domainLocale overrides
73
+ // to prevent unnecessary /{locale}/ prefixes on domain-locale pages
74
+ this.siteDefaultLocale = config.defaultLanguage || 'en'
75
+ this.defaultLocale = config.domainLocale || this.siteDefaultLocale
70
76
  this.activeLocale = config.activeLocale || this.defaultLocale
71
77
 
72
78
  // Build locales list from i18n config
@@ -169,7 +175,7 @@ export default class Website {
169
175
  * @returns {string} Translated route or original if no translation exists
170
176
  */
171
177
  translateRoute(canonicalRoute, locale = this.activeLocale) {
172
- if (!locale || locale === this.defaultLocale) return canonicalRoute
178
+ if (!locale || locale === this.siteDefaultLocale) return canonicalRoute
173
179
  const entry = this._routeTranslations[locale]
174
180
  if (!entry) return canonicalRoute
175
181
  // Exact match
@@ -193,7 +199,7 @@ export default class Website {
193
199
  * @returns {string} Canonical route or original if no translation exists
194
200
  */
195
201
  reverseTranslateRoute(displayRoute, locale = this.activeLocale) {
196
- if (!locale || locale === this.defaultLocale) return displayRoute
202
+ if (!locale || locale === this.siteDefaultLocale) return displayRoute
197
203
  const entry = this._routeTranslations[locale]
198
204
  if (!entry) return displayRoute
199
205
  // Exact match
@@ -280,6 +286,23 @@ export default class Website {
280
286
  }
281
287
  }
282
288
 
289
+ // Normalize trailing slashes for consistent matching
290
+ const normalizedStripped = stripped === '/' ? '/' : stripped.replace(/\/$/, '')
291
+
292
+ // Priority 1: Direct match on the (possibly display) route.
293
+ // Handles published-payload sites where the page map may already contain
294
+ // locale-translated display routes (e.g. fr pages have fr routes).
295
+ // For file-system sites whose page map uses canonical routes this will
296
+ // simply fall through to the reverse-translate path below.
297
+ const directMatch = this.pages.find((page) => page.route === normalizedStripped)
298
+ if (directMatch) {
299
+ if (!directMatch.hasContent()) {
300
+ const indexChild = directMatch.children.find((c) => c.isIndex)
301
+ if (indexChild) return indexChild
302
+ }
303
+ return directMatch
304
+ }
305
+
283
306
  // Reverse-translate display route to canonical (e.g., '/acerca-de' → '/about')
284
307
  stripped = this.reverseTranslateRoute(stripped)
285
308
 
@@ -287,7 +310,7 @@ export default class Website {
287
310
  // '/about/' and '/about' should match the same page
288
311
  const normalizedRoute = stripped === '/' ? '/' : stripped.replace(/\/$/, '')
289
312
 
290
- // Priority 1: Exact match on actual route
313
+ // Priority 1b: Exact match on canonical route
291
314
  const exactMatch = this.pages.find((page) => page.route === normalizedRoute)
292
315
  if (exactMatch) {
293
316
  // Priority 1.5: folder page with an isIndex child — resolve to the index child.
@@ -318,10 +341,15 @@ export default class Website {
318
341
  const match = this._matchDynamicRoute(page.route, normalizedRoute)
319
342
  if (match) {
320
343
  // Create a dynamic page instance with the concrete route and params
321
- const dynamicPage = this._createDynamicPage(page, normalizedRoute, match.params)
322
- if (dynamicPage) {
323
- // Cache for future requests
324
- this._dynamicPageCache.set(normalizedRoute, dynamicPage)
344
+ const result = this._createDynamicPage(page, normalizedRoute, match.params)
345
+ if (result) {
346
+ const { page: dynamicPage, collectionLoaded } = result
347
+ // Only cache when collection data was available at creation time.
348
+ // If DataStore was empty, skip caching so the next render recreates
349
+ // the page with fresh data (correct title, not-found state, etc.).
350
+ if (collectionLoaded) {
351
+ this._dynamicPageCache.set(normalizedRoute, dynamicPage)
352
+ }
325
353
  return dynamicPage
326
354
  }
327
355
  }
@@ -432,11 +460,18 @@ export default class Website {
432
460
  if (currentItem.description || currentItem.excerpt) {
433
461
  pageData.description = currentItem.description || currentItem.excerpt
434
462
  }
463
+ } else if (items.length > 0) {
464
+ // Collection is loaded but this ID isn't in it — definitive not found
465
+ pageData.title = 'Not found'
466
+ pageData.notFound = true
435
467
  }
436
468
 
437
469
  // Store in dynamic context for entity resolution
438
470
  pageData.dynamicContext.currentItem = currentItem || null
439
471
  pageData.dynamicContext.allItems = items
472
+
473
+ // Track whether collection data was available at creation time
474
+ pageData._collectionLoaded = items.length > 0
440
475
  }
441
476
 
442
477
  // Create the page instance
@@ -445,7 +480,7 @@ export default class Website {
445
480
  // Copy parent reference from template
446
481
  dynamicPage.parent = templatePage.parent
447
482
 
448
- return dynamicPage
483
+ return { page: dynamicPage, collectionLoaded: pageData._collectionLoaded ?? true }
449
484
  }
450
485
 
451
486
  /**
@@ -710,6 +745,18 @@ export default class Website {
710
745
  // Reverse-translate from current locale to canonical route
711
746
  targetRoute = this.reverseTranslateRoute(targetRoute)
712
747
 
748
+ // Per-domain locale: if a domain is designated for this locale,
749
+ // return a full cross-domain URL instead of a path-based prefix.
750
+ const domainLocales = this.config?.domainLocales
751
+ if (domainLocales) {
752
+ const designated = Object.entries(domainLocales).find(([, lang]) => lang === localeCode)
753
+ if (designated) {
754
+ const domain = designated[0]
755
+ const translatedRoute = this.translateRoute(targetRoute, localeCode)
756
+ return `https://${domain}${translatedRoute === '/' ? '/' : translatedRoute}`
757
+ }
758
+ }
759
+
713
760
  // Default locale uses root path (no prefix), no translation needed
714
761
  if (localeCode === this.defaultLocale) {
715
762
  return targetRoute