@uniweb/core 0.5.14 → 0.5.16
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 +3 -3
- package/src/block.js +39 -0
- package/src/datastore.js +40 -3
- package/src/entity-store.js +189 -35
- package/src/page.js +5 -0
- package/src/website.js +57 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.16",
|
|
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/
|
|
34
|
-
"@uniweb/
|
|
33
|
+
"@uniweb/theming": "0.1.3",
|
|
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
|
-
|
|
99
|
-
|
|
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
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
220
|
-
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
382
|
+
const parallelFetches = []
|
|
265
383
|
|
|
266
384
|
for (const [schema, cfg] of configs) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
427
|
+
parallelFetches.push(
|
|
274
428
|
this.dataStore.fetch(detailCfg).then((result) => {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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 (
|
|
296
|
-
await Promise.all(
|
|
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
|
@@ -21,6 +21,11 @@ export default class Page {
|
|
|
21
21
|
this.keywords = pageData.keywords || null
|
|
22
22
|
this.lastModified = pageData.lastModified || null
|
|
23
23
|
|
|
24
|
+
// Redirect target (if set, this page redirects instead of rendering content)
|
|
25
|
+
this.redirect = pageData.redirect || null
|
|
26
|
+
// Rewrite target (if set, this route is served by an external site)
|
|
27
|
+
this.rewrite = pageData.rewrite || null
|
|
28
|
+
|
|
24
29
|
// Navigation visibility options
|
|
25
30
|
this.hidden = pageData.hidden || false
|
|
26
31
|
this.hideInHeader = pageData.hideInHeader || false
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
322
|
-
if (
|
|
323
|
-
|
|
324
|
-
|
|
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
|