@uniweb/core 0.28.0 → 0.29.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 +4 -3
- package/src/block.js +7 -0
- package/src/data-keys.js +155 -0
- package/src/data-paths.js +7 -7
- package/src/datastore.js +28 -13
- package/src/detail-url.js +23 -15
- package/src/entity-store.js +215 -136
- package/src/fetch-config.js +255 -81
- package/src/fetcher-dispatcher.js +9 -1
- package/src/index.js +5 -1
- package/src/query-evaluation.js +87 -0
- package/src/route-match.js +3 -2
- package/src/sort.js +2 -3
- package/src/website.js +70 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.1",
|
|
4
4
|
"description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"./base-path": "./src/base-path.js",
|
|
9
9
|
"./records-service": "./src/records-service.js",
|
|
10
10
|
"./data-paths": "./src/data-paths.js",
|
|
11
|
+
"./data-keys": "./src/data-keys.js",
|
|
11
12
|
"./datastore": "./src/datastore.js",
|
|
12
13
|
"./detail-url": "./src/detail-url.js",
|
|
13
14
|
"./fetch-config": "./src/fetch-config.js",
|
|
@@ -45,8 +46,8 @@
|
|
|
45
46
|
"vitest": "^4.1.7"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
|
-
"@uniweb/
|
|
49
|
-
"@uniweb/
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
50
|
+
"@uniweb/theming": "^0.1.15"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
53
|
"test": "vitest run"
|
package/src/block.js
CHANGED
|
@@ -111,6 +111,13 @@ export default class Block {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// ⭐ WHAT THE SECTION HOLDS before any fetch answers it: its tagged data blocks, and
|
|
115
|
+
// its own fetches' answers a static build prerendered into it, by key. A declared key
|
|
116
|
+
// it holds is filled from here first (`runtime/src/prepare-props.js`), and a fetch
|
|
117
|
+
// whose answer is here is not asked again (`EntityStore`). Kept apart from
|
|
118
|
+
// `parsedContent.data`, which each render rebuilds from the declared keys.
|
|
119
|
+
this.heldData = this.parsedContent.data || {}
|
|
120
|
+
|
|
114
121
|
// Flat content structure - no more nested main/items
|
|
115
122
|
// parsedContent now has: title, pretitle, paragraphs, links, images, items, etc.
|
|
116
123
|
this.items = this.parsedContent.items || []
|
package/src/data-keys.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⭐ WHAT A SECTION RECEIVES — the keys its component declares, and which fetch fills each.
|
|
3
|
+
* Ruled 2026-09-14 [Diego]:
|
|
4
|
+
*
|
|
5
|
+
* - **`content.data` holds every key the component declares — in its `meta.js` `data:`,
|
|
6
|
+
* and in its foundation's `main.js` `data:` — and nothing else.** A component with no
|
|
7
|
+
* `data:` receives none of its own; a foundation's keys reach every section, since its
|
|
8
|
+
* handlers run for every section.
|
|
9
|
+
* - **Which fetch fills a declared key** — automatic `as`: the fetches reaching a section
|
|
10
|
+
* are taken one level at a time, most specific first; within a level, a fetch fills
|
|
11
|
+
* its own key when the component declares it, and the fetches whose keys it does not
|
|
12
|
+
* declare fill its still-empty keys of their query's schema, in order.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ Until then `content.data` held every key that reached a section — every fetch of the
|
|
15
|
+
* section, its page, its parent and the site, and every tagged block — and a key reached
|
|
16
|
+
* a component only under the name the fetch was given (`as`), so a component naming its
|
|
17
|
+
* key differently received nothing.
|
|
18
|
+
*
|
|
19
|
+
* Plain data in and out, like `routeQuery`, so the entity store, the editor and anything
|
|
20
|
+
* else that shows what a section receives call these and keep no copy.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { fetchEntries } from './fetch-config.js'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A declared key's schema ref, as `data:` spells it: a ref string (`'@std/article'`), an
|
|
29
|
+
* entry naming one (`{ schema: '@/member' }`), or null for an inline shape — a field map
|
|
30
|
+
* or a form — which names no schema.
|
|
31
|
+
*
|
|
32
|
+
* @param {*} value - one `data:` entry's value, authored or leaned
|
|
33
|
+
* @returns {string|null}
|
|
34
|
+
*/
|
|
35
|
+
function refOf(value) {
|
|
36
|
+
if (typeof value === 'string') return value || null
|
|
37
|
+
if (value && typeof value === 'object' && typeof value.schema === 'string') return value.schema || null
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The keys a section's component receives, in order, each with its schema ref — its
|
|
43
|
+
* component's `data:` first, then its foundation's. A key both declare is the
|
|
44
|
+
* component's.
|
|
45
|
+
*
|
|
46
|
+
* `data: false`, a missing `data:` or anything that is not a map declares nothing. Each
|
|
47
|
+
* value may be as authored or as the build leans it (`{ key: ref|null }`).
|
|
48
|
+
*
|
|
49
|
+
* @param {Object|false|null|undefined} componentData - the component's `data:`
|
|
50
|
+
* @param {Object|false|null|undefined} [foundationData] - its foundation's `main.js` `data:`
|
|
51
|
+
* @returns {Array<[string, string|null]>}
|
|
52
|
+
*/
|
|
53
|
+
export function declaredKeys(componentData, foundationData = null) {
|
|
54
|
+
const out = []
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
for (const data of [componentData, foundationData]) {
|
|
57
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) continue
|
|
58
|
+
for (const [key, value] of Object.entries(data)) {
|
|
59
|
+
if (!key || seen.has(key)) continue
|
|
60
|
+
seen.add(key)
|
|
61
|
+
out.push([key, refOf(value)])
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Do two schema refs name the same schema?
|
|
69
|
+
*
|
|
70
|
+
* ⭐ `@/<name>` is "this project's own `<name>`", and what that project is differs by who
|
|
71
|
+
* wrote it: in a foundation's `meta.js` it is the foundation's schema, in a site's
|
|
72
|
+
* `queries.yml` the site's — and a site synced to a host has its queries' `@/` refs
|
|
73
|
+
* qualified with its org (`@acme/member`). So a local ref matches a ref of ANY scope with
|
|
74
|
+
* the same name; two scoped refs match only when their scopes agree. Decided 2026-09-14
|
|
75
|
+
* (the plan's §7 left it to framework): `@/member` and `@acme/member` match,
|
|
76
|
+
* `@std/person` and `@acme/person` do not.
|
|
77
|
+
*
|
|
78
|
+
* @param {string|null|undefined} a
|
|
79
|
+
* @param {string|null|undefined} b
|
|
80
|
+
* @returns {boolean}
|
|
81
|
+
*/
|
|
82
|
+
export function sameSchema(a, b) {
|
|
83
|
+
if (typeof a !== 'string' || typeof b !== 'string' || !a || !b) return false
|
|
84
|
+
if (a === b) return true
|
|
85
|
+
const pa = parseRef(a)
|
|
86
|
+
const pb = parseRef(b)
|
|
87
|
+
if (!pa || !pb || pa.name !== pb.name) return false
|
|
88
|
+
return pa.scope === pb.scope || pa.scope === '' || pb.scope === ''
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `@scope/name` → `{ scope, name }`, `''` for a local `@/name`; null for anything else. */
|
|
92
|
+
function parseRef(ref) {
|
|
93
|
+
const m = /^@([^/]*)\/([^/]+)$/.exec(ref)
|
|
94
|
+
return m ? { scope: m[1], name: m[2] } : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* ⭐ WHICH FETCH FILLS EACH DECLARED KEY — automatic `as`, ruled 2026-09-14 [Diego].
|
|
99
|
+
*
|
|
100
|
+
* The levels are the fetches reaching a section, most specific first — its own, its
|
|
101
|
+
* page's, its parent page's, a nested page's route binding, the site's (the sources
|
|
102
|
+
* `resolveFetchConfigs` takes). Within a level each fetch's key is its `as`, which is its
|
|
103
|
+
* query's name unless the author wrote another, and:
|
|
104
|
+
*
|
|
105
|
+
* 1. **a fetch fills its key**, if the component declares it;
|
|
106
|
+
* 2. **the fetches whose keys the component does not declare fill its still-empty keys
|
|
107
|
+
* of their query's schema, in order** — the first such fetch, in the order written,
|
|
108
|
+
* fills the first such key, in the order `data:` lists them, and the next fetch the
|
|
109
|
+
* next key (`sameSchema`).
|
|
110
|
+
*
|
|
111
|
+
* A key filled at one level is not refilled by a less specific one, and a key the section
|
|
112
|
+
* already holds — a tagged data block in it (`held`) — is filled before any fetch. Where
|
|
113
|
+
* two fetches could fill one key, the first does. A fetch that fills nothing is not in
|
|
114
|
+
* the answer, and so is not asked for.
|
|
115
|
+
*
|
|
116
|
+
* The mapping says where an answer lands; it renames no fetch. A fetch keeps its `as`
|
|
117
|
+
* for its question, its cache key and its prefetch.
|
|
118
|
+
*
|
|
119
|
+
* @param {Array<[string, string|null]>} declared - `declaredKeys`
|
|
120
|
+
* @param {Array<*>} levels - each level's `fetch`, most specific first; falsy levels skipped
|
|
121
|
+
* @param {Object} [options]
|
|
122
|
+
* @param {Object|null} [options.queries] - the site's `config.queries`, for each fetch's
|
|
123
|
+
* query's schema
|
|
124
|
+
* @param {Iterable<string>} [options.held] - declared keys the section already fills
|
|
125
|
+
* @returns {Map<string, { fetch: Object, level: number, index: number }>} declared key →
|
|
126
|
+
* the fetch that fills it, as the level spells it (with its `as`), the level it came
|
|
127
|
+
* from and its place in that level; in the order `declared` lists the keys
|
|
128
|
+
*/
|
|
129
|
+
export function fillDeclaredKeys(declared, levels, { queries = null, held = [] } = {}) {
|
|
130
|
+
const refs = new Map(declared)
|
|
131
|
+
const filled = new Map()
|
|
132
|
+
const done = new Set(held)
|
|
133
|
+
for (let level = 0; level < levels.length; level++) {
|
|
134
|
+
const entries = fetchEntries(levels[level])
|
|
135
|
+
// 1. a fetch fills its own key, if declared
|
|
136
|
+
entries.forEach((fetch, index) => {
|
|
137
|
+
const key = fetch.as
|
|
138
|
+
if (refs.has(key) && !done.has(key)) {
|
|
139
|
+
done.add(key)
|
|
140
|
+
filled.set(key, { fetch, level, index })
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
// 2. a fetch under an undeclared key fills the next still-empty key of its schema
|
|
144
|
+
entries.forEach((fetch, index) => {
|
|
145
|
+
if (refs.has(fetch.as)) return
|
|
146
|
+
const schema = typeof fetch.query === 'string' ? queries?.[fetch.query]?.schema : null
|
|
147
|
+
if (!schema) return
|
|
148
|
+
const key = declared.find(([k, ref]) => !done.has(k) && sameSchema(ref, schema))?.[0]
|
|
149
|
+
if (key === undefined) return
|
|
150
|
+
done.add(key)
|
|
151
|
+
filled.set(key, { fetch, level, index })
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
return new Map(declared.filter(([key]) => filled.has(key)).map(([key]) => [key, filled.get(key)]))
|
|
155
|
+
}
|
package/src/data-paths.js
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* (`data-fetcher.js`), core injected the per-record default
|
|
14
14
|
* (`fetch-config.js applyDeferredDetail`), core gated locale-prefixing on it
|
|
15
15
|
* (`fetch-config.js localizeConfig`), the dev server matched it with a regex
|
|
16
|
-
* (`build/src/site/plugin.js`), and kit's `useEntityDetail`
|
|
16
|
+
* (`build/src/site/plugin.js`), and kit's `useEntityDetail` — `useWholeRecord`
|
|
17
|
+
* since 2026-09-14 — requested it.
|
|
17
18
|
*
|
|
18
19
|
* They drifted. `useEntityDetail` was edited to `/_data/` on its own and
|
|
19
20
|
* nothing else followed, so a public documented hook requested a URL that
|
|
@@ -73,12 +74,11 @@ export function queryDataUrl(name) {
|
|
|
73
74
|
* URL of one record's full payload — every field, including deferred ones.
|
|
74
75
|
* Emitted per record only when the query declares `deferred:`.
|
|
75
76
|
*
|
|
76
|
-
* Takes either a concrete slug
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* replaced.
|
|
77
|
+
* Takes either a concrete slug — a caller holding the record — or the literal
|
|
78
|
+
* placeholder `{slug}` (core's `applyDeferredDetail`, which builds a pattern
|
|
79
|
+
* that `substitutePlaceholders` resolves later against the dynamic-route
|
|
80
|
+
* param). Both are plain interpolation; this function does not encode,
|
|
81
|
+
* matching the behavior of the call sites it replaced.
|
|
82
82
|
*
|
|
83
83
|
* @param {string} query - The query name.
|
|
84
84
|
* @param {string} slug - A record slug, or a `{param}` placeholder.
|
package/src/datastore.js
CHANGED
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
*
|
|
24
24
|
* An ADDRESSED request — `path` or `url` — is identified by where it goes (the
|
|
25
25
|
* address, the binding key, the unwrap, and for a POST its method and body) AND by
|
|
26
|
-
* the view it takes of what came back: `scope`, `where`, `sort`, `limit
|
|
27
|
-
* the
|
|
28
|
-
*
|
|
29
|
-
* different addresses).
|
|
26
|
+
* the view it takes of what came back: the set — `scope`, `where`, `sort`, `limit` —
|
|
27
|
+
* and the `narrow` taken of it, which the default fetcher evaluates locally over the
|
|
28
|
+
* file (`evaluateQuery`). `query` is not hashed: the address already carries it (a
|
|
29
|
+
* query's list and its per-record file are different addresses).
|
|
30
30
|
*
|
|
31
31
|
* ⛔ **The view was left out until 2026-09-11, on the belief that operators "are
|
|
32
32
|
* applied after the fetch over one shared copy".** They were applied BEFORE the
|
|
@@ -39,12 +39,18 @@
|
|
|
39
39
|
* for the same request."*
|
|
40
40
|
*
|
|
41
41
|
* An ADDRESS-LESS request — a QUESTION sent to the records service — is
|
|
42
|
-
* identified by the question: `query`, `schema`, `scope`, `where`, `
|
|
43
|
-
* `
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
42
|
+
* identified by the question: `query`, `schema`, `scope`, `where`, `sort`, `limit`,
|
|
43
|
+
* `narrow`, `whole`. ⛔ The reason: with no per-query address, two pages binding one
|
|
44
|
+
* `as` to two queries would otherwise share an entry, and a list (brief) and a
|
|
45
|
+
* record (whole) of one query would collide — the one defect on this path that
|
|
46
|
+
* delivers WRONG data rather than none. `narrow.match` is the record a parametric
|
|
47
|
+
* page asks for; without it every record's question is one entry.
|
|
48
|
+
*
|
|
49
|
+
* ⭐ `narrow` is hashed field by field, in one order — `where`, `match`, `sort`,
|
|
50
|
+
* `cursor`, `limit` — so two producers that build the same narrowing in a different
|
|
51
|
+
* key order share an entry, and a `narrow` with nothing in it is no `narrow`. ⛔ Until
|
|
52
|
+
* 2026-09-14 a fetch's narrowing was merged into the top-level fields and `match` sat
|
|
53
|
+
* beside them (`@uniweb/core/fetch-config`, `setAndNarrow`).
|
|
48
54
|
*
|
|
49
55
|
* `locale` is hashed on both when present: two locales' answers must not share
|
|
50
56
|
* an entry, and an asked config always carries the locale it was asked in.
|
|
@@ -62,15 +68,24 @@ export function deriveCacheKey(request) {
|
|
|
62
68
|
? request.method.toUpperCase()
|
|
63
69
|
: undefined
|
|
64
70
|
const body = method === 'POST' ? request?.body : undefined
|
|
65
|
-
const { query, schema, scope, where,
|
|
71
|
+
const { query, schema, scope, where, sort, limit, whole } = request || {}
|
|
72
|
+
const narrow = canonicalNarrow(request?.narrow)
|
|
66
73
|
if (path || url) {
|
|
67
74
|
// ⚠️ The field NAME is part of the hash, so renaming it moves every key ONCE.
|
|
68
75
|
// In-memory stores repopulate; a consumer with a persistent cache takes one
|
|
69
76
|
// cold pass. Chosen over hashing under the old name, which would have hidden
|
|
70
77
|
// the rename inside the one function whose job is to be canonical.
|
|
71
|
-
return JSON.stringify({ path, url, as, transform, method, body, locale, scope, where, sort, limit })
|
|
78
|
+
return JSON.stringify({ path, url, as, transform, method, body, locale, scope, where, sort, limit, narrow })
|
|
72
79
|
}
|
|
73
|
-
return JSON.stringify({ query, schema, scope, where,
|
|
80
|
+
return JSON.stringify({ query, schema, scope, where, sort, limit, narrow, whole, as, transform, locale })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A `narrow` in one field order, or `undefined` when it holds nothing. */
|
|
84
|
+
function canonicalNarrow(narrow) {
|
|
85
|
+
if (!narrow || typeof narrow !== 'object') return undefined
|
|
86
|
+
const { where, match, sort, cursor, limit } = narrow
|
|
87
|
+
if ([where, match, sort, cursor, limit].every((v) => v === undefined)) return undefined
|
|
88
|
+
return { where, match, sort, cursor, limit }
|
|
74
89
|
}
|
|
75
90
|
|
|
76
91
|
/**
|
package/src/detail-url.js
CHANGED
|
@@ -45,7 +45,7 @@ function paramContext(paramName, paramValue, record) {
|
|
|
45
45
|
// detail URL was `/data/articles/{slug}.json`, a guaranteed 404, on every
|
|
46
46
|
// template page with `deferred:` fields (measured 2026-09-04). When the caller
|
|
47
47
|
// holds the record — the entity store does (it matched it), and so does
|
|
48
|
-
// `
|
|
48
|
+
// kit's `useWholeRecord` — its slug fills the name the FILE was written under. A
|
|
49
49
|
// caller with no record in hand leaves `{slug}` literal rather than guessing
|
|
50
50
|
// the capture is one: a visibly unresolved address beats a plausible wrong one.
|
|
51
51
|
const handle = recordHandle(record)
|
|
@@ -69,7 +69,7 @@ export const ROUTE_HANDLE_KEY = routeRecordKey('slug')
|
|
|
69
69
|
* config (post-`resolveFetchConfigs`, which set `detail` — see `./fetch-config.js`).
|
|
70
70
|
* Three sources, and no others:
|
|
71
71
|
*
|
|
72
|
-
* - **the records service** — the route query's
|
|
72
|
+
* - **the records service** — the route query's set, plus `narrow.match`;
|
|
73
73
|
* - **an external query's `record:`** — its `url` (the query's when it names none),
|
|
74
74
|
* `method` (likewise), `body` and `transform` — ⛔ never the list's `body` or
|
|
75
75
|
* `transform`, since a record response is rarely wrapped the way the list is;
|
|
@@ -97,22 +97,30 @@ export function buildDetailConfig(queryConfig, dynamicContext) {
|
|
|
97
97
|
const { paramName, paramValue, record = null } = dynamicContext
|
|
98
98
|
if (!paramName || paramValue === undefined) return null
|
|
99
99
|
|
|
100
|
-
// ⭐ THE RECORDS SERVICE: the record is the route query's
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
100
|
+
// ⭐ THE RECORDS SERVICE: the record is the route query's SET — the query as saved,
|
|
101
|
+
// its `scope`, `where`, `sort` and `limit` all kept — narrowed by `match`, the one
|
|
102
|
+
// key the page's folder names and the URL's value for it (`routeRecordKey`: `[slug]`
|
|
103
|
+
// → `$name`, `[uuid]` → `$uuid`, `[id]` → `id`), asked in full. So the one question
|
|
104
|
+
// answers the record if it is in the set and `[]` (not found) if it is not: a
|
|
105
|
+
// record outside the query's `limit` has no page, and a query's conditions cannot
|
|
106
|
+
// be escaped through the URL (ruled 2026-09-14 [Diego]).
|
|
106
107
|
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
108
|
+
// ⭐ The fetch's own narrowing drops: which pages exist is the query's to decide,
|
|
109
|
+
// never a list's (`routeSelection`). So every section of the page that asks its
|
|
110
|
+
// record asks this one question.
|
|
111
|
+
//
|
|
112
|
+
// ⛔ Until 2026-09-14 the record question dropped the query's `sort` and `limit` and
|
|
113
|
+
// sent `match` at the top level, so "this field note, if it is among the 100" was
|
|
114
|
+
// asked as "this field note". ⛔ And `where` is never touched: until 2026-09-11 the
|
|
115
|
+
// handle was merged into it, which replaced any condition the author had on the same
|
|
116
|
+
// key; `where` is the author's and `match` is the visitor's [Diego]. The value is a
|
|
117
|
+
// string — it is a URL segment — and the service compares it as one, as the local
|
|
118
|
+
// match does.
|
|
111
119
|
if (queryConfig.ask) {
|
|
112
|
-
const {
|
|
120
|
+
const { narrow: _narrow, detail: _detail, match: _match, ...set } = queryConfig
|
|
113
121
|
return {
|
|
114
|
-
...
|
|
115
|
-
match: { [routeRecordKey(paramName)]: String(paramValue) },
|
|
122
|
+
...set,
|
|
123
|
+
narrow: { match: { [routeRecordKey(paramName)]: String(paramValue) } },
|
|
116
124
|
whole: true,
|
|
117
125
|
dynamicContext: { paramName, paramValue },
|
|
118
126
|
}
|