@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.14",
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.4",
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)
@@ -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
- // Navigation visibility. `hidden` excludes the page from all nav; `hideIn` is a
52
- // per-area denylist (layout-area names, e.g. ['header','footer']). The legacy
53
- // hideInHeader/hideInFooter booleans are folded into hideIn and kept as derived
54
- // accessors for back-compat.
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
- // navType is the requested nav area ('header'/'footer'/any foundation area);
1080
- // hideIn lists the areas this page is suppressed from.
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