@uniweb/core 0.7.32 → 0.7.34

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,10 +1,11 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.32",
3
+ "version": "0.7.34",
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
+ "./fetch-config": "./src/fetch-config.js",
8
9
  "./locale-config": "./src/locale-config.js",
9
10
  "./section-id": "./src/section-id.js"
10
11
  },
@@ -32,8 +33,8 @@
32
33
  "vitest": "^4.1.7"
33
34
  },
34
35
  "dependencies": {
35
- "@uniweb/theming": "0.1.14",
36
- "@uniweb/semantic-parser": "1.1.20"
36
+ "@uniweb/theming": "0.1.15",
37
+ "@uniweb/semantic-parser": "1.1.21"
37
38
  },
38
39
  "scripts": {
39
40
  "test": "vitest run"
@@ -15,16 +15,18 @@
15
15
  */
16
16
 
17
17
  import { substitutePlaceholders } from './substitute-placeholders.js'
18
+ import { isFetchRefinement, resolveFetchConfigs } from './fetch-config.js'
18
19
 
19
20
  /**
20
21
  * Is `block.fetch` a per-instance refinement of the ancestor's fetch config
21
22
  * rather than a new source? The canonical spelling is `refine: true`; the
22
23
  * legacy spelling `inherit: true` is still honored for one release with a
23
24
  * dev-mode warning.
25
+ *
26
+ * The predicate itself lives in `./fetch-config.js` with the rest of the
27
+ * cascade rule; this alias keeps the local call sites reading as they did.
24
28
  */
25
- function isRefinement(bf) {
26
- return bf?.refine === true || bf?.inherit === true
27
- }
29
+ const isRefinement = isFetchRefinement
28
30
 
29
31
  let inheritDeprecationWarned = false
30
32
  function warnInheritDeprecation(block) {
@@ -95,54 +97,39 @@ export default class EntityStore {
95
97
  return []
96
98
  }
97
99
 
98
- /**
99
- * Return a localized copy of a fetch config for collection data.
100
- * Non-default locales get /{locale} prefixed onto /data/ paths so the
101
- * client fetches the translated JSON (/fr/data/articles.json).
102
- */
103
- _localizeConfig(cfg, website) {
104
- if (!cfg.path || !website) return cfg
105
- const locale = website.getActiveLocale?.()
106
- const defaultLocale = website.getDefaultLocale?.()
107
- if (!locale || locale === defaultLocale) return cfg
108
- if (!cfg.path.startsWith('/data/')) return cfg
109
- return { ...cfg, path: `/${locale}${cfg.path}` }
110
- }
111
-
112
100
  /**
113
101
  * Walk the four-level hierarchy and collect fetch configs for the
114
102
  * requested schemas. First match per schema wins.
103
+ *
104
+ * This method's job is to read the four source slots off the object graph;
105
+ * the rule applied to them (precedence, first-match-per-schema, locale
106
+ * normalization, deferred-detail injection) lives in `./fetch-config.js`,
107
+ * shared with every other host that has to answer the same question. Do not
108
+ * re-inline it here — divergence between copies is what the extraction
109
+ * exists to prevent.
115
110
  */
116
111
  _findFetchConfigs(block, requested) {
117
- const configs = new Map()
118
- const collectAll = requested.length === 0
119
- const sources = []
120
-
121
112
  if (block.fetch?.inherit === true && block.fetch?.refine !== true) {
122
113
  warnInheritDeprecation(block)
123
114
  }
124
- if (block.fetch && !isRefinement(block.fetch)) sources.push(block.fetch)
125
- const page = block.page
126
- if (page?.fetch) sources.push(page.fetch)
127
- if (page?.parent?.fetch) sources.push(page.parent.fetch)
128
- const siteFetch = block.website?.config?.fetch
129
- if (siteFetch) sources.push(siteFetch)
130
115
 
116
+ const page = block.page
131
117
  const website = block.website
132
118
 
133
- for (const source of sources) {
134
- const configList = Array.isArray(source) ? source : [source]
135
- for (const cfg of configList) {
136
- if (!cfg.schema) continue
137
- if (configs.has(cfg.schema)) continue
138
- if (collectAll || requested.includes(cfg.schema)) {
139
- const localized = this._localizeConfig(cfg, website)
140
- const withDetail = this._applyDeferredDetail(localized, website)
141
- configs.set(cfg.schema, withDetail)
142
- }
143
- }
144
- }
145
- return configs
119
+ return resolveFetchConfigs(
120
+ [
121
+ block.fetch && !isRefinement(block.fetch) ? block.fetch : null,
122
+ page?.fetch,
123
+ page?.parent?.fetch,
124
+ website?.config?.fetch,
125
+ ],
126
+ {
127
+ schemas: requested,
128
+ locale: website?.getActiveLocale?.() ?? null,
129
+ defaultLocale: website?.getDefaultLocale?.() ?? null,
130
+ collections: website?.config?.collections ?? null,
131
+ },
132
+ )
146
133
  }
147
134
 
148
135
  /**
@@ -167,52 +154,6 @@ export default class EntityStore {
167
154
  }
168
155
  }
169
156
 
170
- /**
171
- * Auto-inject `detail:` on collection refs whose collection has
172
- * `deferred:` declared. The detail pattern points at the per-record
173
- * source so the existing dynamic-route singular flow fetches a record
174
- * with all fields (including the deferred ones) instead of the
175
- * matched-item-from-the-cascade-collection (without).
176
- *
177
- * Two patterns:
178
- *
179
- * - Markdown-backed collections (the build emits per-record files
180
- * at `/data/<name>/<slug>.json`): the auto-injected pattern is
181
- * that path. `isLocalPath` resolution downstream gives the
182
- * fetch a `path:` shape.
183
- *
184
- * - API-backed collections (the source is a remote URL; the build
185
- * emits no per-record files): the author declares a `detailUrl:`
186
- * on the collection — e.g., `/api/articles/{slug}` — and the
187
- * auto-injected pattern uses it. `isLocalPath` resolution
188
- * downstream gives the fetch a `url:` shape (because the
189
- * collection itself has `url:`, not `path:`).
190
- *
191
- * Conventions:
192
- * - Per-record sources are keyed by `item.slug`. The injected
193
- * pattern uses the `{slug}` placeholder; substitution works when
194
- * the dynamic route's paramName is 'slug' (the documented
195
- * convention). Routes with other param names need an explicit
196
- * author-written `detail:` value.
197
- * - Author-supplied `cfg.detail` always wins. This helper only fills
198
- * in the default for collections that have declared deferred fields.
199
- * - Per-record files are not currently localized; sites needing
200
- * localized deferred collections write their own `detail:` URL.
201
- */
202
- _applyDeferredDetail(cfg, website) {
203
- if (cfg.detail !== undefined) return cfg
204
- const schema = cfg.schema
205
- if (!schema) return cfg
206
- const collConfig = website?.config?.collections?.[schema]
207
- if (!collConfig || typeof collConfig !== 'object') return cfg
208
- const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
209
- if (!deferred || deferred.length === 0) return cfg
210
- const pattern = typeof collConfig.detailUrl === 'string'
211
- ? collConfig.detailUrl
212
- : `/data/${schema}/{slug}.json`
213
- return { ...cfg, detail: pattern }
214
- }
215
-
216
157
  /**
217
158
  * Build a detail-URL fetch config from a collection config + dynamic context.
218
159
  *
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Fetch-config resolution — the shared rule, in one place.
3
+ *
4
+ * "Which fetch configs apply here?" is a framework concept. Authors declare
5
+ * `fetch:` at the section, page, folder and site levels; the framework decides
6
+ * which declaration wins per schema, how a local data path is localized, and
7
+ * when a collection with deferred fields gets a detail pattern injected.
8
+ *
9
+ * Every host that renders a page needs that answer — the browser runtime, the
10
+ * build-time prerenderer, and any server-side renderer. The rule had grown
11
+ * more than one implementation, and they had diverged in both directions (each
12
+ * carrying a level or a semantic the other lacked). This module is the single
13
+ * definition they call.
14
+ *
15
+ * INTENTIONALLY A LEAF: no imports, so it is safe to load anywhere — including
16
+ * environments with no DOM, no filesystem, and a hard bundle-size ceiling.
17
+ * Importing anything here (or from the package root) would defeat that. Keep
18
+ * it dependency-free.
19
+ *
20
+ * WHAT THIS DOES NOT OWN: where the sources come from. A caller holding a live
21
+ * object graph reads them off the graph; a caller holding a content document
22
+ * reads them off the JSON. Both hand the same ordered array to
23
+ * `resolveFetchConfigs`. That difference is real and stays with the caller.
24
+ */
25
+
26
+ /**
27
+ * Is this fetch declaration a per-instance *refinement* of an ancestor's
28
+ * config rather than a new source of its own?
29
+ *
30
+ * The canonical spelling is `refine: true`. The legacy spelling `inherit: true`
31
+ * is still honored; callers that want to warn about it should test for the key
32
+ * themselves — this predicate stays silent so it is safe in any environment.
33
+ *
34
+ * @param {Object} cfg - a fetch declaration
35
+ * @returns {boolean}
36
+ */
37
+ export function isFetchRefinement(cfg) {
38
+ return cfg?.refine === true || cfg?.inherit === true
39
+ }
40
+
41
+ /**
42
+ * Localize a fetch config that reads a local data path.
43
+ *
44
+ * Non-default locales get `/{locale}` prefixed onto `/data/` paths so the
45
+ * caller reads the translated JSON (`/fr/data/articles.json`). Configs with no
46
+ * `path` (remote `url:` sources), or paths outside `/data/`, pass through
47
+ * untouched — a remote endpoint's localization is the author's business.
48
+ *
49
+ * @param {Object} cfg
50
+ * @param {string|null} locale - the locale being rendered
51
+ * @param {string|null} defaultLocale - the site's default locale
52
+ * @returns {Object} the original config, or a localized copy
53
+ */
54
+ function localizeConfig(cfg, locale, defaultLocale) {
55
+ if (!cfg.path) return cfg
56
+ if (!locale || locale === defaultLocale) return cfg
57
+ if (!cfg.path.startsWith('/data/')) return cfg
58
+ return { ...cfg, path: `/${locale}${cfg.path}` }
59
+ }
60
+
61
+ /**
62
+ * Auto-inject `detail:` on a collection ref whose collection declares
63
+ * `deferred:` fields.
64
+ *
65
+ * A deferred collection ships a lean list payload, so the full record has to
66
+ * come from somewhere else. Two patterns, picked by what the collection
67
+ * declares:
68
+ *
69
+ * - the collection has `detailUrl:` → use it verbatim (a remote source);
70
+ * - otherwise → `/data/<schema>/{slug}.json`, the per-record file emitted
71
+ * alongside the lean list.
72
+ *
73
+ * Conventions carried from the original implementation:
74
+ * - Per-record sources are keyed by `item.slug`, and the injected pattern
75
+ * uses the `{slug}` placeholder. Substitution works when the dynamic
76
+ * route's paramName is `slug` (the documented convention); a route using
77
+ * another param name needs an explicit author-written `detail:`.
78
+ * - Per-record files are not currently localized. A site needing localized
79
+ * deferred collections writes its own `detail:` URL.
80
+ *
81
+ * An author-supplied `cfg.detail` always wins; this only fills the default.
82
+ * With no `collections` map available the config passes through untouched —
83
+ * deferred-detail injection is an enhancement, never a correctness
84
+ * requirement, so a caller that does not have collection metadata still gets
85
+ * a usable config. That matters for hosts whose content projection may not
86
+ * carry collection metadata at all.
87
+ *
88
+ * @param {Object} cfg
89
+ * @param {Object|null} collections - the site's `config.collections` map
90
+ * @returns {Object} the original config, or a copy carrying `detail`
91
+ */
92
+ function applyDeferredDetail(cfg, collections) {
93
+ if (cfg.detail !== undefined) return cfg
94
+ const schema = cfg.schema
95
+ if (!schema || !collections) return cfg
96
+ const collConfig = collections[schema]
97
+ if (!collConfig || typeof collConfig !== 'object') return cfg
98
+ const deferred = Array.isArray(collConfig.deferred) ? collConfig.deferred : null
99
+ if (!deferred || deferred.length === 0) return cfg
100
+ const pattern = typeof collConfig.detailUrl === 'string'
101
+ ? collConfig.detailUrl
102
+ : `/data/${schema}/{slug}.json`
103
+ return { ...cfg, detail: pattern }
104
+ }
105
+
106
+ /**
107
+ * Resolve the applicable fetch configs from an ordered list of sources.
108
+ *
109
+ * The rule: walk the sources in precedence order and take the FIRST match per
110
+ * schema. Sources are the framework's cascade, most specific first — typically
111
+ * section → page → parent page → site. A source may be a single config or an
112
+ * array of them; arrays are walked in order.
113
+ *
114
+ * First-match-per-schema (rather than first-match-wins-outright) is what lets
115
+ * a page needing two schemas inherit one from the site and declare the other
116
+ * itself. Collapsing that to a single winner is a real behavior change, not a
117
+ * simplification.
118
+ *
119
+ * @param {Array<Object|Array<Object>>} sources - ordered, most specific first.
120
+ * Falsy entries are skipped, so callers can pass optional levels directly.
121
+ * @param {Object} [options]
122
+ * @param {string[]} [options.schemas] - restrict to these schema names.
123
+ * Empty (the default) collects every schema found.
124
+ * @param {string|null} [options.locale] - the locale being rendered
125
+ * @param {string|null} [options.defaultLocale] - the site's default locale
126
+ * @param {Object|null} [options.collections] - the site's `config.collections`
127
+ * @returns {Map<string, Object>} schema name → resolved config
128
+ */
129
+ export function resolveFetchConfigs(sources, options = {}) {
130
+ const {
131
+ schemas = [],
132
+ locale = null,
133
+ defaultLocale = null,
134
+ collections = null,
135
+ } = options
136
+
137
+ const configs = new Map()
138
+ const collectAll = schemas.length === 0
139
+
140
+ for (const source of sources) {
141
+ if (!source) continue
142
+ const configList = Array.isArray(source) ? source : [source]
143
+ for (const cfg of configList) {
144
+ if (!cfg?.schema) continue
145
+ if (configs.has(cfg.schema)) continue
146
+ if (!collectAll && !schemas.includes(cfg.schema)) continue
147
+ const localized = localizeConfig(cfg, locale, defaultLocale)
148
+ configs.set(cfg.schema, applyDeferredDetail(localized, collections))
149
+ }
150
+ }
151
+
152
+ return configs
153
+ }
package/src/theme.js CHANGED
@@ -13,28 +13,6 @@ const SHADE_LEVELS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
13
13
  // Valid color contexts
14
14
  const VALID_CONTEXTS = ['light', 'medium', 'dark']
15
15
 
16
- // Default semantic tokens by context
17
- const DEFAULT_CONTEXT_TOKENS = {
18
- light: {
19
- bg: 'var(--neutral-50)',
20
- text: 'var(--neutral-950)',
21
- heading: 'var(--neutral-900)',
22
- link: 'var(--primary-600)',
23
- },
24
- medium: {
25
- bg: 'var(--neutral-100)',
26
- text: 'var(--neutral-950)',
27
- heading: 'var(--neutral-900)',
28
- link: 'var(--primary-600)',
29
- },
30
- dark: {
31
- bg: 'var(--neutral-900)',
32
- text: 'var(--neutral-50)',
33
- heading: 'white',
34
- link: 'var(--primary-400)',
35
- },
36
- }
37
-
38
16
  /**
39
17
  * Theme class for runtime theme access
40
18
  */
@@ -154,40 +132,42 @@ export default class Theme {
154
132
  // Context Access
155
133
  // ============================================================
156
134
 
157
- /**
158
- * Get a semantic token value for a context
135
+ /*
136
+ * REMOVED 2026-07-28: getContextToken(context, token) and
137
+ * getContextTokens(context), plus the DEFAULT_CONTEXT_TOKENS table backing
138
+ * them. Read this before reintroducing either.
159
139
  *
160
- * @param {string} context - Context name ('light', 'medium', 'dark')
161
- * @param {string} token - Token name (e.g., 'bg', 'text', 'link')
162
- * @returns {string|null} Token value or null
140
+ * They were unreachable-by-design rather than merely stale. A lookup keyed on
141
+ * (context, token) answers "what does the .context-<name> class set by
142
+ * default?", but the question callers ask is "what colour is --heading HERE",
143
+ * and those diverge for two reasons no such lookup can see:
163
144
  *
164
- * @example
165
- * theme.getContextToken('light', 'bg') // → "var(--neutral-50)"
166
- * theme.getContextToken('dark', 'text') // "var(--neutral-50)"
167
- */
168
- getContextToken(context, token) {
169
- // Check custom context tokens first
170
- const customContext = this._contexts[context]
171
- if (customContext && customContext[token]) {
172
- return customContext[token]
173
- }
174
-
175
- // Fall back to defaults
176
- const defaults = DEFAULT_CONTEXT_TOKENS[context]
177
- return defaults?.[token] || null
178
- }
179
-
180
- /**
181
- * Get all tokens for a context
145
+ * - a section's own `theme:` overrides, emitted as `#section-{id} { … }` by
146
+ * @uniweb/theming's buildSectionOverrides;
147
+ * - the active site scheme, where `.scheme-dark` redefines the root tokens.
182
148
  *
183
- * @param {string} context - Context name
184
- * @returns {Object} Token name value mapping
149
+ * So a correct-looking answer would still be wrong whenever a section
150
+ * overrides tokens or the visitor is in dark mode — the failure being a
151
+ * confident wrong value, not an error. (The table had also drifted: it held
152
+ * `bg`/`text`, retired in favour of `section`/`body`, and four tokens where
153
+ * the live set is ~25. Repopulating it would have made a misleading API look
154
+ * trustworthy, which is worse than leaving it visibly incomplete.)
155
+ *
156
+ * What to use instead:
157
+ * - an actually-resolved value (canvas, SVG, a chart matching the theme):
158
+ * getComputedStyle(el).getPropertyValue('--heading') — this accounts for
159
+ * both section overrides and the active scheme, which no static table can;
160
+ * - the defaults table itself (tooling, a theme editor):
161
+ * getDefaultContextTokens() from @uniweb/theming, which owns it;
162
+ * - ordinary component styling: the CSS variables directly. Reading tokens
163
+ * into JS to branch on them is the `isDark ? … : …` pattern semantic
164
+ * tokens exist to remove.
165
+ *
166
+ * SHADE_LEVELS below is duplicated from @uniweb/theming too, and was left
167
+ * deliberately: both copies are identical and the 11-step scale is fixed by
168
+ * the design, so there is no drift to prevent — noted so the next reader
169
+ * knows it was considered rather than missed.
185
170
  */
186
- getContextTokens(context) {
187
- const defaults = DEFAULT_CONTEXT_TOKENS[context] || {}
188
- const custom = this._contexts[context] || {}
189
- return { ...defaults, ...custom }
190
- }
191
171
 
192
172
  /**
193
173
  * Get the CSS class name for a context
@@ -423,6 +403,15 @@ export default class Theme {
423
403
  * point here; everything that consumes the model (runtime boot, kit's
424
404
  * useAppearance) imports THIS one.
425
405
  *
406
+ * A warning is not a mechanism, and this one has been tested: while it stood,
407
+ * a SECOND shared table in this same file — DEFAULT_CONTEXT_TOKENS, duplicating
408
+ * @uniweb/theming's — drifted to retired token names and lost twenty entries
409
+ * without anyone noticing, because nothing called the two methods that read it.
410
+ * It was removed 2026-07-28 (see the note where it sat). Treat the lockstep
411
+ * above as a live obligation with a track record, not a formality: if you find
412
+ * yourself copying a value out of @uniweb/theming into this file, that is the
413
+ * moment this comment is for.
414
+ *
426
415
  * @param {Object} appearance - the resolved theme.yml `appearance:` block
427
416
  * @returns {boolean}
428
417
  */