@uniweb/core 0.7.31 → 0.7.33

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,11 +1,13 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.31",
3
+ "version": "0.7.33",
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
- "./locale-config": "./src/locale-config.js"
8
+ "./fetch-config": "./src/fetch-config.js",
9
+ "./locale-config": "./src/locale-config.js",
10
+ "./section-id": "./src/section-id.js"
9
11
  },
10
12
  "files": [
11
13
  "src"
@@ -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
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The DOM id of a rendered section — one rule, shared by everything that
3
+ * needs to name a section.
4
+ *
5
+ * A section's id is written by the renderer and read by anything that links
6
+ * to a section: the search index's anchors, a table of contents, a deep link
7
+ * someone pastes. Those only agree if they derive the id the same way, and
8
+ * they cannot check each other — a mismatch produces no error anywhere. The
9
+ * link simply lands on the page and does not scroll, and if the target is the
10
+ * page you are already on, nothing visibly happens at all.
11
+ *
12
+ * That is not hypothetical. The renderers moved from the positional `id` to
13
+ * `stableId` (so an id survives reordering) and the search extractor was not
14
+ * updated, so it kept emitting `Section1` while the DOM said
15
+ * `section-what-is-uniweb`. Every section-level search result on every site
16
+ * pointed at a fragment that did not exist, and no test failed.
17
+ *
18
+ * So this is deliberately the ONLY place the rule is written down, and it
19
+ * captures both halves — which identity to use AND how to spell it. A helper
20
+ * that only formatted a value the caller picked would have prevented the
21
+ * spelling half of that bug and none of the identity half.
22
+ *
23
+ * **Zero imports, and it must stay that way.** `@uniweb/projections` consumes
24
+ * this through the leaf subpath `@uniweb/core/section-id` because its
25
+ * environment contract forbids the bare `@uniweb/core` entry (that pulls in
26
+ * semantic-parser and theming). Adding an import here would break that
27
+ * package's `tests/environment.test.js`.
28
+ */
29
+
30
+ /** Prefix for every section wrapper id. */
31
+ const PREFIX = 'section-'
32
+
33
+ /**
34
+ * The DOM id for a section, from either a Block or the raw section data.
35
+ *
36
+ * Accepts both shapes on purpose: the runtime holds `Block` instances while
37
+ * build-time consumers hold plain objects off the wire. Both carry the same
38
+ * two fields, so one function serves both rather than each growing its own.
39
+ *
40
+ * `stableId` is preferred because it is derived from the section's filename
41
+ * (or an authored `id:`) and therefore survives reordering; the positional
42
+ * `id` is the fallback for content that has no stable identity.
43
+ *
44
+ * @param {{stableId?: string, id?: string|number}} section - A Block, or a
45
+ * section object from site content.
46
+ * @returns {string} e.g. `section-hero`. Returns `section-unknown` rather
47
+ * than an id ending in `undefined` when a section carries no identity at
48
+ * all — a wrong-but-obvious anchor beats a malformed one.
49
+ */
50
+ export function sectionDomId(section) {
51
+ if (!section) return `${PREFIX}unknown`
52
+ const id = section.stableId || section.id
53
+ return `${PREFIX}${id === undefined || id === null || id === '' ? 'unknown' : id}`
54
+ }
55
+
56
+ /**
57
+ * The same id as a URL fragment, for building a link to a section.
58
+ *
59
+ * Exists so a caller composing an href never has to remember whether the
60
+ * `#` is already included — the concatenation is the part people get wrong.
61
+ *
62
+ * @param {{stableId?: string, id?: string|number}} section
63
+ * @returns {string} e.g. `#section-hero`
64
+ */
65
+ export function sectionHash(section) {
66
+ return `#${sectionDomId(section)}`
67
+ }