@uniweb/build 0.44.5 → 0.46.0
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 +7 -7
- package/src/prerender.js +222 -69
- package/src/site/content-collector.js +178 -83
- package/src/site/data-fetcher.js +51 -7
- package/src/site/fetch-shapes.js +54 -0
- package/src/site/query-processor.js +35 -6
- package/src/uwx/emit-surface.json +1 -1
- package/src/uwx/project-writer.js +22 -7
- package/src/uwx/site-project.js +42 -21
- package/src/uwx/site.js +31 -18
- package/src/validate-data.js +3 -3
|
@@ -54,8 +54,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
|
|
|
54
54
|
import { existsSync } from 'node:fs'
|
|
55
55
|
import yaml from 'js-yaml'
|
|
56
56
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
57
|
-
import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
|
|
58
|
-
import { applyWhere, applySort } from './data-fetcher.js'
|
|
57
|
+
import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
|
|
58
|
+
import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
|
|
59
59
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
60
60
|
import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
|
|
61
61
|
import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
|
|
@@ -113,6 +113,7 @@ function parseQueryConfig(name, config) {
|
|
|
113
113
|
schema: config,
|
|
114
114
|
url: null,
|
|
115
115
|
route: null,
|
|
116
|
+
scope: null,
|
|
116
117
|
sort: null,
|
|
117
118
|
where: null,
|
|
118
119
|
filter: null,
|
|
@@ -122,6 +123,7 @@ function parseQueryConfig(name, config) {
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
refuseUnder(config.where, `queries.${name}`)
|
|
125
127
|
return {
|
|
126
128
|
name,
|
|
127
129
|
// The query's schema selects its records from the pool — `entities/{schema}/`
|
|
@@ -129,6 +131,9 @@ function parseQueryConfig(name, config) {
|
|
|
129
131
|
schema: config.schema || null,
|
|
130
132
|
url: config.url || null,
|
|
131
133
|
route: config.route || null,
|
|
134
|
+
// The folder branch the query reads (`records.yml` placement). ⛔ Not read
|
|
135
|
+
// here until 2026-09-11: a named query's `scope` was ignored on this lane.
|
|
136
|
+
scope: typeof config.scope === 'string' ? config.scope : null,
|
|
132
137
|
sort: config.sort || null,
|
|
133
138
|
// `where:` is the CANONICAL predicate; `filter:` is the deprecated string DSL
|
|
134
139
|
// it replaced. Both are carried and both are applied below, in the same order
|
|
@@ -142,7 +147,7 @@ function parseQueryConfig(name, config) {
|
|
|
142
147
|
},
|
|
143
148
|
// `deferred:` lists fields that are heavy (article body, full nested
|
|
144
149
|
// arrays). Those fields are stripped from the cascade payload that
|
|
145
|
-
// ships with `
|
|
150
|
+
// ships with `query: <name>` declarations, and per-record full files
|
|
146
151
|
// are emitted at public/data/<name>/<slug>.json. Components that
|
|
147
152
|
// need the full record fetch the per-record file on demand, either
|
|
148
153
|
// automatically on dynamic-route pages (entity-store routes the
|
|
@@ -673,6 +678,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
673
678
|
// Filter out nulls (unpublished items)
|
|
674
679
|
items = items.filter(Boolean)
|
|
675
680
|
|
|
681
|
+
// ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
|
|
682
|
+
// field a `[slug]` or `[...path]` page matches, and the one the records service
|
|
683
|
+
// serves. It is the record's FINAL slug: set here, after every format has been
|
|
684
|
+
// read and flattened, so a frontmatter `slug:` (which wins over the filename),
|
|
685
|
+
// a BibTeX cite key and an array-form file's own `slug` all count — exactly what
|
|
686
|
+
// our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
|
|
687
|
+
// foundations and templates read it.
|
|
688
|
+
items = items.map((item) => (
|
|
689
|
+
item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
|
|
690
|
+
? { ...item, $name: String(item.slug) }
|
|
691
|
+
: item
|
|
692
|
+
))
|
|
693
|
+
|
|
676
694
|
warnDuplicateSlugs(items, config.name)
|
|
677
695
|
|
|
678
696
|
// `route:` on the query — bake each record's canonical href.
|
|
@@ -711,8 +729,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
|
|
|
711
729
|
// the sync wire, stored — and never applied, while the DEPRECATED one it replaced
|
|
712
730
|
// worked. An author following current guidance got silence and shipped unfiltered
|
|
713
731
|
// data. Pinned by `tests/collection-query-terms.test.js`.
|
|
714
|
-
|
|
715
|
-
|
|
732
|
+
// ⭐ ONLY THE `where` FIXED FOR EVERY PAGE. A clause bound to the route —
|
|
733
|
+
// `where: { tag: :dir }` — cannot be applied to a file written once for every
|
|
734
|
+
// page; the runtime binds it per page (`@uniweb/core/fetch-config`,
|
|
735
|
+
// `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
|
|
736
|
+
// `':dir'`, and the query compiled to no records (measured).
|
|
737
|
+
//
|
|
738
|
+
// ⛔ `scope` is NEVER baked, fixed or routed. The runtime applies the one that
|
|
739
|
+
// wins — a page fetch's own, else this query's — which is what the records
|
|
740
|
+
// service does. Baked here, a page's `scope:` could only narrow inside the
|
|
741
|
+
// query's branch on a static site and would replace it on a hosted one.
|
|
742
|
+
const fixed = withoutRouteVariables({ where: config.where })
|
|
743
|
+
if (fixed.where) {
|
|
744
|
+
items = applyWhere(items, fixed.where)
|
|
716
745
|
}
|
|
717
746
|
|
|
718
747
|
// Apply sort
|
|
@@ -884,7 +913,7 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
|
|
|
884
913
|
if (deferred && deferred.length > 0) {
|
|
885
914
|
// `deferred:` is set — emit two payloads:
|
|
886
915
|
// 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
|
|
887
|
-
// This is what `
|
|
916
|
+
// This is what `query: <name>` declarations deliver everywhere.
|
|
888
917
|
// 2. Per-record full files at /data/<name>/<slug>.json with every field.
|
|
889
918
|
// Dynamic-route singular fetches and useEntityDetail hooks read these.
|
|
890
919
|
const recordsDir = join(dataDir, name)
|
|
@@ -19,15 +19,22 @@ import { parseFrontmatter } from './entity-source.js'
|
|
|
19
19
|
import { renderEntityDocument } from './backfill.js'
|
|
20
20
|
import { queriesYmlPath } from './queries-config.js'
|
|
21
21
|
import { recordsYmlPath } from '../site/records-config.js'
|
|
22
|
+
import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
22
23
|
|
|
23
24
|
// Frontmatter keys that belong to the CCA framework / the developer's local
|
|
24
25
|
// authoring, not to externally-editable params. On a section write an existing
|
|
25
26
|
// reserved key is preserved and never overwritten by incoming params, so a
|
|
26
27
|
// projection doesn't churn fields it didn't author (the surgical-update bar).
|
|
28
|
+
//
|
|
29
|
+
// ⭐ The declaration keys — `query`, `fetch`, and the retired `data` — are one
|
|
30
|
+
// group: a section that declares its data locally, under any of them, keeps that
|
|
31
|
+
// declaration, and an incoming one under a different key does not land beside it
|
|
32
|
+
// (a file holding two is refused by the build).
|
|
27
33
|
export const DEFAULT_RESERVED_FRONTMATTER = new Set([
|
|
28
34
|
'type',
|
|
29
35
|
'preset',
|
|
30
36
|
'input',
|
|
37
|
+
'query',
|
|
31
38
|
'fetch',
|
|
32
39
|
'data',
|
|
33
40
|
'nest',
|
|
@@ -168,6 +175,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
168
175
|
const { frontmatter, body: existingBody } = parseFrontmatter(existing, filePath)
|
|
169
176
|
|
|
170
177
|
const nextFrontmatter = { ...frontmatter }
|
|
178
|
+
const declaresLocally = DECLARATION_KEYS.some((k) => k in frontmatter)
|
|
171
179
|
if (params) {
|
|
172
180
|
for (const [key, value] of Object.entries(params)) {
|
|
173
181
|
// A reserved key is preserved only when it already exists locally (the
|
|
@@ -175,6 +183,7 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
175
183
|
// there is nothing to protect, so the incoming value fills it — that's
|
|
176
184
|
// how a newly-projected section gets its `type`/`nest`/etc.
|
|
177
185
|
if (reserved.has(key) && key in frontmatter) continue
|
|
186
|
+
if (reserved.has(key) && DECLARATION_KEYS.includes(key) && declaresLocally) continue
|
|
178
187
|
if (value === null || value === undefined) delete nextFrontmatter[key]
|
|
179
188
|
else nextFrontmatter[key] = value
|
|
180
189
|
}
|
|
@@ -186,11 +195,12 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
186
195
|
|
|
187
196
|
// Shallow-merge `changes` into a YAML config file and write idempotently. A key
|
|
188
197
|
// whose value is null/undefined is deleted; an object value is shallow-merged one
|
|
189
|
-
// level deep (so partial `theme` / `build` updates don't drop sibling keys)
|
|
190
|
-
// other value replaces. NOTE: this re-dumps the file,
|
|
191
|
-
// not preserved — acceptable for machine-owned
|
|
192
|
-
// merges for hand-authored config files are a
|
|
193
|
-
|
|
198
|
+
// level deep (so partial `theme` / `build` updates don't drop sibling keys) unless
|
|
199
|
+
// the key is in `replace`; any other value replaces. NOTE: this re-dumps the file,
|
|
200
|
+
// so author comments/order are not preserved — acceptable for machine-owned
|
|
201
|
+
// config, but comment-preserving merges for hand-authored config files are a
|
|
202
|
+
// quality bar to revisit.
|
|
203
|
+
function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
194
204
|
let existing = {}
|
|
195
205
|
try {
|
|
196
206
|
existing = yaml.load(readFileSync(filePath, 'utf8')) || {}
|
|
@@ -200,7 +210,7 @@ function mergeYamlConfig(filePath, changes) {
|
|
|
200
210
|
for (const [key, value] of Object.entries(changes)) {
|
|
201
211
|
if (value === null || value === undefined) {
|
|
202
212
|
delete existing[key]
|
|
203
|
-
} else if (typeof value === 'object' && !Array.isArray(value)) {
|
|
213
|
+
} else if (typeof value === 'object' && !Array.isArray(value) && !replace.includes(key)) {
|
|
204
214
|
existing[key] = { ...(existing[key] || {}), ...value }
|
|
205
215
|
} else {
|
|
206
216
|
existing[key] = value
|
|
@@ -212,10 +222,15 @@ function mergeYamlConfig(filePath, changes) {
|
|
|
212
222
|
/**
|
|
213
223
|
* Merge `config` into `site.yml` (shallow). Preserves keys not present in the
|
|
214
224
|
* update (foundation, base, paths, …).
|
|
225
|
+
*
|
|
226
|
+
* ⛔ The declaration keys (`query` / `fetch` / `data`) are written WHOLE, never
|
|
227
|
+
* merged: a declaration is one value, and merging an incoming `fetch:` into the
|
|
228
|
+
* local one kept whatever key the remote no longer has — a stale `limit` survived
|
|
229
|
+
* every pull.
|
|
215
230
|
* @returns {'updated'|'unchanged'}
|
|
216
231
|
*/
|
|
217
232
|
export function writeSiteConfig(siteRoot, config) {
|
|
218
|
-
return mergeYamlConfig(join(siteRoot, 'site.yml'), config)
|
|
233
|
+
return mergeYamlConfig(join(siteRoot, 'site.yml'), config, { replace: DECLARATION_KEYS })
|
|
219
234
|
}
|
|
220
235
|
|
|
221
236
|
/**
|
package/src/uwx/site-project.js
CHANGED
|
@@ -39,7 +39,7 @@ import { createHash } from 'node:crypto'
|
|
|
39
39
|
import yaml from 'js-yaml'
|
|
40
40
|
import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
|
|
41
41
|
import { declarationsToQueriesYml } from './records-project.js'
|
|
42
|
-
import {
|
|
42
|
+
import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
43
43
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
|
|
44
44
|
import { buildFreeformPath } from '../i18n/freeform.js'
|
|
45
45
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
@@ -138,8 +138,8 @@ const INFO_TO_SITE_YML = {
|
|
|
138
138
|
// Authored-only, like `submit` and `assistant`: a host's tracking endpoint is
|
|
139
139
|
// offered through `config.services.tracking` and resolved at render, so it
|
|
140
140
|
// never enters `info` and a pull cannot launder it into authored config.
|
|
141
|
-
// ⛔
|
|
142
|
-
// `site.yml::
|
|
141
|
+
// ⛔ The site's declaration is not verbatim — see the explicit `settings.fetch`
|
|
142
|
+
// branch below: it projects to `site.yml::query` or `site.yml::fetch`.
|
|
143
143
|
template: 'template',
|
|
144
144
|
// ⭐ `tags` — authored, non-localized tokens; the filter facet for a list of site
|
|
145
145
|
// cards. Round-trips verbatim like any authored list.
|
|
@@ -199,6 +199,16 @@ const SETTINGS_TO_SITE_YML = {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
/** An authored YAML config as it stands, or null when there is none to read. */
|
|
203
|
+
function readAuthoredYaml(filePath) {
|
|
204
|
+
try {
|
|
205
|
+
const value = yaml.load(readFileSync(filePath, 'utf8'))
|
|
206
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
|
|
207
|
+
} catch {
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
202
212
|
/**
|
|
203
213
|
* Project a site-content document's `info` (+ `extensions`) onto the site's
|
|
204
214
|
* config files: `site.yml`, `theme.yml`, and `head.html`. Idempotent; only the
|
|
@@ -238,22 +248,21 @@ export function siteInfoToConfig({ document, siteRoot, sourceLocale = LOCALIZED_
|
|
|
238
248
|
if (info[infoKey] !== undefined) siteChanges[ymlKey] = info[infoKey]
|
|
239
249
|
}
|
|
240
250
|
|
|
241
|
-
// ⭐ `settings.fetch` → `site.yml::fetch
|
|
242
|
-
//
|
|
243
|
-
// `
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
251
|
+
// ⭐ `settings.fetch` → `site.yml::query` or `site.yml::fetch` — the key the file
|
|
252
|
+
// already uses, else `query:` when the declaration is nothing but query names
|
|
253
|
+
// (`authorableDeclaration`). The wire carries the desugared form and cannot say
|
|
254
|
+
// which was typed, and the round-trip law keeps the authored KEY as well as the
|
|
255
|
+
// value (the sync format's round-trip law): the site lane once wrote the shorthand back verbatim,
|
|
256
|
+
// so an author who typed `fetch:` pushed, pulled, and got the shorthand back.
|
|
257
|
+
// The other declaration keys are removed, so the file never holds two (the
|
|
258
|
+
// build refuses that) — a retired `data:` included.
|
|
249
259
|
//
|
|
250
260
|
// ⛔ The producer always desugars, so the wire carries a config or a list of them —
|
|
251
261
|
// never a bare string. Nothing here accommodates an older shape.
|
|
252
262
|
const wireFetch = settingsSection.fetch
|
|
253
263
|
if (wireFetch !== undefined) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
: authorableFetch(wireFetch)
|
|
264
|
+
const { key, value } = authorableDeclaration(wireFetch, readAuthoredYaml(join(siteRoot, 'site.yml')))
|
|
265
|
+
for (const k of DECLARATION_KEYS) siteChanges[k] = k === key ? value : null
|
|
257
266
|
}
|
|
258
267
|
|
|
259
268
|
// The `settings` Section — authored configuration that is not identity, so it is
|
|
@@ -409,8 +418,13 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
|
|
|
409
418
|
if (theme_override !== undefined) frontmatter.theme = theme_override
|
|
410
419
|
if (preset !== undefined) frontmatter.preset = preset
|
|
411
420
|
if (input !== undefined) frontmatter.input = input
|
|
412
|
-
// Invert the build's resolution rather than copy it — see fetch-shapes.js.
|
|
413
|
-
|
|
421
|
+
// Invert the build's resolution rather than copy it — see fetch-shapes.js. A new
|
|
422
|
+
// file gets `query:` for a declaration of names alone; a section that declares
|
|
423
|
+
// its data locally keeps it (`writeSectionFile`, the declaration keys).
|
|
424
|
+
if (fetch !== undefined) {
|
|
425
|
+
const { key, value } = authorableDeclaration(fetch)
|
|
426
|
+
frontmatter[key] = value
|
|
427
|
+
}
|
|
414
428
|
if (stable_id !== undefined) frontmatter.id = stable_id
|
|
415
429
|
|
|
416
430
|
const body = insets ? reinlineInsets(sourceContent, insets) : sourceContent
|
|
@@ -496,10 +510,13 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
|
|
|
496
510
|
// page.yml/folder.yml. On a merge write these are replaced wholesale (a managed
|
|
497
511
|
// key the record no longer carries is dropped); any other key is author-authored
|
|
498
512
|
// and preserved. Keep in sync with pageRecordToYml below.
|
|
513
|
+
// ⚠️ `query` and `data` are managed with `fetch`: the declaration is written under
|
|
514
|
+
// ONE key (`authorableDeclaration`), so the other two — a retired `data:`
|
|
515
|
+
// included — are dropped rather than left beside it.
|
|
499
516
|
const PAGE_YML_MANAGED_KEYS = new Set([
|
|
500
517
|
'id', 'title', 'description', 'label', 'keywords', 'index', 'hidden',
|
|
501
518
|
'hideIn', 'knowledge', 'trackSections', 'redirect', 'rewrite', 'layout', 'seo',
|
|
502
|
-
'fetch', 'sections',
|
|
519
|
+
'query', 'fetch', 'data', 'sections',
|
|
503
520
|
])
|
|
504
521
|
|
|
505
522
|
// Inverse of site.js buildPageData → the `page.yml` / `folder.yml` object.
|
|
@@ -507,7 +524,7 @@ const PAGE_YML_MANAGED_KEYS = new Set([
|
|
|
507
524
|
// directory (name, page.yml vs folder.yml, `[param]/`), not the config body.
|
|
508
525
|
// Identity (the backend uuid) is NOT written here — it lives in the gitignored
|
|
509
526
|
// `.uniweb/` index so authored files stay clean.
|
|
510
|
-
function pageRecordToYml(record, sectionsArray, sourceLocale) {
|
|
527
|
+
function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
|
|
511
528
|
const y = {}
|
|
512
529
|
if (record.stable_id !== undefined) y.id = record.stable_id
|
|
513
530
|
const title = unwrapLocalized(record.title, sourceLocale)
|
|
@@ -531,8 +548,12 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
|
|
|
531
548
|
if (record.rewrite !== undefined) y.rewrite = record.rewrite
|
|
532
549
|
if (record.layout !== undefined) y.layout = record.layout
|
|
533
550
|
if (record.seo !== undefined) y.seo = record.seo
|
|
534
|
-
// Invert the build's resolution rather than copy it — see fetch-shapes.js.
|
|
535
|
-
|
|
551
|
+
// Invert the build's resolution rather than copy it — see fetch-shapes.js. Under
|
|
552
|
+
// the key the file already uses (`existing`), else `query:` for names alone.
|
|
553
|
+
if (record.fetch !== undefined) {
|
|
554
|
+
const { key, value } = authorableDeclaration(record.fetch, existing)
|
|
555
|
+
y[key] = value
|
|
556
|
+
}
|
|
536
557
|
// `sections:` exists to preserve ORDER and NESTING, which the projected filenames
|
|
537
558
|
// can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
|
|
538
559
|
// decide MEMBERSHIP — and a bare list does: the collector reads a list without
|
|
@@ -635,7 +656,7 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
|
|
|
635
656
|
const ymlPath = join(pageDir, ymlName)
|
|
636
657
|
// Merge (not full-dump) so author-added keys survive a pull; the projector
|
|
637
658
|
// owns only PAGE_YML_MANAGED_KEYS.
|
|
638
|
-
writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale), PAGE_YML_MANAGED_KEYS)
|
|
659
|
+
writeMergedYaml(ymlPath, pageRecordToYml(record, sectionsArray, sourceLocale, readAuthoredYaml(ymlPath)), PAGE_YML_MANAGED_KEYS)
|
|
639
660
|
report.pages.push(ymlPath)
|
|
640
661
|
|
|
641
662
|
writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route)
|
package/src/uwx/site.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import { readdir, readFile } from 'node:fs/promises'
|
|
41
41
|
import { existsSync } from 'node:fs'
|
|
42
|
-
import { join, parse } from 'node:path'
|
|
42
|
+
import { join, parse, relative } from 'node:path'
|
|
43
43
|
import {
|
|
44
44
|
readYamlFile,
|
|
45
45
|
readFolderConfig,
|
|
@@ -52,8 +52,12 @@ import {
|
|
|
52
52
|
parseWildcardArray,
|
|
53
53
|
applyWildcardOrder,
|
|
54
54
|
processMarkdownFile,
|
|
55
|
-
|
|
55
|
+
declaredFetch,
|
|
56
|
+
checkDeclaration,
|
|
57
|
+
fetchFromQueryShorthand,
|
|
58
|
+
assertRouteFolder,
|
|
56
59
|
} from '../site/content-collector.js'
|
|
60
|
+
import { refuseUnder } from '../site/data-fetcher.js'
|
|
57
61
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
58
62
|
import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
|
|
59
63
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
@@ -194,7 +198,7 @@ function mapSectionData(section) {
|
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
function buildPageData(config, ctx) {
|
|
197
|
-
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations } =
|
|
201
|
+
const { slug, mode, isDynamic, paramName, isRoot, siteIndex, sourceLocale, translations, where } =
|
|
198
202
|
ctx
|
|
199
203
|
// The page `slug` is the localized route source — a `{lang: slug}` map (the
|
|
200
204
|
// site-content Model declares it localized; greenlit 2026-06-13). A single-locale
|
|
@@ -232,10 +236,16 @@ function buildPageData(config, ctx) {
|
|
|
232
236
|
setIf(data, 'rewrite', config.rewrite)
|
|
233
237
|
setIf(data, 'layout', config.layout)
|
|
234
238
|
setIf(data, 'seo', config.seo)
|
|
235
|
-
// ⭐ A `
|
|
239
|
+
// ⭐ A `query:` or `fetch:` LIST means "fetch each" — one declaration per entry. Before
|
|
236
240
|
// 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
|
|
237
241
|
// carried one dataset for a page that asked for several.
|
|
238
|
-
|
|
242
|
+
// `fetch:`, or the `query:` shorthand, read and refused exactly as the build
|
|
243
|
+
// reads them (`declaredFetch`) — a `folder.yml` as much as a `page.yml`.
|
|
244
|
+
let fetch = declaredFetch(config, where ?? 'page.yml')
|
|
245
|
+
// `where: { path: { under } }` is refused here as the build refuses it
|
|
246
|
+
// (`parseFetchConfig`): a site that cannot build must not sync either. A
|
|
247
|
+
// section's fetch is refused where the collector parses it.
|
|
248
|
+
for (const one of [fetch].flat()) refuseUnder(one?.where, 'fetch')
|
|
239
249
|
// Resolve the authored `query:` shorthand to the runtime-fetchable
|
|
240
250
|
// `path: /data/<name>.json` (the static convention the default-fetcher uses).
|
|
241
251
|
// A shell/backend-hosted site renders client-side with NO prerender, so the
|
|
@@ -243,7 +253,7 @@ function buildPageData(config, ctx) {
|
|
|
243
253
|
// it would never resolve at render (the static build resolves it the same way
|
|
244
254
|
// in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
|
|
245
255
|
// at `<base>/data/<name>.json`.
|
|
246
|
-
// ⛔ **Mapped, not read.** A `
|
|
256
|
+
// ⛔ **Mapped, not read.** A `query:`/`fetch:` LIST reaches here as an array, and
|
|
247
257
|
// `fetch.query` on one is `undefined` — so a property test would skip the
|
|
248
258
|
// resolution below and put bare `{ query }` entries on the wire with no
|
|
249
259
|
// `path`, no `as` and no `schema`. That is the silent-empty class: a payload
|
|
@@ -548,14 +558,20 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
548
558
|
const { siteRoot, siteIndex, sourceLocale, translations } = ctx
|
|
549
559
|
const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
|
|
550
560
|
const out = []
|
|
561
|
+
// A folder inside a `[...path]` folder can never be reached, and `[dir]` /
|
|
562
|
+
// `[path]` would name a route variable — refused here as the collector refuses
|
|
563
|
+
// them, so a site that cannot build cannot sync either (ruled 2026-09-11).
|
|
564
|
+
const insideCatchAll = (parentSlugPath || '').split('/').includes(CATCH_ALL_MARKER)
|
|
551
565
|
for (let i = 0; i < folders.length; i++) {
|
|
552
566
|
const f = folders[i]
|
|
567
|
+
assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
|
|
553
568
|
const dyn = f.dirName.match(DYNAMIC_RE)
|
|
554
569
|
const slug = dyn ? dyn[1] : f.name
|
|
555
570
|
const mode = f.source === 'folder.yml' ? 'folder' : 'page'
|
|
556
571
|
const slugPath = parentSlugPath ? `${parentSlugPath}/${slug}` : slug
|
|
557
572
|
|
|
558
573
|
const data = buildPageData(f.config, {
|
|
574
|
+
where: relative(siteRoot, join(f.path, f.source)),
|
|
559
575
|
slug,
|
|
560
576
|
mode,
|
|
561
577
|
isDynamic: !!dyn,
|
|
@@ -803,6 +819,7 @@ const DECL_NOT_ON_WIRE = new Set([
|
|
|
803
819
|
function queriesNested(declarations, uuids = null, org = null) {
|
|
804
820
|
const out = []
|
|
805
821
|
for (const [name, d] of Object.entries(declarations)) {
|
|
822
|
+
refuseUnder(d.where, `queries.${name}`)
|
|
806
823
|
const data = {}
|
|
807
824
|
const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
|
|
808
825
|
setIf(data, 'source', source)
|
|
@@ -1068,10 +1085,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1068
1085
|
// summarized.
|
|
1069
1086
|
setIf(settings, 'agents', siteYml.agents)
|
|
1070
1087
|
|
|
1071
|
-
// ⭐ The site-level fetch, DESUGARED and under its real name. `
|
|
1088
|
+
// ⭐ The site-level fetch, DESUGARED and under its real name. `query:` is the
|
|
1072
1089
|
// authoring shorthand for `fetch:` and every other tier already calls the wire
|
|
1073
|
-
// field `fetch`; the site tier
|
|
1074
|
-
|
|
1090
|
+
// field `fetch`; the site tier's wire field was `data` until 2026-09-09.
|
|
1091
|
+
// `query:` / `fetch:` checked as the build checks them, and `under` refused;
|
|
1092
|
+
// the `query:` shorthand carries no `where`. ⚠️ The desugaring stays inline in
|
|
1093
|
+
// `setIf`: `gen-emit-surface.mjs` reads the published key's sources off it.
|
|
1094
|
+
checkDeclaration(siteYml, 'site.yml')
|
|
1095
|
+
for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
|
|
1096
|
+
setIf(settings, 'fetch', siteYml.fetch ?? fetchFromQueryShorthand(siteYml.query))
|
|
1075
1097
|
|
|
1076
1098
|
// ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
|
|
1077
1099
|
// the page and folder tiers have always had. `hide` is a non-destructive per-area
|
|
@@ -1249,15 +1271,6 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1249
1271
|
// endpoint; here it leaves the site with the RIGHT answer.
|
|
1250
1272
|
//
|
|
1251
1273
|
// The provisioned record rides the `$services` section instead (see servicesNested).
|
|
1252
|
-
// ⭐ DESUGARED, like every other tier. `data:` is the shorthand for `fetch:`
|
|
1253
|
-
// (`data: articles` → `{ query: 'articles' }`), and the page level has always
|
|
1254
|
-
// desugared before emitting. The site level shipped the bare string until
|
|
1255
|
-
// 2026-09-09, so `info.data` carried two different shapes depending on which
|
|
1256
|
-
// key the author happened to type.
|
|
1257
|
-
//
|
|
1258
|
-
// 📌 The wire NAME is still `data` and becomes `fetch` when the Section moves —
|
|
1259
|
-
// renaming it now would be a second destructive wire change for a cosmetic gain;
|
|
1260
|
-
// renaming it during the move is free.
|
|
1261
1274
|
// ⛔ THE CONFIGURATION KEYS ARE NOT HERE — they ride the `settings` Section
|
|
1262
1275
|
// (`settingsNested` above). `info` is the BRIEF: what a card or a select dropdown
|
|
1263
1276
|
// renders, plus what a listing can filter on. Eighteen keys moved off it on
|
package/src/validate-data.js
CHANGED
|
@@ -54,7 +54,7 @@ import { processQueries } from './site/query-processor.js'
|
|
|
54
54
|
* agree on which schema governs what by construction.
|
|
55
55
|
*
|
|
56
56
|
* Inputs are consumed from what the canonical build parsers already compute —
|
|
57
|
-
* `section.fetch` (the binding resolved from `
|
|
57
|
+
* `section.fetch` (the binding resolved from `query:` / `fetch:`) and
|
|
58
58
|
* `schema.json[type].data` (the key→ref bindings). Re-deriving either would let
|
|
59
59
|
* this command and the build disagree about what feeds what.
|
|
60
60
|
*
|
|
@@ -139,7 +139,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
139
139
|
`but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}. ` +
|
|
140
140
|
`The section will render with no data and nothing else will say so. ` +
|
|
141
141
|
`Name the query for the key the section reads, or give the section its own ` +
|
|
142
|
-
`\`
|
|
142
|
+
`\`query: <name>\`.`,
|
|
143
143
|
// One user per declared key, so `uniweb validate` can print
|
|
144
144
|
// `used by /team › Team › data.team` — the key is the thing to rename.
|
|
145
145
|
users: declaredKeys.map((k) => ({ route: page.route, section: type, key: k })),
|
|
@@ -479,7 +479,7 @@ async function loadStandardSchemas() {
|
|
|
479
479
|
*/
|
|
480
480
|
function collectInputs(section, pageFetch, siteFetch) {
|
|
481
481
|
const byKey = new Map()
|
|
482
|
-
// ⭐ Each level may declare SEVERAL — `
|
|
482
|
+
// ⭐ Each level may declare SEVERAL — `query: [team, articles]` — so each is
|
|
483
483
|
// flattened rather than read. Order is least- to most-specific and `set`
|
|
484
484
|
// overwrites, which is what makes a section's declaration win the key.
|
|
485
485
|
for (const source of [siteFetch, pageFetch, section.fetch]) {
|