@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/package.json +2 -1
- package/src/block.js +12 -2
- package/src/datastore.js +146 -19
- package/src/detail-url.js +79 -21
- package/src/entity-store.js +149 -35
- package/src/fetch-config.js +175 -27
- package/src/fetcher-dispatcher.js +12 -0
- package/src/index.js +11 -12
- package/src/page.js +6 -0
- package/src/query-address.js +51 -80
- package/src/route-match.js +196 -18
- package/src/sort.js +116 -0
- package/src/website.js +97 -15
- package/src/request-styles/index.js +0 -59
- package/src/request-styles/json-body.js +0 -117
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
|
588
|
-
//
|
|
589
|
-
//
|
|
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
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
: (
|
|
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
|
|
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
|
-
|
|
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
|