@uniweb/core 0.5.13 → 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.13",
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/page.js CHANGED
@@ -96,14 +96,15 @@ export default class Page {
96
96
  * @returns {Object} Head metadata
97
97
  */
98
98
  getHeadMeta() {
99
+ const resolvedTitle = this.getTitle()
99
100
  return {
100
- title: this.title,
101
+ title: resolvedTitle,
101
102
  description: this.description,
102
103
  keywords: this.keywords,
103
104
  canonical: this.seo.canonical,
104
105
  robots: this.seo.noindex ? 'noindex, nofollow' : null,
105
106
  og: {
106
- title: this.seo.ogTitle || this.title,
107
+ title: this.seo.ogTitle || resolvedTitle,
107
108
  description: this.seo.ogDescription || this.description,
108
109
  image: this.seo.image,
109
110
  url: this.route,
@@ -284,21 +285,41 @@ export default class Page {
284
285
  // ─────────────────────────────────────────────────────────────────
285
286
 
286
287
  /**
287
- * Get the navigation route (canonical route for links)
288
- * With the new routing model, route is already the canonical nav route.
289
- * Index pages have route set to parent route (e.g., '/' for homepage).
288
+ * Get the navigation route (canonical route for links).
289
+ * For index pages whose route ends in /index (e.g., /Articles/index),
290
+ * returns the parent folder route (/Articles) so nav comparisons and
291
+ * active-route highlighting work against the clean URL.
290
292
  * @returns {string}
291
293
  */
292
294
  getNavRoute() {
295
+ if (this.isIndex && this.route.endsWith('/index')) {
296
+ return this.route.slice(0, -'/index'.length) || '/'
297
+ }
293
298
  return this.route
294
299
  }
295
300
 
301
+ /**
302
+ * Get display title for the page.
303
+ * For index pages with no meaningful title (empty or the literal string "index"),
304
+ * falls back to the parent folder's title so /Articles/index shows "Articles".
305
+ * @returns {string}
306
+ */
307
+ getTitle() {
308
+ if (this.isIndex && this.route.endsWith('/index')) {
309
+ const own = this.title?.trim()
310
+ if (!own || own.toLowerCase() === 'index') {
311
+ return this.parent?.title || own || ''
312
+ }
313
+ }
314
+ return this.title
315
+ }
316
+
296
317
  /**
297
318
  * Get display label for navigation (short form of title)
298
319
  * @returns {string}
299
320
  */
300
321
  getLabel() {
301
- return this.label || this.title
322
+ return this.label || this.getTitle()
302
323
  }
303
324
 
304
325
  /**
@@ -362,9 +383,10 @@ export default class Page {
362
383
  getNavigableRoute() {
363
384
  if (this.hasContent()) return this.route
364
385
  const children = this.children || []
365
- // Prefer the index child (designated landing page for this folder)
386
+ // Prefer the index child (designated landing page for this folder).
387
+ // Return this folder's own route so the URL stays clean (/Articles, not /Articles/index).
366
388
  const indexChild = children.find((c) => c.isIndex)
367
- if (indexChild) return indexChild.getNavigableRoute()
389
+ if (indexChild) return this.route
368
390
  // Fall back to first child with content
369
391
  for (const child of children) {
370
392
  const route = child.getNavigableRoute()
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,9 +310,18 @@ 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
- if (exactMatch) return exactMatch
315
+ if (exactMatch) {
316
+ // Priority 1.5: folder page with an isIndex child — resolve to the index child.
317
+ // This makes /Articles resolve to the /Articles/index page for rendering,
318
+ // keeping the URL clean while serving real content.
319
+ if (!exactMatch.hasContent()) {
320
+ const indexChild = exactMatch.children.find((c) => c.isIndex)
321
+ if (indexChild) return indexChild
322
+ }
323
+ return exactMatch
324
+ }
293
325
 
294
326
  // Priority 2: Index page nav route match
295
327
  const indexMatch = this.pages.find((page) => page.isIndex && page.getNavRoute() === normalizedRoute)
@@ -309,10 +341,15 @@ export default class Website {
309
341
  const match = this._matchDynamicRoute(page.route, normalizedRoute)
310
342
  if (match) {
311
343
  // Create a dynamic page instance with the concrete route and params
312
- const dynamicPage = this._createDynamicPage(page, normalizedRoute, match.params)
313
- if (dynamicPage) {
314
- // Cache for future requests
315
- 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
+ }
316
353
  return dynamicPage
317
354
  }
318
355
  }
@@ -423,11 +460,18 @@ export default class Website {
423
460
  if (currentItem.description || currentItem.excerpt) {
424
461
  pageData.description = currentItem.description || currentItem.excerpt
425
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
426
467
  }
427
468
 
428
469
  // Store in dynamic context for entity resolution
429
470
  pageData.dynamicContext.currentItem = currentItem || null
430
471
  pageData.dynamicContext.allItems = items
472
+
473
+ // Track whether collection data was available at creation time
474
+ pageData._collectionLoaded = items.length > 0
431
475
  }
432
476
 
433
477
  // Create the page instance
@@ -436,7 +480,7 @@ export default class Website {
436
480
  // Copy parent reference from template
437
481
  dynamicPage.parent = templatePage.parent
438
482
 
439
- return dynamicPage
483
+ return { page: dynamicPage, collectionLoaded: pageData._collectionLoaded ?? true }
440
484
  }
441
485
 
442
486
  /**
@@ -701,6 +745,18 @@ export default class Website {
701
745
  // Reverse-translate from current locale to canonical route
702
746
  targetRoute = this.reverseTranslateRoute(targetRoute)
703
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
+
704
760
  // Default locale uses root path (no prefix), no translation needed
705
761
  if (localeCode === this.defaultLocale) {
706
762
  return targetRoute
@@ -878,6 +934,10 @@ export default class Website {
878
934
  // These are templates for generating pages, not actual navigable pages
879
935
  if (page.route.includes(':')) return false
880
936
 
937
+ // Exclude index pages (route ends in /index) from navigation — they are
938
+ // represented by their parent folder entry which links to them via navigableRoute
939
+ if (page.isIndex && page.route.endsWith('/index')) return false
940
+
881
941
  // Check visibility based on navigation type
882
942
  if (!includeHidden) {
883
943
  if (page.hidden) return false
@@ -885,9 +945,11 @@ export default class Website {
885
945
  if (navType === 'footer' && page.hideInFooter) return false
886
946
  }
887
947
 
888
- // Skip empty folders (no content) that have no visible children.
889
- // Folders with children still appear as dropdown parents.
890
- if (!page.hasContent() && !page.children?.some(isPageVisible)) return false
948
+ // Skip empty folders that have no visible children AND no index child.
949
+ // Folders with an isIndex child are navigable (they link to the index page)
950
+ // even after the index child itself is filtered out above.
951
+ const hasNavigableIndex = !page.hasContent() && page.children?.some((c) => c.isIndex)
952
+ if (!page.hasContent() && !hasNavigableIndex && !page.children?.some(isPageVisible)) return false
891
953
 
892
954
  // Apply custom filter if provided
893
955
  if (customFilter && !customFilter(page)) return false
@@ -919,7 +981,7 @@ export default class Website {
919
981
  route: navRoute, // Use canonical nav route (e.g., '/' for index pages)
920
982
  navigableRoute: page.getNavigableRoute(), // First route with content (for links)
921
983
  translatedRoute: this.translateRoute(navRoute), // Locale-specific display route
922
- title: page.title,
984
+ title: page.getTitle(),
923
985
  label: page.getLabel(),
924
986
  description: page.description,
925
987
  hasContent: page.hasContent(),