@uniweb/core 0.7.15 → 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.15",
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,8 +30,8 @@
30
30
  "vitest": "^4.1.7"
31
31
  },
32
32
  "dependencies": {
33
- "@uniweb/semantic-parser": "1.1.17",
34
- "@uniweb/theming": "0.1.4"
33
+ "@uniweb/theming": "0.1.5",
34
+ "@uniweb/semantic-parser": "1.1.17"
35
35
  },
36
36
  "scripts": {
37
37
  "test": "vitest run"
@@ -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')
@@ -406,11 +412,13 @@ export default class Page {
406
412
  /**
407
413
  * Check if page should appear in a named nav area ('header', 'footer', or any
408
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.
409
417
  * @param {string} area
410
418
  * @returns {boolean}
411
419
  */
412
420
  showInNav(area) {
413
- return !this.hidden && !this.hideIn.includes(area)
421
+ return !this.hidden && !this.hideIn.includes('*') && !this.hideIn.includes(area)
414
422
  }
415
423
 
416
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