@uniweb/core 0.13.1 → 0.14.1
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 +5 -4
- package/src/block.js +2 -2
- package/src/data-paths.js +26 -21
- package/src/datastore.js +24 -1
- package/src/detail-url.js +21 -21
- package/src/entity-store.js +37 -34
- package/src/fetch-config.js +42 -37
- package/src/fetcher-dispatcher.js +1 -1
- package/src/index.js +4 -4
- package/src/{collection-address.js → query-address.js} +19 -19
- package/src/request-styles/strapi.js +2 -2
- package/src/uniweb.js +9 -0
- package/src/website.js +9 -9
- package/src/where.js +2 -2
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.js",
|
|
8
8
|
"./base-path": "./src/base-path.js",
|
|
9
|
-
"./collection-address": "./src/
|
|
9
|
+
"./collection-address": "./src/query-address.js",
|
|
10
|
+
"./query-address": "./src/query-address.js",
|
|
10
11
|
"./data-paths": "./src/data-paths.js",
|
|
11
12
|
"./detail-url": "./src/detail-url.js",
|
|
12
13
|
"./fetch-config": "./src/fetch-config.js",
|
|
@@ -41,8 +42,8 @@
|
|
|
41
42
|
"vitest": "^4.1.7"
|
|
42
43
|
},
|
|
43
44
|
"dependencies": {
|
|
44
|
-
"@uniweb/
|
|
45
|
-
"@uniweb/
|
|
45
|
+
"@uniweb/theming": "^0.1.15",
|
|
46
|
+
"@uniweb/semantic-parser": "^1.3.1"
|
|
46
47
|
},
|
|
47
48
|
"scripts": {
|
|
48
49
|
"test": "vitest run"
|
package/src/block.js
CHANGED
|
@@ -87,7 +87,7 @@ export default class Block {
|
|
|
87
87
|
|
|
88
88
|
// Content structure
|
|
89
89
|
// The content can be:
|
|
90
|
-
// 1. Raw ProseMirror content (from
|
|
90
|
+
// 1. Raw ProseMirror content (from a record)
|
|
91
91
|
// 2. Pre-parsed content with main/items structure
|
|
92
92
|
// For now, store raw and parse on demand
|
|
93
93
|
//
|
|
@@ -358,7 +358,7 @@ export default class Block {
|
|
|
358
358
|
*
|
|
359
359
|
* Supports multiple input shapes:
|
|
360
360
|
* 1. Pre-parsed groups structure (from the editor)
|
|
361
|
-
* 2. ProseMirror document (from markdown
|
|
361
|
+
* 2. ProseMirror document (from a markdown record)
|
|
362
362
|
* 3. Wrapped ProseMirror document (content-API format)
|
|
363
363
|
* 4. Plain object (passed through directly)
|
|
364
364
|
*
|
package/src/data-paths.js
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compiled-
|
|
3
|
-
*
|
|
2
|
+
* Compiled-query paths — the ONE home for the URL and directory convention that
|
|
3
|
+
* the build emits and every fetcher requests.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ `<name>` IS A QUERY'S NAME. `/data/<name>.json` is a named query's
|
|
6
|
+
* MATERIALIZATION — the answer when no host declares a live lane — and never the
|
|
7
|
+
* definition of anything. The records themselves live in `entities/{schema}/`,
|
|
8
|
+
* and `records.yml` decides which of them are published.
|
|
4
9
|
*
|
|
5
10
|
* Why this module exists. The path `/data/<name>.json` was a bare string
|
|
6
11
|
* literal in six places across three packages: the build wrote it
|
|
7
|
-
* (
|
|
12
|
+
* (the query processor), the build resolved the query shorthand to it
|
|
8
13
|
* (`data-fetcher.js`), core injected the per-record default
|
|
9
14
|
* (`fetch-config.js applyDeferredDetail`), core gated locale-prefixing on it
|
|
10
15
|
* (`fetch-config.js localizeConfig`), the dev server matched it with a regex
|
|
@@ -27,11 +32,11 @@
|
|
|
27
32
|
* inconsistency invites a rename. It has been proposed and declined. Those
|
|
28
33
|
* are machinery: an endpoint and bundler artifacts, which no visitor should
|
|
29
34
|
* land on and which an underscore correctly marks as internal. Compiled
|
|
30
|
-
*
|
|
35
|
+
* this JSON is the opposite — it is the site's own content, the same
|
|
31
36
|
* records an agent that found the site through `llms.txt` may reasonably
|
|
32
37
|
* fetch directly. `/data/articles.json` is a legitimate public address, and
|
|
33
38
|
* `data` is a legitimate page route; neither collides with the other, since
|
|
34
|
-
* pages emit `.html` and
|
|
39
|
+
* pages emit `.html` and queries emit `.json`. Prefixing it would say
|
|
35
40
|
* "internal" about something that is not.
|
|
36
41
|
*
|
|
37
42
|
* Zero-dependency leaf, like `./locale-config.js`, so a consumer that must
|
|
@@ -47,26 +52,26 @@
|
|
|
47
52
|
export const DATA_DIR = 'data'
|
|
48
53
|
|
|
49
54
|
/**
|
|
50
|
-
* The URL prefix every compiled-
|
|
55
|
+
* The URL prefix every compiled-query request carries. Note the
|
|
51
56
|
* trailing slash: `isDataUrl` is a prefix test, and without it `/database`
|
|
52
57
|
* would match.
|
|
53
58
|
*/
|
|
54
59
|
export const DATA_URL_PREFIX = `/${DATA_DIR}/`
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
|
-
* URL of a
|
|
58
|
-
* `deferred:` fields stripped when the
|
|
62
|
+
* URL of a query's cascade payload — every record it returns, with
|
|
63
|
+
* `deferred:` fields stripped when the query declares them.
|
|
59
64
|
*
|
|
60
|
-
* @param {string} name - The
|
|
65
|
+
* @param {string} name - The query name.
|
|
61
66
|
* @returns {string} e.g. `/data/articles.json`
|
|
62
67
|
*/
|
|
63
|
-
export function
|
|
68
|
+
export function queryDataUrl(name) {
|
|
64
69
|
return `${DATA_URL_PREFIX}${name}.json`
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
68
73
|
* URL of one record's full payload — every field, including deferred ones.
|
|
69
|
-
* Emitted per
|
|
74
|
+
* Emitted per record only when the query declares `deferred:`.
|
|
70
75
|
*
|
|
71
76
|
* Takes either a concrete slug (kit's `useEntityDetail`, which holds a
|
|
72
77
|
* record) or the literal placeholder `{slug}` (core's `applyDeferredDetail`,
|
|
@@ -75,21 +80,21 @@ export function collectionDataUrl(name) {
|
|
|
75
80
|
* function does not encode, matching the behavior of the call sites it
|
|
76
81
|
* replaced.
|
|
77
82
|
*
|
|
78
|
-
* @param {string}
|
|
83
|
+
* @param {string} query - The query name.
|
|
79
84
|
* @param {string} slug - A record slug, or a `{param}` placeholder.
|
|
80
85
|
* @returns {string} e.g. `/data/articles/design-tips.json`
|
|
81
86
|
*/
|
|
82
|
-
export function recordDataUrl(
|
|
83
|
-
return `${DATA_URL_PREFIX}${
|
|
87
|
+
export function recordDataUrl(query, slug) {
|
|
88
|
+
return `${DATA_URL_PREFIX}${query}/${slug}.json`
|
|
84
89
|
}
|
|
85
90
|
|
|
86
91
|
/**
|
|
87
|
-
* The inverse of `
|
|
88
|
-
* path so a caller can look it up among the declared
|
|
92
|
+
* The inverse of `queryDataUrl` — recover a query name from a fetch
|
|
93
|
+
* path so a caller can look it up among the declared queries.
|
|
89
94
|
*
|
|
90
95
|
* Best-effort by design, and the caller decides what a miss means: a path
|
|
91
|
-
* outside the compiled
|
|
92
|
-
* suffix removed, which simply will not match any declared
|
|
96
|
+
* outside the compiled tree is returned with only its `.json`
|
|
97
|
+
* suffix removed, which simply will not match any declared query and
|
|
93
98
|
* lets the caller fall through to reading the file. Nested names round-trip
|
|
94
99
|
* (`/data/archive/2024/posts.json` → `archive/2024/posts`).
|
|
95
100
|
*
|
|
@@ -98,16 +103,16 @@ export function recordDataUrl(collection, slug) {
|
|
|
98
103
|
* how `validate-data.js` came to hold a sixth copy of it.
|
|
99
104
|
*
|
|
100
105
|
* @param {string} path - A fetch config's `path`.
|
|
101
|
-
* @returns {string} The derived
|
|
106
|
+
* @returns {string} The derived query name.
|
|
102
107
|
*/
|
|
103
|
-
export function
|
|
108
|
+
export function queryNameFromUrl(path) {
|
|
104
109
|
if (typeof path !== 'string') return ''
|
|
105
110
|
// DATA_DIR is a plain identifier segment, so it needs no regex escaping.
|
|
106
111
|
return path.replace(new RegExp(`^/?${DATA_DIR}/`), '').replace(/\.json$/i, '')
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
/**
|
|
110
|
-
* Whether a fetch config's `path` addresses compiled
|
|
115
|
+
* Whether a fetch config's `path` addresses compiled query data.
|
|
111
116
|
*
|
|
112
117
|
* Used to scope behavior that only makes sense for build-emitted files —
|
|
113
118
|
* locale prefixing in particular, which must not touch a remote `url:`
|
package/src/datastore.js
CHANGED
|
@@ -62,7 +62,7 @@ export default class DataStore {
|
|
|
62
62
|
*
|
|
63
63
|
* Two forms:
|
|
64
64
|
* - `subscribe(fn)` — fires after every successful `set()` (all keys).
|
|
65
|
-
* - `subscribe(key, fn)` — fires only when `set(key, ...)` is called.
|
|
65
|
+
* - `subscribe(key, fn)` — fires only when `set(key, ...)` or `delete(key)` is called.
|
|
66
66
|
*
|
|
67
67
|
* The global form is useful for debugging / blanket observers. The keyed
|
|
68
68
|
* form is what Layer-3 kit hooks (`useFetched`, `useCacheEntry`) use so
|
|
@@ -131,6 +131,29 @@ export default class DataStore {
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Drop one entry, and any in-flight record for the same key.
|
|
136
|
+
*
|
|
137
|
+
* Fires the key's subscribers (and the global ones) so an observer re-reads
|
|
138
|
+
* and sees the absence, exactly as it would see a write. Exists for the case
|
|
139
|
+
* `clear()` is too blunt for: one consumer's entries must leave memory — a
|
|
140
|
+
* viewer's records at sign-out — while everything else stays warm.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} key
|
|
143
|
+
* @returns {boolean} true if an entry was removed
|
|
144
|
+
*/
|
|
145
|
+
delete(key) {
|
|
146
|
+
const had = this._cache.delete(key)
|
|
147
|
+
this._inflight.delete(key)
|
|
148
|
+
if (!had) return false
|
|
149
|
+
for (const fn of this._listeners) fn()
|
|
150
|
+
const keyed = this._keyedListeners.get(key)
|
|
151
|
+
if (keyed) {
|
|
152
|
+
for (const fn of keyed) fn()
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
/**
|
|
135
158
|
* In-flight fetch registry — used by the dispatcher to dedup concurrent
|
|
136
159
|
* requests and collect abort signals so the underlying fetch is cancelled
|
package/src/detail-url.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Detail-record resolution — the ONE home for turning a
|
|
2
|
+
* Detail-record resolution — the ONE home for turning a query's `detail:`
|
|
3
3
|
* declaration plus a dynamic route's params into a fetch config.
|
|
4
4
|
*
|
|
5
5
|
* Why this is a subpath rather than an EntityStore internal. A host that
|
|
@@ -47,31 +47,31 @@ function paramContext(paramName, paramValue) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* Build a detail-URL fetch config from a
|
|
50
|
+
* Build a detail-URL fetch config from a query config + dynamic context.
|
|
51
51
|
*
|
|
52
52
|
* Four forms of `detail:`:
|
|
53
53
|
* - `'rest'` — append paramValue as a path segment.
|
|
54
54
|
* - `'query'` — append `?paramName=paramValue`.
|
|
55
55
|
* - `'/articles/{slug}'` — custom URL pattern with {paramName} placeholders,
|
|
56
56
|
* or the generic `{param}` alias (see below).
|
|
57
|
-
* - `{ body, envelope }` — object form. Reuses the
|
|
57
|
+
* - `{ body, envelope }` — object form. Reuses the query's url /
|
|
58
58
|
* method / headers / auth; adds per-detail
|
|
59
59
|
* body (with placeholder substitution) and
|
|
60
60
|
* per-detail envelope.
|
|
61
61
|
*
|
|
62
|
-
* Returns `null` — never throws — when the
|
|
63
|
-
* when the dynamic context carries no param, or when the
|
|
62
|
+
* Returns `null` — never throws — when the query declares no `detail:`,
|
|
63
|
+
* when the dynamic context carries no param, or when the query has
|
|
64
64
|
* neither `url:` nor `path:` to build from. A caller treats `null` as "this
|
|
65
|
-
*
|
|
65
|
+
* query has no separate detail fetch", which is the common case.
|
|
66
66
|
*
|
|
67
|
-
* @param {Object}
|
|
67
|
+
* @param {Object} queryConfig - A resolved fetch config for the query
|
|
68
68
|
* (post-`resolveFetchConfigs`, so `detail` may have been auto-injected for a
|
|
69
|
-
* `deferred:`
|
|
69
|
+
* `deferred:` query — see `./fetch-config.js`).
|
|
70
70
|
* @param {{ paramName: string, paramValue: string }} dynamicContext
|
|
71
71
|
* @returns {Object|null} A fetch config carrying `url` or `path`, or null.
|
|
72
72
|
*/
|
|
73
|
-
export function buildDetailConfig(
|
|
74
|
-
const { detail } =
|
|
73
|
+
export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
74
|
+
const { detail } = queryConfig
|
|
75
75
|
if (!detail) return null
|
|
76
76
|
const { paramName, paramValue } = dynamicContext
|
|
77
77
|
if (!paramName || paramValue === undefined) return null
|
|
@@ -80,28 +80,28 @@ export function buildDetailConfig(collectionConfig, dynamicContext) {
|
|
|
80
80
|
// kind: an `endpoint` carries remote semantics the fetcher decides on, so
|
|
81
81
|
// returning a detail as `path` would silently drop operator pushdown and the
|
|
82
82
|
// site's static headers for exactly the request that is one record.
|
|
83
|
-
const baseUrl =
|
|
83
|
+
const baseUrl = queryConfig.endpoint || queryConfig.url || queryConfig.path
|
|
84
84
|
if (!baseUrl) return null
|
|
85
|
-
const addressKey =
|
|
85
|
+
const addressKey = queryConfig.endpoint
|
|
86
86
|
? 'endpoint'
|
|
87
|
-
:
|
|
87
|
+
: queryConfig.url
|
|
88
88
|
? 'url'
|
|
89
89
|
: 'path'
|
|
90
90
|
|
|
91
|
-
// Object form: `detail: { body, envelope }`. Reuses
|
|
91
|
+
// Object form: `detail: { body, envelope }`. Reuses the query's URL +
|
|
92
92
|
// method + headers + auth. The body is placeholder-substituted against
|
|
93
93
|
// the dynamic context so `body: { variables: { slug: "{slug}" } }` works.
|
|
94
94
|
if (detail && typeof detail === 'object') {
|
|
95
95
|
const out = {
|
|
96
96
|
[addressKey]: baseUrl,
|
|
97
|
-
schema:
|
|
98
|
-
transform:
|
|
97
|
+
schema: queryConfig.schema,
|
|
98
|
+
transform: queryConfig.transform,
|
|
99
99
|
}
|
|
100
|
-
if (
|
|
100
|
+
if (queryConfig.method) out.method = queryConfig.method
|
|
101
101
|
if (detail.body !== undefined) {
|
|
102
102
|
out.body = substitutePlaceholders(detail.body, paramContext(paramName, paramValue), { encode: false })
|
|
103
|
-
} else if (
|
|
104
|
-
out.body = substitutePlaceholders(
|
|
103
|
+
} else if (queryConfig.body !== undefined) {
|
|
104
|
+
out.body = substitutePlaceholders(queryConfig.body, paramContext(paramName, paramValue), { encode: false })
|
|
105
105
|
}
|
|
106
106
|
if (detail.envelope) out.envelope = detail.envelope
|
|
107
107
|
return out
|
|
@@ -145,7 +145,7 @@ export function buildDetailConfig(collectionConfig, dynamicContext) {
|
|
|
145
145
|
|
|
146
146
|
return {
|
|
147
147
|
[addressKey]: detailUrl,
|
|
148
|
-
schema:
|
|
149
|
-
transform:
|
|
148
|
+
schema: queryConfig.schema,
|
|
149
|
+
transform: queryConfig.transform,
|
|
150
150
|
}
|
|
151
151
|
}
|
package/src/entity-store.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* asks the Website's FetcherDispatcher to execute them, and assembles the
|
|
6
6
|
* data payload passed to `prepare-props`.
|
|
7
7
|
*
|
|
8
|
-
* The cascade, localization, detail-query handling, and
|
|
8
|
+
* The cascade, localization, detail-query handling, and list-first
|
|
9
9
|
* content-gate logic all live here — unchanged from the pre-refactor model.
|
|
10
10
|
* What changed: EntityStore no longer talks to DataStore directly. It calls
|
|
11
11
|
* `website.fetcher.peek(request, ctx)` for the sync path (resolve) and
|
|
@@ -127,9 +127,12 @@ export default class EntityStore {
|
|
|
127
127
|
schemas: requested,
|
|
128
128
|
locale: website?.getActiveLocale?.() ?? null,
|
|
129
129
|
defaultLocale: website?.getDefaultLocale?.() ?? null,
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
130
|
+
// ⚠️ `queries`, matching `resolveFetchConfigs`. This passed `collections`
|
|
131
|
+
// after the payload key was renamed — a dead option name, silently: the
|
|
132
|
+
// resolver simply saw no queries and stopped injecting `detail:`.
|
|
133
|
+
queries: website?.config?.queries ?? null,
|
|
134
|
+
// A host's live-records lane. Absent on every static site and on
|
|
135
|
+
// local dev, which is why `resolveQuerySource` treats absence as
|
|
133
136
|
// the ordinary case and reads the compiled artifact without comment.
|
|
134
137
|
records: website?.config?.records ?? null,
|
|
135
138
|
},
|
|
@@ -137,12 +140,12 @@ export default class EntityStore {
|
|
|
137
140
|
}
|
|
138
141
|
|
|
139
142
|
/**
|
|
140
|
-
* Post-process assembled
|
|
143
|
+
* Post-process assembled records: for each fetch config that declares a
|
|
141
144
|
* `detailPage` page-ref, resolve it to a locale route template (O(1), via the
|
|
142
145
|
* Website's `_pageIdMap`) and inject a `route` on each record — so a dynamic-list
|
|
143
|
-
* card links to the
|
|
146
|
+
* card links to the query's canonical detail page regardless of which page
|
|
144
147
|
* the list sits on. Runs after `data` is fully assembled, in BOTH the sync (peek)
|
|
145
|
-
* and async (fetch) paths. Replaces the old runtime `
|
|
148
|
+
* and async (fetch) paths. Replaces the old runtime `getQueryDetailRoute`
|
|
146
149
|
* page-tree scan. A dangling `detailPage` (unresolvable ref) is a no-op — the
|
|
147
150
|
* component degrades gracefully; records with a baked `route` (file lane) are kept.
|
|
148
151
|
*/
|
|
@@ -159,14 +162,14 @@ export default class EntityStore {
|
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
/**
|
|
162
|
-
* Build a detail-URL fetch config from a
|
|
165
|
+
* Build a detail-URL fetch config from a query config + dynamic context.
|
|
163
166
|
*
|
|
164
167
|
* Delegates to the exported resolver so a host fetching this record
|
|
165
168
|
* server-side reaches the identical rule — see `./detail-url.js` for why the
|
|
166
169
|
* four `detail:` forms are a contract rather than an implementation detail.
|
|
167
170
|
*/
|
|
168
|
-
_buildDetailConfig(
|
|
169
|
-
return buildDetailConfig(
|
|
171
|
+
_buildDetailConfig(queryConfig, dynamicContext) {
|
|
172
|
+
return buildDetailConfig(queryConfig, dynamicContext)
|
|
170
173
|
}
|
|
171
174
|
|
|
172
175
|
/**
|
|
@@ -219,9 +222,9 @@ export default class EntityStore {
|
|
|
219
222
|
const routeSchema = dynamicContext?.schema
|
|
220
223
|
|
|
221
224
|
for (const [schema, cfg] of configs) {
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
// refine detail:false — the
|
|
225
|
+
const isRouteQuery = dynamicContext && schema === routeSchema
|
|
226
|
+
if (isRouteQuery && !inheritDetail) {
|
|
227
|
+
// refine detail:false — the records minus the active one (related items).
|
|
225
228
|
const cached = dispatcher?.peek(cfg, ctx)
|
|
226
229
|
if (cached) {
|
|
227
230
|
const { paramName, paramValue } = dynamicContext
|
|
@@ -234,10 +237,10 @@ export default class EntityStore {
|
|
|
234
237
|
} else {
|
|
235
238
|
allCached = false
|
|
236
239
|
}
|
|
237
|
-
} else if (
|
|
240
|
+
} else if (isRouteQuery) {
|
|
238
241
|
// Detail page: deliver the focused record as a length-1 array under the
|
|
239
|
-
//
|
|
240
|
-
// others use the matched
|
|
242
|
+
// query key. A deferred/remote query fetches the full per-record;
|
|
243
|
+
// others use the matched record. Not found → [].
|
|
241
244
|
const cached = dispatcher?.peek(cfg, ctx)
|
|
242
245
|
if (cached) {
|
|
243
246
|
const { paramName, paramValue } = dynamicContext
|
|
@@ -283,7 +286,7 @@ export default class EntityStore {
|
|
|
283
286
|
|
|
284
287
|
/**
|
|
285
288
|
* Async fetch — dispatches missing configs through the FetcherDispatcher
|
|
286
|
-
* and assembles the result.
|
|
289
|
+
* and assembles the result. List-first detail ordering preserved.
|
|
287
290
|
*
|
|
288
291
|
* @param {Object} [options]
|
|
289
292
|
* @param {AbortSignal} [options.signal] - Forwarded to the dispatcher.
|
|
@@ -316,31 +319,31 @@ export default class EntityStore {
|
|
|
316
319
|
const routeSchema = dynamicContext?.schema
|
|
317
320
|
|
|
318
321
|
for (const [schema, cfg] of configs) {
|
|
319
|
-
const
|
|
320
|
-
if (
|
|
321
|
-
// refine detail:false — the
|
|
322
|
-
let
|
|
323
|
-
if (
|
|
322
|
+
const isRouteQuery = dynamicContext && schema === routeSchema
|
|
323
|
+
if (isRouteQuery && !inheritDetail) {
|
|
324
|
+
// refine detail:false — the records minus the active one.
|
|
325
|
+
let records = peekArray(dispatcher, cfg, ctx)
|
|
326
|
+
if (records === null) {
|
|
324
327
|
const result = await dispatcher.dispatch(cfg, ctx)
|
|
325
|
-
|
|
328
|
+
records = Array.isArray(result?.data) ? result.data : null
|
|
326
329
|
}
|
|
327
330
|
const { paramName, paramValue } = dynamicContext
|
|
328
|
-
let filtered = Array.isArray(
|
|
329
|
-
?
|
|
330
|
-
: (
|
|
331
|
+
let filtered = Array.isArray(records)
|
|
332
|
+
? records.filter((item) => String(item[paramName]) !== String(paramValue))
|
|
333
|
+
: (records ?? [])
|
|
331
334
|
if (order) filtered = this._sortItems(filtered, order)
|
|
332
335
|
data[schema] = limit && Array.isArray(filtered) ? filtered.slice(0, limit) : filtered
|
|
333
|
-
} else if (
|
|
334
|
-
// Detail page: focused record as a length-1 array under the
|
|
336
|
+
} else if (isRouteQuery) {
|
|
337
|
+
// Detail page: focused record as a length-1 array under the query key.
|
|
335
338
|
const { paramName, paramValue } = dynamicContext
|
|
336
339
|
|
|
337
|
-
let
|
|
338
|
-
if (
|
|
340
|
+
let records = peekArray(dispatcher, cfg, ctx)
|
|
341
|
+
if (records === null) {
|
|
339
342
|
const result = await dispatcher.dispatch(cfg, ctx)
|
|
340
|
-
|
|
343
|
+
records = Array.isArray(result?.data) ? result.data : null
|
|
341
344
|
}
|
|
342
345
|
|
|
343
|
-
const match =
|
|
346
|
+
const match = records?.find(
|
|
344
347
|
(item) => String(item[paramName]) === String(paramValue)
|
|
345
348
|
) ?? null
|
|
346
349
|
|
|
@@ -396,9 +399,9 @@ function peekArray(dispatcher, cfg, ctx) {
|
|
|
396
399
|
* Interpolate a record's fields into a detail-page route template to build its
|
|
397
400
|
* `route` (the canonical href for a card). `/blog/:slug` + `{ slug: 'a-post' }`
|
|
398
401
|
* → `/blog/a-post`. Returns a SHALLOW COPY with `route` added — never mutates the
|
|
399
|
-
* cached record (the same
|
|
402
|
+
* cached record (the same query may back several sections with different
|
|
400
403
|
* detail pages). Idempotent + back-compat: a record that already carries a `route`
|
|
401
|
-
* (the file lane bakes one via
|
|
404
|
+
* (the file lane bakes one via the query processor) is returned untouched. A
|
|
402
405
|
* `:param` with no matching record field → no `route` (graceful; degrades to the
|
|
403
406
|
* component's own fallback rather than emitting a broken href).
|
|
404
407
|
*/
|
package/src/fetch-config.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* "Which fetch configs apply here?" is a framework concept. Authors declare
|
|
5
5
|
* `fetch:` at the section, page, folder and site levels; the framework decides
|
|
6
6
|
* which declaration wins per schema, how a local data path is localized, and
|
|
7
|
-
* when a
|
|
7
|
+
* when a query with deferred fields gets a detail pattern injected.
|
|
8
8
|
*
|
|
9
9
|
* Every host that renders a page needs that answer — the browser runtime, the
|
|
10
10
|
* build-time prerenderer, and any server-side renderer. The rule had grown
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
* `resolveFetchConfigs`. That difference is real and stays with the caller.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
30
|
+
import { queryDataUrl, isDataUrl, recordDataUrl } from './data-paths.js'
|
|
31
|
+
import { resolveQueryAddress, resolveRecordAddressPattern } from './query-address.js'
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Is this fetch declaration a per-instance *refinement* of an ancestor's
|
|
@@ -66,14 +66,14 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Auto-inject `detail:` on a
|
|
69
|
+
* Auto-inject `detail:` on a query ref whose query declares
|
|
70
70
|
* `deferred:` fields.
|
|
71
71
|
*
|
|
72
|
-
* A deferred
|
|
73
|
-
* come from somewhere else. Two patterns, picked by what the
|
|
72
|
+
* A deferred query ships a lean list payload, so the full record has to
|
|
73
|
+
* come from somewhere else. Two patterns, picked by what the query
|
|
74
74
|
* declares:
|
|
75
75
|
*
|
|
76
|
-
* - the
|
|
76
|
+
* - the query has `detailUrl:` → use it verbatim (a remote source);
|
|
77
77
|
* - otherwise → `/data/<schema>/{slug}.json`, the per-record file emitted
|
|
78
78
|
* alongside the lean list.
|
|
79
79
|
*
|
|
@@ -83,39 +83,39 @@ function localizeConfig(cfg, locale, defaultLocale) {
|
|
|
83
83
|
* route's paramName is `slug` (the documented convention); a route using
|
|
84
84
|
* another param name needs an explicit author-written `detail:`.
|
|
85
85
|
* - Per-record files are not currently localized. A site needing localized
|
|
86
|
-
* deferred
|
|
86
|
+
* a deferred query writes its own `detail:` URL.
|
|
87
87
|
*
|
|
88
88
|
* An author-supplied `cfg.detail` always wins; this only fills the default.
|
|
89
|
-
* With no `
|
|
89
|
+
* With no `queries` map available the config passes through untouched —
|
|
90
90
|
* deferred-detail injection is an enhancement, never a correctness
|
|
91
|
-
* requirement, so a caller that does not have
|
|
91
|
+
* requirement, so a caller that does not have query metadata still gets
|
|
92
92
|
* a usable config. That matters for hosts whose content projection may not
|
|
93
|
-
* carry
|
|
93
|
+
* carry query metadata at all.
|
|
94
94
|
*
|
|
95
95
|
* @param {Object} cfg
|
|
96
|
-
* @param {Object|null}
|
|
96
|
+
* @param {Object|null} queries - the site's `config.queries` map
|
|
97
97
|
* @returns {Object} the original config, or a copy carrying `detail`
|
|
98
98
|
*/
|
|
99
|
-
function applyDeferredDetail(cfg,
|
|
99
|
+
function applyDeferredDetail(cfg, queries, records) {
|
|
100
100
|
if (cfg.detail !== undefined) return cfg
|
|
101
101
|
|
|
102
102
|
// ⭐ A lane's record address is injected whenever the lane declares one —
|
|
103
|
-
// NOT only for a `deferred:`
|
|
103
|
+
// NOT only for a `deferred:` query, and the difference is load-bearing.
|
|
104
104
|
//
|
|
105
105
|
// A live lane answers a list request at brief depth and a record request in
|
|
106
106
|
// full, so a detail page that filtered the list would render the brief and
|
|
107
107
|
// silently miss the body. And it cannot fall back to the rule below: the
|
|
108
|
-
// `deferred:` declaration lives in `config.
|
|
108
|
+
// `deferred:` declaration lives in `config.queries`, which a host's
|
|
109
109
|
// projection is not obliged to carry — so on such a host that rule can never
|
|
110
110
|
// fire, and this is the only way a detail page reaches a whole record.
|
|
111
111
|
if (cfg.endpoint) {
|
|
112
|
-
const recordPattern = resolveRecordAddressPattern(cfg.
|
|
112
|
+
const recordPattern = resolveRecordAddressPattern(cfg.query ?? cfg.schema, records)
|
|
113
113
|
if (recordPattern) return { ...cfg, detail: recordPattern }
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
const schema = cfg.schema
|
|
117
|
-
if (!schema || !
|
|
118
|
-
const collConfig =
|
|
117
|
+
if (!schema || !queries) return cfg
|
|
118
|
+
const collConfig = queries[schema]
|
|
119
119
|
if (!collConfig || typeof collConfig !== 'object') return cfg
|
|
120
120
|
const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
|
|
121
121
|
if (!deferred || deferred.length === 0) return cfg
|
|
@@ -126,10 +126,15 @@ function applyDeferredDetail(cfg, collections, records) {
|
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
/**
|
|
129
|
-
* Resolve a
|
|
129
|
+
* Resolve a query reference to something the fetcher can call.
|
|
130
130
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
131
|
+
* ⭐ ONE NAME END TO END. The author writes `query:` in queries.yml, the wire
|
|
132
|
+
* carries `query`, and this reads `query`. It said `collection` on the wire for
|
|
133
|
+
* a while, on the belief that the field was the backend's to name — measured
|
|
134
|
+
* otherwise: `fetch` is a blob they carry, not one they model.
|
|
135
|
+
*
|
|
136
|
+
* The author names a query; this decides where its records live, and there are
|
|
137
|
+
* exactly two answers:
|
|
133
138
|
*
|
|
134
139
|
* - a host declared a live lane (`config.records`) → an `endpoint`, final on
|
|
135
140
|
* arrival, which the fetcher calls without composing anything further;
|
|
@@ -139,24 +144,24 @@ function applyDeferredDetail(cfg, collections, records) {
|
|
|
139
144
|
* every site with no backend, which is the framework's default rather than a
|
|
140
145
|
* degraded mode — so an absent lane is silent, not warned.
|
|
141
146
|
*
|
|
142
|
-
* ⭐ `
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
147
|
+
* ⭐ `query` OUTRANKS a `path` sitting beside it, which matters because the sync
|
|
148
|
+
* producer emits both — `query` for a consumer that resolves it, `path` as the
|
|
149
|
+
* artifact address for one that cannot. Resolving whenever `query` is present is
|
|
150
|
+
* also what the build-time parser does (`parseFetchConfig` returns early on
|
|
151
|
+
* `query`, ignoring any `path`), so the two agree rather than disagreeing on a
|
|
152
|
+
* shape nobody hand-writes.
|
|
148
153
|
*/
|
|
149
|
-
function
|
|
150
|
-
if (typeof cfg.
|
|
154
|
+
function resolveQuerySource(cfg, records) {
|
|
155
|
+
if (typeof cfg.query !== 'string' || cfg.query.length === 0) return cfg
|
|
151
156
|
|
|
152
|
-
const endpoint =
|
|
157
|
+
const endpoint = resolveQueryAddress(cfg.query, records)
|
|
153
158
|
if (endpoint) {
|
|
154
159
|
// Drop the transitional `path`: two addresses on one request is an
|
|
155
160
|
// ambiguity the fetcher would have to break by accident of field order.
|
|
156
161
|
const { path, url, ...rest } = cfg
|
|
157
162
|
return { ...rest, endpoint }
|
|
158
163
|
}
|
|
159
|
-
return { ...cfg, path:
|
|
164
|
+
return { ...cfg, path: queryDataUrl(cfg.query) }
|
|
160
165
|
}
|
|
161
166
|
|
|
162
167
|
/**
|
|
@@ -179,9 +184,9 @@ function resolveCollectionSource(cfg, records) {
|
|
|
179
184
|
* Empty (the default) collects every schema found.
|
|
180
185
|
* @param {string|null} [options.locale] - the locale being rendered
|
|
181
186
|
* @param {string|null} [options.defaultLocale] - the site's default locale
|
|
182
|
-
* @param {Object|null} [options.
|
|
187
|
+
* @param {Object|null} [options.queries] - the site's `config.queries`
|
|
183
188
|
* @param {Object|null} [options.records] - the site's `config.records`, a host's
|
|
184
|
-
* live-
|
|
189
|
+
* live-records lane. Absent means the compiled artifact answers, which is
|
|
185
190
|
* the whole of what a site with no backend needs.
|
|
186
191
|
* @returns {Map<string, Object>} schema name → resolved config
|
|
187
192
|
*/
|
|
@@ -190,7 +195,7 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
190
195
|
schemas = [],
|
|
191
196
|
locale = null,
|
|
192
197
|
defaultLocale = null,
|
|
193
|
-
|
|
198
|
+
queries = null,
|
|
194
199
|
records = null,
|
|
195
200
|
} = options
|
|
196
201
|
|
|
@@ -205,10 +210,10 @@ export function resolveFetchConfigs(sources, options = {}) {
|
|
|
205
210
|
if (configs.has(cfg.schema)) continue
|
|
206
211
|
if (!collectAll && !schemas.includes(cfg.schema)) continue
|
|
207
212
|
// Address first: localization and deferred-detail both key on `path`,
|
|
208
|
-
// which a
|
|
209
|
-
const sourced =
|
|
213
|
+
// which a query ref does not have until this runs.
|
|
214
|
+
const sourced = resolveQuerySource(cfg, records)
|
|
210
215
|
const localized = localizeConfig(sourced, locale, defaultLocale)
|
|
211
|
-
configs.set(cfg.schema, applyDeferredDetail(localized,
|
|
216
|
+
configs.set(cfg.schema, applyDeferredDetail(localized, queries, records))
|
|
212
217
|
}
|
|
213
218
|
}
|
|
214
219
|
|
|
@@ -122,7 +122,7 @@ export default class FetcherDispatcher {
|
|
|
122
122
|
})
|
|
123
123
|
} catch (err) {
|
|
124
124
|
if (this._dev) {
|
|
125
|
-
console.warn('[FetcherDispatcher] extension transports
|
|
125
|
+
console.warn('[FetcherDispatcher] extension transports list threw:', err)
|
|
126
126
|
}
|
|
127
127
|
continue
|
|
128
128
|
}
|
package/src/index.js
CHANGED
|
@@ -21,9 +21,9 @@ export { default as ObservableState } from './observable-state.js'
|
|
|
21
21
|
// Utilities
|
|
22
22
|
export { substitutePlaceholders } from './substitute-placeholders.js'
|
|
23
23
|
export {
|
|
24
|
-
|
|
24
|
+
resolveQueryAddress,
|
|
25
25
|
resolveRecordAddressPattern,
|
|
26
|
-
} from './
|
|
26
|
+
} from './query-address.js'
|
|
27
27
|
export { resolveFetchConfigs } from './fetch-config.js'
|
|
28
28
|
export { buildDetailConfig } from './detail-url.js'
|
|
29
29
|
export {
|
|
@@ -36,9 +36,9 @@ export {
|
|
|
36
36
|
export {
|
|
37
37
|
DATA_DIR,
|
|
38
38
|
DATA_URL_PREFIX,
|
|
39
|
-
|
|
39
|
+
queryDataUrl,
|
|
40
40
|
recordDataUrl,
|
|
41
|
-
|
|
41
|
+
queryNameFromUrl,
|
|
42
42
|
isDataUrl
|
|
43
43
|
} from './data-paths.js'
|
|
44
44
|
export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resolve a
|
|
2
|
+
* Resolve a query request to an address the fetcher can call.
|
|
3
3
|
*
|
|
4
4
|
* ## The one idea
|
|
5
5
|
*
|
|
6
|
-
* A site names a
|
|
6
|
+
* A site names a query; it never names where its records live. Where
|
|
7
7
|
* it lives is a **deployment** fact, and the two possible answers have different
|
|
8
8
|
* owners:
|
|
9
9
|
*
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
*
|
|
25
25
|
* A base assumes the layout is "root plus one segment". A pattern assumes
|
|
26
26
|
* nothing, so a host can carry a site id, a locale segment, a different root
|
|
27
|
-
* for records than for the
|
|
27
|
+
* for records than for the list, or none of those, and move any of it
|
|
28
28
|
* without a framework release.
|
|
29
29
|
*
|
|
30
30
|
* This is the `config.assets.url` rule applied to records. That pattern exists
|
|
@@ -45,7 +45,7 @@ import { substitutePlaceholders } from './substitute-placeholders.js'
|
|
|
45
45
|
/**
|
|
46
46
|
* The placeholder a list pattern must carry.
|
|
47
47
|
*
|
|
48
|
-
* ⛔ IT IS `{path}`, NOT `{
|
|
48
|
+
* ⛔ IT IS `{path}`, NOT `{query}`, AND THAT IS NOT COSMETIC. A *query* is
|
|
49
49
|
* framework's own build concept — a named set our build compiles to one file. A host
|
|
50
50
|
* serving records has no such thing: it has content organised somewhere, and what we
|
|
51
51
|
* substitute is a **path** to it. Naming the slot for our file vocabulary put that
|
|
@@ -61,11 +61,11 @@ const warnedPatterns = new Set()
|
|
|
61
61
|
function warnOnce(key, message) {
|
|
62
62
|
if (warnedPatterns.has(key)) return
|
|
63
63
|
warnedPatterns.add(key)
|
|
64
|
-
console.warn(`[
|
|
64
|
+
console.warn(`[query-address] ${message}`)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Test seam — reset the once-per-pattern memo so suites do not leak. */
|
|
68
|
-
export function
|
|
68
|
+
export function _resetQueryAddressWarnings() {
|
|
69
69
|
warnedPatterns.clear()
|
|
70
70
|
}
|
|
71
71
|
|
|
@@ -83,36 +83,36 @@ function readPattern(lane, key) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* The address for a whole
|
|
86
|
+
* The address for a whole query's records, or `null` to fall through to the artifact.
|
|
87
87
|
*
|
|
88
88
|
* ⚠️ A pattern that does not carry `{path}` is REFUSED rather than used.
|
|
89
|
-
* Substituting nothing would yield one identical URL for every
|
|
89
|
+
* Substituting nothing would yield one identical URL for every query on the
|
|
90
90
|
* site — every schema reading the same records, with a 200 on each request. That
|
|
91
91
|
* is the failure this check exists for; an unusable pattern must degrade to the
|
|
92
92
|
* artifact, which is at least correct.
|
|
93
93
|
*
|
|
94
|
-
* @param {string}
|
|
94
|
+
* @param {string} query - the query's authored name (the wiring key).
|
|
95
95
|
* @param {Object|null} lane - `config.records`.
|
|
96
96
|
* @returns {string|null} the address, or null when nothing usable is declared.
|
|
97
97
|
*/
|
|
98
|
-
export function
|
|
99
|
-
if (typeof
|
|
98
|
+
export function resolveQueryAddress(query, lane) {
|
|
99
|
+
if (typeof query !== 'string' || query.length === 0) return null
|
|
100
100
|
const pattern = readPattern(lane, 'list')
|
|
101
101
|
if (!pattern) return null
|
|
102
102
|
if (!pattern.includes(PATH_SLOT)) {
|
|
103
103
|
warnOnce(
|
|
104
104
|
`list:${pattern}`,
|
|
105
105
|
`config.records.list carries no ${PATH_SLOT} placeholder, so every ` +
|
|
106
|
-
`
|
|
107
|
-
`compiled
|
|
106
|
+
`query would resolve to the same address. Ignoring it and reading the ` +
|
|
107
|
+
`compiled file instead.`
|
|
108
108
|
)
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
|
-
return substitutePlaceholders(pattern, { path:
|
|
111
|
+
return substitutePlaceholders(pattern, { path: query })
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* The address pattern for ONE record of a
|
|
115
|
+
* The address pattern for ONE record of a query, with `{param}` left in
|
|
116
116
|
* place for the dynamic-route substitution that happens later.
|
|
117
117
|
*
|
|
118
118
|
* Returning a pattern rather than a finished URL is deliberate: the route param
|
|
@@ -120,12 +120,12 @@ export function resolveCollectionAddress(collection, lane) {
|
|
|
120
120
|
* (`buildDetailConfig` / `substitutePlaceholders` at fetch time). Resolving it
|
|
121
121
|
* twice, in two places, is how the two copies drift.
|
|
122
122
|
*
|
|
123
|
-
* @param {string}
|
|
123
|
+
* @param {string} query
|
|
124
124
|
* @param {Object|null} lane - `config.records`.
|
|
125
125
|
* @returns {string|null} a pattern still containing `{param}`, or null.
|
|
126
126
|
*/
|
|
127
|
-
export function resolveRecordAddressPattern(
|
|
128
|
-
if (typeof
|
|
127
|
+
export function resolveRecordAddressPattern(query, lane) {
|
|
128
|
+
if (typeof query !== 'string' || query.length === 0) return null
|
|
129
129
|
const pattern = readPattern(lane, 'record')
|
|
130
130
|
if (!pattern) return null
|
|
131
131
|
if (!pattern.includes(PARAM_SLOT)) {
|
|
@@ -139,5 +139,5 @@ export function resolveRecordAddressPattern(collection, lane) {
|
|
|
139
139
|
}
|
|
140
140
|
// Only `{path}` is substituted here — `{param}` survives for the
|
|
141
141
|
// dynamic-route resolution that owns it.
|
|
142
|
-
return substitutePlaceholders(pattern, { path:
|
|
142
|
+
return substitutePlaceholders(pattern, { path: query })
|
|
143
143
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
* sort: 'date desc, title asc'
|
|
29
29
|
* → sort[0]=date:desc & sort[1]=title:asc
|
|
30
30
|
*
|
|
31
|
-
* Response envelope defaults to `{
|
|
31
|
+
* Response envelope defaults to `{ list: 'data', item: 'data' }` —
|
|
32
32
|
* Strapi v4 wraps every response in `{ data, meta }`. Sites can override
|
|
33
33
|
* via `envelope:` at the site or per-fetch level.
|
|
34
34
|
*
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
export const strapi = {
|
|
62
62
|
name: 'strapi',
|
|
63
63
|
canPush: new Set(['where', 'limit', 'sort']),
|
|
64
|
-
defaultEnvelope: {
|
|
64
|
+
defaultEnvelope: { list: 'data', item: 'data' },
|
|
65
65
|
|
|
66
66
|
encode(request, { method, pushCandidates, rename }) {
|
|
67
67
|
const pushed = new Set()
|
package/src/uniweb.js
CHANGED
|
@@ -88,6 +88,15 @@ export default class Uniweb {
|
|
|
88
88
|
// `defaultInsets` above.
|
|
89
89
|
this.tracking = new Tracker()
|
|
90
90
|
|
|
91
|
+
// Reserved for `@uniweb/api` — the one client instance per page. That
|
|
92
|
+
// package is bundled into each foundation, so a page with a primary
|
|
93
|
+
// foundation and extensions holds several copies of its code; the first to
|
|
94
|
+
// need a client creates one and parks it here, and every other copy adopts
|
|
95
|
+
// it instead of creating another. Core implements nothing on it: declared
|
|
96
|
+
// null so the seal permits the assignment, nothing more. Same pattern as
|
|
97
|
+
// `defaultInsets` above.
|
|
98
|
+
this.api = null
|
|
99
|
+
|
|
91
100
|
Object.seal(this)
|
|
92
101
|
}
|
|
93
102
|
|
package/src/website.js
CHANGED
|
@@ -506,11 +506,11 @@ export default class Website {
|
|
|
506
506
|
// Create a dynamic page instance with the concrete route and params
|
|
507
507
|
const result = this._createDynamicPage(page, normalizedRoute, match.params)
|
|
508
508
|
if (result) {
|
|
509
|
-
const { page: dynamicPage,
|
|
510
|
-
// Only cache when
|
|
509
|
+
const { page: dynamicPage, recordsLoaded } = result
|
|
510
|
+
// Only cache when the records were available at creation time.
|
|
511
511
|
// If DataStore was empty, skip caching so the next render recreates
|
|
512
512
|
// the page with fresh data (correct title, not-found state, etc.).
|
|
513
|
-
if (
|
|
513
|
+
if (recordsLoaded) {
|
|
514
514
|
this._dynamicPageCache.set(normalizedRoute, dynamicPage)
|
|
515
515
|
}
|
|
516
516
|
return dynamicPage
|
|
@@ -584,7 +584,7 @@ export default class Website {
|
|
|
584
584
|
const parentPage = this.pages.find(p => p.route === parentRoute || p.getNavRoute() === parentRoute)
|
|
585
585
|
|
|
586
586
|
if (parentPage && pluralSchema) {
|
|
587
|
-
// Find
|
|
587
|
+
// Find the records from the parent's fetch config via the dispatcher's
|
|
588
588
|
// peek (sync cache probe). Used to populate the page title / notFound
|
|
589
589
|
// flag on dynamic pages before the page instance is constructed.
|
|
590
590
|
const parentFetch = parentPage.fetch
|
|
@@ -608,18 +608,18 @@ export default class Website {
|
|
|
608
608
|
pageData.description = currentItem.description || currentItem.excerpt
|
|
609
609
|
}
|
|
610
610
|
} else if (items.length > 0) {
|
|
611
|
-
//
|
|
611
|
+
// The records are loaded but this ID isn't among them — definitive not found
|
|
612
612
|
pageData.title = 'Not found'
|
|
613
613
|
pageData.notFound = true
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
-
// Track whether
|
|
616
|
+
// Track whether the records were available at creation time.
|
|
617
617
|
// Note: the matched record and the sibling list are intentionally NOT
|
|
618
618
|
// stored on dynamicContext — nothing reads them (documented shape is
|
|
619
619
|
// { paramName, paramValue, schema }; the record reaches components via
|
|
620
620
|
// content.data, siblings via `fetch: { refine: true, detail: false }`).
|
|
621
621
|
// The local `currentItem`/`items` above drive title/description/notFound.
|
|
622
|
-
pageData.
|
|
622
|
+
pageData._recordsLoaded = items.length > 0
|
|
623
623
|
}
|
|
624
624
|
|
|
625
625
|
// Create the page instance
|
|
@@ -628,7 +628,7 @@ export default class Website {
|
|
|
628
628
|
// Copy parent reference from template
|
|
629
629
|
dynamicPage.parent = templatePage.parent
|
|
630
630
|
|
|
631
|
-
return { page: dynamicPage,
|
|
631
|
+
return { page: dynamicPage, recordsLoaded: pageData._recordsLoaded ?? true }
|
|
632
632
|
}
|
|
633
633
|
|
|
634
634
|
/**
|
|
@@ -813,7 +813,7 @@ export default class Website {
|
|
|
813
813
|
* Resolve a `page:<stable_id>` detail-page reference (from a fetch config's
|
|
814
814
|
* `detailPage`) to a locale-specific route TEMPLATE, e.g. '/blog/:slug'. The
|
|
815
815
|
* entity store interpolates each record's field into the `:param` slot to build
|
|
816
|
-
* a card's href — so a dynamic-list preview links to the
|
|
816
|
+
* a card's href — so a dynamic-list preview links to the query's canonical
|
|
817
817
|
* detail page regardless of which page it sits on.
|
|
818
818
|
*
|
|
819
819
|
* O(1): a `_pageIdMap` lookup (keyed on stable_id, same map makeHref uses), NOT
|
package/src/where.js
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
* not Single sub-predicate; must not match.
|
|
46
46
|
*
|
|
47
47
|
* `under` is for fields holding a slash-separated location — a record's
|
|
48
|
-
* position inside
|
|
48
|
+
* position inside the folder, a category path, a docs section. It matches
|
|
49
49
|
* the value itself and anything below it, and it respects segment boundaries so
|
|
50
50
|
* a sibling with a shared prefix does not match:
|
|
51
51
|
*
|
|
@@ -143,7 +143,7 @@ function evaluateClause(key, value, record) {
|
|
|
143
143
|
*
|
|
144
144
|
* Both sides are compared with leading and trailing `/` trimmed, so an author
|
|
145
145
|
* who writes `'/2024'` or `'2024/'` gets what they meant. An empty ancestor is
|
|
146
|
-
* the root and contains everything — which is what makes "the whole
|
|
146
|
+
* the root and contains everything — which is what makes "the whole pool"
|
|
147
147
|
* expressible as a predicate rather than as the absence of one.
|
|
148
148
|
*
|
|
149
149
|
* ⛔ The `+ '/'` is the whole point: a plain `startsWith` would match `2024b`
|