@uniweb/core 0.7.14 → 0.7.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 +2 -2
- package/src/block.js +15 -0
- package/src/entity-store.js +48 -0
- package/src/page.js +25 -5
- package/src/website.js +39 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"vitest": "^4.1.7"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@uniweb/theming": "0.1.
|
|
33
|
+
"@uniweb/theming": "0.1.5",
|
|
34
34
|
"@uniweb/semantic-parser": "1.1.17"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
package/src/block.js
CHANGED
|
@@ -437,6 +437,15 @@ export default class Block {
|
|
|
437
437
|
* @returns {Object|null} Next block's info or null
|
|
438
438
|
*/
|
|
439
439
|
getNextBlockInfo() {
|
|
440
|
+
// Layout-area blocks (header/footer/panels) live on a shared, contentless
|
|
441
|
+
// area page — not the content page being rendered — so walking their own
|
|
442
|
+
// page's sequence never reaches the content. Their "next block" is the
|
|
443
|
+
// first content section of the active page (a header adapting to the
|
|
444
|
+
// section it floats over). Resolve against the active page instead.
|
|
445
|
+
const active = this.website?.activePage
|
|
446
|
+
if (active && active !== this.page) {
|
|
447
|
+
return active.getFirstBodyBlockInfo()
|
|
448
|
+
}
|
|
440
449
|
const index = this.getIndex()
|
|
441
450
|
if (index < 0 || !this.page) return null
|
|
442
451
|
return this.page.getBlockInfo(index + 1)
|
|
@@ -448,6 +457,12 @@ export default class Block {
|
|
|
448
457
|
* @returns {Object|null} Previous block's info or null
|
|
449
458
|
*/
|
|
450
459
|
getPrevBlockInfo() {
|
|
460
|
+
// See getNextBlockInfo: from a shared layout-area block, "previous" is the
|
|
461
|
+
// last content section of the active page (e.g. a footer adapting to it).
|
|
462
|
+
const active = this.website?.activePage
|
|
463
|
+
if (active && active !== this.page) {
|
|
464
|
+
return active.getLastBodyBlockInfo()
|
|
465
|
+
}
|
|
451
466
|
const index = this.getIndex()
|
|
452
467
|
if (index <= 0 || !this.page) return null
|
|
453
468
|
return this.page.getBlockInfo(index - 1)
|
package/src/entity-store.js
CHANGED
|
@@ -145,6 +145,28 @@ export default class EntityStore {
|
|
|
145
145
|
return configs
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Post-process assembled collection data: for each fetch config that declares a
|
|
150
|
+
* `detailPage` page-ref, resolve it to a locale route template (O(1), via the
|
|
151
|
+
* Website's `_pageIdMap`) and inject a `route` on each record — so a dynamic-list
|
|
152
|
+
* card links to the collection's canonical detail page regardless of which page
|
|
153
|
+
* the list sits on. Runs after `data` is fully assembled, in BOTH the sync (peek)
|
|
154
|
+
* and async (fetch) paths. Replaces the old runtime `getCollectionDetailRoute`
|
|
155
|
+
* page-tree scan. A dangling `detailPage` (unresolvable ref) is a no-op — the
|
|
156
|
+
* component degrades gracefully; records with a baked `route` (file lane) are kept.
|
|
157
|
+
*/
|
|
158
|
+
_applyDetailRoutes(data, configs, website) {
|
|
159
|
+
if (!data || !website?.resolveDetailPageTemplate) return
|
|
160
|
+
for (const [schema, cfg] of configs) {
|
|
161
|
+
if (!cfg.detailPage) continue
|
|
162
|
+
const items = data[schema]
|
|
163
|
+
if (!Array.isArray(items) || items.length === 0) continue
|
|
164
|
+
const template = website.resolveDetailPageTemplate(cfg.detailPage)
|
|
165
|
+
if (!template) continue
|
|
166
|
+
data[schema] = items.map((item) => addDetailRoute(item, template))
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
148
170
|
/**
|
|
149
171
|
* Auto-inject `detail:` on collection refs whose collection has
|
|
150
172
|
* `deferred:` declared. The detail pattern points at the per-record
|
|
@@ -363,6 +385,7 @@ export default class EntityStore {
|
|
|
363
385
|
}
|
|
364
386
|
|
|
365
387
|
if (allCached) {
|
|
388
|
+
this._applyDetailRoutes(data, configs, block.website)
|
|
366
389
|
return { status: 'ready', data }
|
|
367
390
|
}
|
|
368
391
|
return { status: 'pending', data: null }
|
|
@@ -465,6 +488,7 @@ export default class EntityStore {
|
|
|
465
488
|
}
|
|
466
489
|
|
|
467
490
|
if (parallelFetches.length > 0) await Promise.all(parallelFetches)
|
|
491
|
+
this._applyDetailRoutes(data, configs, block.website)
|
|
468
492
|
return { data }
|
|
469
493
|
}
|
|
470
494
|
}
|
|
@@ -477,3 +501,27 @@ function peekArray(dispatcher, cfg, ctx) {
|
|
|
477
501
|
if (!cached) return null
|
|
478
502
|
return Array.isArray(cached.data) ? cached.data : null
|
|
479
503
|
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Interpolate a record's fields into a detail-page route template to build its
|
|
507
|
+
* `route` (the canonical href for a card). `/blog/:slug` + `{ slug: 'a-post' }`
|
|
508
|
+
* → `/blog/a-post`. Returns a SHALLOW COPY with `route` added — never mutates the
|
|
509
|
+
* cached record (the same collection may back several sections with different
|
|
510
|
+
* detail pages). Idempotent + back-compat: a record that already carries a `route`
|
|
511
|
+
* (the file lane bakes one via collection-processor) is returned untouched. A
|
|
512
|
+
* `:param` with no matching record field → no `route` (graceful; degrades to the
|
|
513
|
+
* component's own fallback rather than emitting a broken href).
|
|
514
|
+
*/
|
|
515
|
+
function addDetailRoute(item, template) {
|
|
516
|
+
if (!item || typeof item !== 'object' || item.route !== undefined) return item
|
|
517
|
+
let missing = false
|
|
518
|
+
const route = template.replace(/:(\w+)/g, (_, name) => {
|
|
519
|
+
const value = item[name]
|
|
520
|
+
if (value == null) {
|
|
521
|
+
missing = true
|
|
522
|
+
return ''
|
|
523
|
+
}
|
|
524
|
+
return encodeURIComponent(String(value))
|
|
525
|
+
})
|
|
526
|
+
return missing ? item : { ...item, route }
|
|
527
|
+
}
|
package/src/page.js
CHANGED
|
@@ -48,10 +48,16 @@ export default class Page {
|
|
|
48
48
|
// Rewrite target (if set, this route is served by an external site)
|
|
49
49
|
this.rewrite = pageData.rewrite || null
|
|
50
50
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
51
|
+
// Two orthogonal visibility axes:
|
|
52
|
+
// • `hidden` — REACHABILITY. When true the page is excluded from the published
|
|
53
|
+
// site entirely (the build prunes it and its subtree); it survives only in
|
|
54
|
+
// dev/authoring for preview. A hidden page is also, a fortiori, absent from
|
|
55
|
+
// nav. This is NOT a nav-only flag.
|
|
56
|
+
// • `hideIn` — NAV PLACEMENT. A per-area denylist (layout-area names, e.g.
|
|
57
|
+
// ['header','footer']) applied while the page IS routed. The sentinel '*'
|
|
58
|
+
// means "suppressed from every nav area" (still routed) — this is how a page
|
|
59
|
+
// is kept reachable-but-out-of-all-menus. The legacy hideInHeader/hideInFooter
|
|
60
|
+
// booleans fold into hideIn and are kept as derived accessors for back-compat.
|
|
55
61
|
this.hidden = pageData.hidden || false
|
|
56
62
|
this.hideIn = normalizeHideIn(pageData)
|
|
57
63
|
this.hideInHeader = this.hideIn.includes('header')
|
|
@@ -265,6 +271,18 @@ export default class Page {
|
|
|
265
271
|
return this.bodyBlocks?.[0]?.getBlockInfo() || null
|
|
266
272
|
}
|
|
267
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Get the last body block's info.
|
|
276
|
+
* Symmetric to getFirstBodyBlockInfo — e.g. a footer adapting to the
|
|
277
|
+
* content section directly above it.
|
|
278
|
+
*
|
|
279
|
+
* @returns {Object|null} Last body block's info or null
|
|
280
|
+
*/
|
|
281
|
+
getLastBodyBlockInfo() {
|
|
282
|
+
const blocks = this.bodyBlocks
|
|
283
|
+
return blocks?.[blocks.length - 1]?.getBlockInfo() || null
|
|
284
|
+
}
|
|
285
|
+
|
|
268
286
|
/**
|
|
269
287
|
* Get all blocks (header, body, footer) as flat array
|
|
270
288
|
* Respects page layout preferences (hide list)
|
|
@@ -394,11 +412,13 @@ export default class Page {
|
|
|
394
412
|
/**
|
|
395
413
|
* Check if page should appear in a named nav area ('header', 'footer', or any
|
|
396
414
|
* foundation-declared area). The general form behind showInHeader/showInFooter.
|
|
415
|
+
* False when the page is unpublished (`hidden`), suppressed from every area
|
|
416
|
+
* (`hideIn` contains '*'), or suppressed from this specific area.
|
|
397
417
|
* @param {string} area
|
|
398
418
|
* @returns {boolean}
|
|
399
419
|
*/
|
|
400
420
|
showInNav(area) {
|
|
401
|
-
return !this.hidden && !this.hideIn.includes(area)
|
|
421
|
+
return !this.hidden && !this.hideIn.includes('*') && !this.hideIn.includes(area)
|
|
402
422
|
}
|
|
403
423
|
|
|
404
424
|
/**
|
package/src/website.js
CHANGED
|
@@ -791,6 +791,39 @@ export default class Website {
|
|
|
791
791
|
return resolvedHref
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Resolve a `page:<stable_id>` detail-page reference (from a fetch config's
|
|
796
|
+
* `detailPage`) to a locale-specific route TEMPLATE, e.g. '/blog/:slug'. The
|
|
797
|
+
* entity store interpolates each record's field into the `:param` slot to build
|
|
798
|
+
* a card's href — so a dynamic-list preview links to the collection's canonical
|
|
799
|
+
* detail page regardless of which page it sits on.
|
|
800
|
+
*
|
|
801
|
+
* O(1): a `_pageIdMap` lookup (keyed on stable_id, same map makeHref uses), NOT
|
|
802
|
+
* a page-tree scan. Returns null when the ref is unresolvable — the target page
|
|
803
|
+
* was deleted or de-dynamicized (a dangling ref); the caller degrades gracefully
|
|
804
|
+
* (leaves the record without a `route`). Rename-safe: a stable_id survives page
|
|
805
|
+
* reorganization.
|
|
806
|
+
*
|
|
807
|
+
* @param {string} pageRef - `page:<stable_id>` (bare `<stable_id>` also accepted)
|
|
808
|
+
* @returns {string|null} locale-specific route template, or null if unresolvable
|
|
809
|
+
*/
|
|
810
|
+
resolveDetailPageTemplate(pageRef) {
|
|
811
|
+
if (!pageRef || typeof pageRef !== 'string') return null
|
|
812
|
+
const id = pageRef.startsWith('page:') ? pageRef.slice(5) : pageRef
|
|
813
|
+
const page = this._pageIdMap?.get(id)
|
|
814
|
+
if (!page || !page.route) {
|
|
815
|
+
if (
|
|
816
|
+
typeof console !== 'undefined' &&
|
|
817
|
+
typeof process !== 'undefined' &&
|
|
818
|
+
process?.env?.NODE_ENV !== 'production'
|
|
819
|
+
) {
|
|
820
|
+
console.warn(`[resolveDetailPageTemplate] Detail page not found: ${pageRef}`)
|
|
821
|
+
}
|
|
822
|
+
return null
|
|
823
|
+
}
|
|
824
|
+
return this.translateRoute(page.route)
|
|
825
|
+
}
|
|
826
|
+
|
|
794
827
|
/**
|
|
795
828
|
* Get available languages
|
|
796
829
|
* @deprecated Use getLocales() instead
|
|
@@ -1075,9 +1108,13 @@ export default class Website {
|
|
|
1075
1108
|
|
|
1076
1109
|
// Check visibility based on navigation type
|
|
1077
1110
|
if (!includeHidden) {
|
|
1111
|
+
// `hidden` = unpublished (a fortiori not in nav). In a published build it
|
|
1112
|
+
// is already pruned; this also keeps dev drafts out of the menus.
|
|
1078
1113
|
if (page.hidden) return false
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1114
|
+
// hideIn lists the areas this page is suppressed from; '*' suppresses it
|
|
1115
|
+
// from every area (reachable-but-out-of-all-menus). navType is the
|
|
1116
|
+
// requested area ('header'/'footer'/any foundation-declared area).
|
|
1117
|
+
if (page.hideIn?.includes('*')) return false
|
|
1081
1118
|
if (navType && page.hideIn?.includes(navType)) return false
|
|
1082
1119
|
}
|
|
1083
1120
|
|