@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.28.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/theming": "^0.1.15",
49
- "@uniweb/semantic-parser": "^1.4.0"
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 || []
@@ -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` requested it.
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 (kit's `useEntityDetail`, which holds a
77
- * record) or the literal placeholder `{slug}` (core's `applyDeferredDetail`,
78
- * which builds a pattern that `substitutePlaceholders` resolves later
79
- * against the dynamic-route param). Both are plain interpolation; this
80
- * function does not encode, matching the behavior of the call sites it
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`, which
27
- * the default fetcher evaluates locally over the file. `query` is not hashed: the
28
- * address already carries it (a query's list and its per-record file are
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`, `match`,
43
- * `sort`, `limit`, `whole`. ⛔ The reason: with no per-query address, two pages
44
- * binding one `as` to two queries would otherwise share an entry, and a list
45
- * (brief) and a record (whole) of one query would collide — the one defect on
46
- * this path that delivers WRONG data rather than none. `match` is the record a
47
- * parametric page asks for; without it every record's question is one entry.
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, match, sort, limit, whole } = request || {}
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, match, sort, limit, whole, as, transform, locale })
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
- // `useEntityDetail` — its slug fills the name the FILE was written under. A
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 own question, plus `match`;
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 own question, unchanged,
101
- // plus `match` — the one key the page's folder names and the URL's value for it
102
- // (`routeRecordKey`: `[slug]` → `$name`, `[uuid]` → `$uuid`, `[id]` → `id`) —
103
- // asked in full. `sort` and `limit` are the list's and drop; `scope` and the
104
- // authored `where` stay AS WRITTEN, so a query's conditions cannot be escaped
105
- // through the URL (the records contract, §1a).
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
- // ⛔ `where` is never touched. Until 2026-09-11 the handle was merged into it,
108
- // which replaced any condition the author had on the same key; `where` is the
109
- // author's and `match` is the visitor's [Diego]. The value is a string — it is a
110
- // URL segment — and the service compares it as one, as the local match does.
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 { sort, limit, detail: _detail, match: _match, ...rest } = queryConfig
120
+ const { narrow: _narrow, detail: _detail, match: _match, ...set } = queryConfig
113
121
  return {
114
- ...rest,
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
  }