@uniweb/core 0.19.0 → 0.21.0

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/src/website.js CHANGED
@@ -11,7 +11,9 @@ import FetcherDispatcher from './fetcher-dispatcher.js'
11
11
  import ObservableState from './observable-state.js'
12
12
  import { normalizeSeo } from './seo.js'
13
13
  import { resolveDefaultLocale, localeLabel } from './locale-config.js'
14
- import { matchDynamicRoute, decodeRouteValue } from './route-match.js'
14
+ import { matchDynamicRoute, decodeRouteValue, routePatternToRegex, splitPathCapture, routeParamValue } from './route-match.js'
15
+ import { resolveFetchConfigs } from './fetch-config.js'
16
+ import { buildDetailConfig } from './detail-url.js'
15
17
  import { resolveService } from './services.js'
16
18
 
17
19
  /**
@@ -558,14 +560,36 @@ export default class Website {
558
560
  pageData.route = concreteRoute
559
561
  pageData.isDynamic = false // No longer a template
560
562
 
561
- const paramName = Object.keys(params)[0]
562
- const paramValue = Object.values(params)[0]
563
+ // The route's variables, and the param the record is delivered by.
564
+ //
565
+ // `[slug]` — the one capture, under the folder's own label: `paramName` is
566
+ // that label and the record is matched on `item[paramName]`.
567
+ //
568
+ // `[...path]` — the capture is split by the rule in `route-match.js`
569
+ // (`:path` the whole capture · `:dir` everything before the last segment ·
570
+ // `:slug` the last segment); the record is delivered by `slug`, its handle,
571
+ // exactly as under `[slug]`, and `path` / `dir` exist for a query to bind
572
+ // (`scope: :dir`, `where: { tag: :dir }`). Ruled 2026-09-04 [Diego]: the
573
+ // variables are standard, never author-named.
574
+ const { catchAll } = routePatternToRegex(templatePage.route)
575
+ let variables = { ...params }
576
+ let paramName
577
+ let paramValue
578
+ if (catchAll && params[catchAll] !== undefined) {
579
+ const parts = splitPathCapture(params[catchAll])
580
+ variables = { ...params, ...parts }
581
+ paramName = originalData.paramName || 'slug'
582
+ paramValue = parts.slug
583
+ } else {
584
+ paramName = originalData.paramName || Object.keys(params)[0]
585
+ paramValue = params[paramName]
586
+ }
563
587
  const pluralSchema = originalData.parentSchema // e.g., 'articles'
564
588
 
565
589
  // Store dynamic context for components to access
566
590
  pageData.dynamicContext = {
567
591
  templateRoute: templatePage.route,
568
- params,
592
+ params: variables,
569
593
  paramName,
570
594
  paramValue,
571
595
  schema: pluralSchema,
@@ -580,32 +604,65 @@ export default class Website {
580
604
 
581
605
  // Try to resolve page metadata from DataStore
582
606
  // Look up the parent page's fetch config to find data in the store
583
- const parentRoute = templatePage.route.replace(/\/:[\w]+$/, '') || '/'
607
+ // The template's parent: the route without its `:param` — or `:path*` — tail.
608
+ const parentRoute = templatePage.route.replace(/\/:[\w-]+\*?$/, '') || '/'
584
609
  const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
585
610
 
586
611
  if (parentPage && pluralSchema) {
587
- // Find the records from the parent's fetch config via the dispatcher's
588
- // peek (sync cache probe). Used to populate the page title / notFound
589
- // flag on dynamic pages before the page instance is constructed.
612
+ // Find the record the page is ABOUT via the dispatcher's peek (a sync
613
+ // cache probe), to set the page title / description / notFound flag
614
+ // before the page instance is constructed.
615
+ //
616
+ // ⛔ RESOLVED THE WAY THE ENTITY STORE RESOLVES IT, not the raw declaration.
617
+ // Until 2026-09-04 this peeked `parentPage.fetch` as authored — `{ query,
618
+ // path, as }` — while the store writes under the RESOLVED config: on a door
619
+ // lane that carries `door` and no `path`, and on a non-default locale a
620
+ // `/fr/data/…` path. Two different keys for one dataset, so the probe
621
+ // missed on exactly those lanes: no title, no not-found, and the page was
622
+ // never cached (`recordsLoaded` false on every visit). Silent, on a
623
+ // visitor's page — the "write key ≠ read key" failure.
590
624
  const parentFetch = parentPage.fetch
591
625
  let items = []
626
+ let currentItem = null
592
627
 
593
628
  if (parentFetch && this.fetcher) {
594
629
  // ⛔ `as` is the binding key. This matched on `schema` alone until
595
630
  // 2026-09-02 — which, once the alias went, would have found nothing:
596
631
  // `items` stays `[]` and the page reports "Not found" for a record that
597
632
  // exists. Silent, and on a visitor's page.
598
- const keyOf = (f) => f?.as
599
- const fetchConfig = Array.isArray(parentFetch)
600
- ? parentFetch.find((f) => keyOf(f) === pluralSchema)
601
- : (keyOf(parentFetch) === pluralSchema ? parentFetch : null)
633
+ const fetchConfig = resolveFetchConfigs([parentFetch], {
634
+ schemas: [pluralSchema],
635
+ locale: this.getActiveLocale(),
636
+ defaultLocale: this.getDefaultLocale(),
637
+ queries: this.config?.queries ?? null,
638
+ records: this.config?.records ?? null,
639
+ variables,
640
+ }).get(pluralSchema)
602
641
  if (fetchConfig) {
603
- const cached = this.fetcher.peek(fetchConfig, { website: this })
642
+ const ctx = { website: this }
643
+ // ⭐ The page is about ONE record, so ask for that record first: a
644
+ // cached detail fetch (a live lane's record address, a deferred
645
+ // query's per-record file) carries the title even when the list was
646
+ // never fetched — a cold load on a detail URL — where a scan of the
647
+ // list finds nothing and silently sets no title (F3, 2026-09-04).
648
+ const detailCfg = fetchConfig.detail
649
+ ? buildDetailConfig(fetchConfig, { paramName, paramValue })
650
+ : null
651
+ const detailCached = detailCfg ? this.fetcher.peek(detailCfg, ctx) : null
652
+ // A door answers the record question as a list of one (a question's
653
+ // answer is always a list); a per-record file answers the bare record.
654
+ const raw = detailCached?.data
655
+ const record = Array.isArray(raw) ? raw[0] : raw
656
+ if (record && typeof record === 'object') currentItem = record
657
+
658
+ const cached = this.fetcher.peek(fetchConfig, ctx)
604
659
  items = Array.isArray(cached?.data) ? cached.data : []
605
660
  }
606
661
  }
607
662
 
608
- const currentItem = items.find(item => String(item[paramName]) === String(paramValue))
663
+ if (!currentItem) {
664
+ currentItem = items.find(item => String(routeParamValue(item, paramName)) === String(paramValue)) ?? null
665
+ }
609
666
 
610
667
  if (currentItem) {
611
668
  if (currentItem.title) pageData.title = currentItem.title
@@ -624,7 +681,7 @@ export default class Website {
624
681
  // { paramName, paramValue, schema }; the record reaches components via
625
682
  // content.data, siblings via `fetch: { refine: true, detail: false }`).
626
683
  // The local `currentItem`/`items` above drive title/description/notFound.
627
- pageData._recordsLoaded = items.length > 0
684
+ pageData._recordsLoaded = items.length > 0 || currentItem !== null
628
685
  }
629
686
 
630
687
  // Create the page instance
@@ -814,6 +871,31 @@ export default class Website {
814
871
  return resolvedHref
815
872
  }
816
873
 
874
+ /**
875
+ * The route template that renders ONE record of a binding key — `{ route,
876
+ * paramName }` for the `[param]` page whose parent query lands under `key`,
877
+ * or null when the site routes no detail page over it.
878
+ *
879
+ * ⭐ This is how a caller outside a template page learns which record field
880
+ * the site's URL is built on: `kit`'s `useEntityDetail` asks it so a hover
881
+ * card and the page it links to address the record by the SAME field. It
882
+ * hardcoded `slug` until 2026-09-04, which was quietly wrong on any site
883
+ * routing `[id]`. The first template found
884
+ * wins, matching `parentSchema`'s own rule that one key indexes one template.
885
+ *
886
+ * @param {string} key - a binding key (`content.data.<key>`)
887
+ * @returns {{ route: string, paramName: string } | null}
888
+ */
889
+ detailTemplateFor(key) {
890
+ if (!key) return null
891
+ for (const data of this._dynamicPageData.values()) {
892
+ if (data?.parentSchema === key && data.paramName) {
893
+ return { route: data.route, paramName: data.paramName }
894
+ }
895
+ }
896
+ return null
897
+ }
898
+
817
899
  /**
818
900
  * Resolve a `page:<stable_id>` detail-page reference (from a fetch config's
819
901
  * `detailPage`) to a locale-specific route TEMPLATE, e.g. '/blog/:slug'. The
@@ -1,59 +0,0 @@
1
- /**
2
- * Request style — how the default fetcher reshapes a normalized request
3
- * into wire format: which operators become URL params, which go into a
4
- * body, what envelope the response carries.
5
- *
6
- * One style ships: `json-body`, the framework's own conventions, and it
7
- * is the only wire the default fetcher speaks. A backend with another
8
- * dialect is reached through a named transport — shipped by the
9
- * foundation, or by an extension the site selects per schema in
10
- * `site.yml fetcher.transports` — never through a second built-in style.
11
- * Two vendor dialects, `flat-query` and `strapi`, shipped here from
12
- * `@uniweb/core` 0.7.1 and were removed: a third party's wire is not a
13
- * framework concern, and core is loaded by every site and never
14
- * tree-shaken.
15
- *
16
- * `site.yml fetcher.request.style` is still read, for one reason: a site
17
- * that names a style the framework does not ship must be told. Falling
18
- * back silently would send the default wire to a backend that does not
19
- * speak it — the request succeeds and the data is wrong.
20
- *
21
- * Internal to @uniweb/core. Consumed by @uniweb/runtime's default-fetcher.
22
- */
23
-
24
- import { jsonBody } from './json-body.js'
25
-
26
- const unknownStyleMessage = (name) =>
27
- `[default-fetcher] unknown request style "${name}". The framework ships one wire, ` +
28
- `"json-body"; a backend with a different dialect is reached through a named transport ` +
29
- `(site.yml fetcher.transports), shipped by the foundation or by an extension.`
30
-
31
- /**
32
- * Resolve the request style. No name, or `json-body`, returns the one
33
- * shipped style. Any other name is a site declaring a wire dialect the
34
- * framework does not ship: in dev this throws, so the site does not boot
35
- * on the wrong wire; in production it logs an error once and falls back
36
- * to `json-body`, so the site still renders.
37
- *
38
- * @param {string|undefined|null} name
39
- * @param {{ dev?: boolean }} [options]
40
- * @returns {Object} A style module.
41
- * @throws {Error} in dev, on a name that is not `json-body`.
42
- */
43
- export function resolveStyle(name, { dev = false } = {}) {
44
- if (!name || name === jsonBody.name) return jsonBody
45
- if (dev) {
46
- const err = new Error(unknownStyleMessage(name))
47
- err.code = 'UNKNOWN_REQUEST_STYLE'
48
- throw err
49
- }
50
- if (!erroredUnknownStyles.has(name)) {
51
- erroredUnknownStyles.add(name)
52
- console.error(unknownStyleMessage(name) + ' Falling back to "json-body".')
53
- }
54
- return jsonBody
55
- }
56
-
57
- const erroredUnknownStyles = new Set()
58
-
59
- export { jsonBody }
@@ -1,117 +0,0 @@
1
- /**
2
- * json-body — the framework's general-purpose request style.
3
- *
4
- * Ambient default when the site doesn't pick a style. Speaks the
5
- * framework's own conventions:
6
- *
7
- * GET — operators travel as URL params prefixed with underscore:
8
- * ?_where=<JSON.stringify(predicate)>
9
- * ?_limit=N
10
- * ?_sort=field:dir (comma-separated for multi-key)
11
- * The leading underscore avoids collision with backend-
12
- * specific query params the author may have included in `url:`.
13
- *
14
- * POST — operators merge as top-level keys into an object body,
15
- * alongside any author-supplied body. Content-Type defaults
16
- * to `application/json` unless the site set a different one.
17
- * String POST bodies (rare; typically GraphQL-only) don't
18
- * receive operator merge — the string is sent as-is.
19
- *
20
- * Operator name renames are applied from the `rename:` map passed in
21
- * context. Shallow substitutions only — `rename: { limit: pageSize }`
22
- * swaps the wire name `_limit` → `pageSize` on GET, or the body key
23
- * `limit` → `pageSize` on POST. The operator identity (what `limit`
24
- * means in the query) does not change.
25
- *
26
- * Internal module. Accessed by `default-fetcher.js` via the registry;
27
- * never imported directly from outside `@uniweb/runtime`.
28
- */
29
-
30
- export const jsonBody = {
31
- name: 'json-body',
32
-
33
- // Which operators this style knows how to push. The effective push set
34
- // is the intersection of this and the site's `supports:` list.
35
- canPush: new Set(['where', 'limit', 'sort']),
36
-
37
- // Default response envelope. null = no wrapper (a plain JSON payload).
38
- defaultEnvelope: null,
39
-
40
- /**
41
- * Encode a request against the json-body conventions.
42
- *
43
- * @param {Object} request - The normalized fetch request.
44
- * @param {Object} ctx
45
- * @param {'GET'|'POST'} ctx.method - The method the fetcher chose.
46
- * @param {Set<string>} ctx.pushCandidates - Operators present on the
47
- * request AND listed in the site's `supports:`. Style may choose to
48
- * push all, some, or none of these based on what it knows how to
49
- * express on the wire.
50
- * @param {Object|null} ctx.rename - Optional { operator → wireName } map.
51
- * @returns {{
52
- * queryParams: Array<[string, string]>,
53
- * bodyMerge: Object|null,
54
- * pushed: Set<string>,
55
- * }}
56
- * queryParams — pairs appended to the URL's query string.
57
- * bodyMerge — object merged into the POST body, or null.
58
- * pushed — the operators that actually rode on the wire.
59
- * Feeds the fetcher's cache-key derivation and
60
- * runtime-fallback skip.
61
- */
62
- encode(request, { method, pushCandidates, rename }) {
63
- const pushed = new Set()
64
- const queryParams = []
65
- let bodyMerge = null
66
-
67
- if (method === 'GET') {
68
- if (pushCandidates.has('where') && request.where !== undefined) {
69
- queryParams.push([
70
- wireName('where', '_where', rename),
71
- JSON.stringify(request.where),
72
- ])
73
- pushed.add('where')
74
- }
75
- if (pushCandidates.has('limit') && request.limit !== undefined) {
76
- queryParams.push([
77
- wireName('limit', '_limit', rename),
78
- String(request.limit),
79
- ])
80
- pushed.add('limit')
81
- }
82
- if (pushCandidates.has('sort') && request.sort !== undefined) {
83
- queryParams.push([
84
- wireName('sort', '_sort', rename),
85
- String(request.sort),
86
- ])
87
- pushed.add('sort')
88
- }
89
- } else if (method === 'POST') {
90
- const merged = {}
91
- if (pushCandidates.has('where') && request.where !== undefined) {
92
- merged[wireName('where', 'where', rename)] = request.where
93
- pushed.add('where')
94
- }
95
- if (pushCandidates.has('limit') && request.limit !== undefined) {
96
- merged[wireName('limit', 'limit', rename)] = request.limit
97
- pushed.add('limit')
98
- }
99
- if (pushCandidates.has('sort') && request.sort !== undefined) {
100
- merged[wireName('sort', 'sort', rename)] = request.sort
101
- pushed.add('sort')
102
- }
103
- if (Object.keys(merged).length > 0) bodyMerge = merged
104
- }
105
-
106
- return { queryParams, bodyMerge, pushed }
107
- },
108
- }
109
-
110
- function wireName(operator, defaultWire, rename) {
111
- if (rename && typeof rename[operator] === 'string' && rename[operator].length > 0) {
112
- return rename[operator]
113
- }
114
- return defaultWire
115
- }
116
-
117
- export default jsonBody