@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 +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 +30 -8
- package/src/website.js +77 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.5.
|
|
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/
|
|
34
|
-
"@uniweb/
|
|
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
|
-
|
|
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
|
@@ -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:
|
|
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 ||
|
|
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
|
-
*
|
|
289
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,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
|
|
313
|
+
// Priority 1b: Exact match on canonical route
|
|
291
314
|
const exactMatch = this.pages.find((page) => page.route === normalizedRoute)
|
|
292
|
-
if (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
|
|
313
|
-
if (
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
889
|
-
// Folders with
|
|
890
|
-
|
|
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.
|
|
984
|
+
title: page.getTitle(),
|
|
923
985
|
label: page.getLabel(),
|
|
924
986
|
description: page.description,
|
|
925
987
|
hasContent: page.hasContent(),
|