@uniweb/build 0.51.0 → 0.52.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/dev/api-mount.js +2 -1
- package/src/dev/plugin.js +15 -11
- package/src/generate-entry.js +29 -2
- package/src/i18n/freeform.js +116 -28
- package/src/i18n/index.js +11 -19
- package/src/i18n/records.js +144 -28
- package/src/prerender.js +4 -13
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +3 -2
- package/src/schema.js +13 -3
- package/src/site/build-site-data.js +11 -5
- package/src/site/config.js +2 -1
- package/src/site/content-collector.js +17 -3
- package/src/site/data-fetcher.js +68 -144
- package/src/site/deploy-config.js +2 -1
- package/src/site/fetch-shapes.js +19 -7
- package/src/site/plugin.js +97 -41
- package/src/site/queries-config.js +15 -4
- package/src/site/query-processor.js +110 -62
- package/src/site/records-config.js +3 -2
- package/src/utils/frontmatter.js +2 -1
- package/src/utils/yaml-schema.js +44 -0
- package/src/uwx/backfill.js +3 -2
- package/src/uwx/entity-source.js +2 -1
- package/src/uwx/project-writer.js +3 -2
- package/src/uwx/records-project.js +9 -3
- package/src/uwx/records.js +7 -4
- package/src/uwx/site-project.js +2 -1
- package/src/uwx/site.js +6 -5
- package/src/validate-data.js +9 -8
package/src/site/data-fetcher.js
CHANGED
|
@@ -19,6 +19,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { existsSync } from 'node:fs'
|
|
21
21
|
import yaml from 'js-yaml'
|
|
22
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
22
23
|
import { matchWhere, sortRecords, queryDataUrl, evaluateQuery, whereOutsideLanguage, CURRENT_MODES } from '@uniweb/core'
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -113,11 +114,11 @@ export function applyPostProcessing(data, config, { locale = null } = {}) {
|
|
|
113
114
|
* @example
|
|
114
115
|
* // A query name
|
|
115
116
|
* parseFetchConfig('team')
|
|
116
|
-
* // Returns: { query: 'team', path: '/data/team.json', as: 'team'
|
|
117
|
+
* // Returns: { query: 'team', path: '/data/team.json', as: 'team' }
|
|
117
118
|
*
|
|
118
119
|
* // A binding that adapts its query
|
|
119
120
|
* parseFetchConfig({ query: 'articles', as: 'latest', limit: 3, sort: 'date desc' })
|
|
120
|
-
* // Returns: { query: 'articles', path: '/data/articles.json', as: 'latest', limit: 3, sort: 'date desc'
|
|
121
|
+
* // Returns: { query: 'articles', path: '/data/articles.json', as: 'latest', limit: 3, sort: 'date desc' }
|
|
121
122
|
*/
|
|
122
123
|
// ─── Unrecognized-key reporting ───────────────────────────────────────
|
|
123
124
|
//
|
|
@@ -144,8 +145,9 @@ const RECOGNIZED_FETCH_KEYS = {
|
|
|
144
145
|
// ⛔ `path`, `url`, `method`, `body`, `transform` and `detail` are not binding keys
|
|
145
146
|
// either — a binding names a query, which supplies its source (`refuseBinding`).
|
|
146
147
|
// The source shape that took them (`{ path }`, `{ url }`) was retired on 2026-09-13.
|
|
148
|
+
// ⛔ Nor `merge`, retired 2026-09-14 and refused (`refuseMerge`).
|
|
147
149
|
query: new Set([
|
|
148
|
-
'query', 'as', 'prerender',
|
|
150
|
+
'query', 'as', 'prerender',
|
|
149
151
|
'where', 'limit', 'sort', 'current', 'detailPage',
|
|
150
152
|
]),
|
|
151
153
|
}
|
|
@@ -275,6 +277,49 @@ function refuseRefineAndMisplacedCurrent(fetch, context, level) {
|
|
|
275
277
|
}
|
|
276
278
|
}
|
|
277
279
|
|
|
280
|
+
/**
|
|
281
|
+
* ⛔ `limit:` IS A WHOLE NUMBER OF RECORDS, 0 OR MORE — on a query and on a fetch. Every
|
|
282
|
+
* lane cuts a set only by a number above 0 (`@uniweb/core`), so until 2026-09-14
|
|
283
|
+
* `limit: "5"` and `limit: -1` meant no limit and `limit: 2.5` cut to a count nobody
|
|
284
|
+
* wrote, with nothing said. `0` means no limit, and so does a `limit:` with no value
|
|
285
|
+
* (null), as though it were not written.
|
|
286
|
+
*
|
|
287
|
+
* @param {*} limit - the authored `limit`
|
|
288
|
+
* @param {string} context - where the declaration sits, for the message
|
|
289
|
+
*/
|
|
290
|
+
export function refuseLimit(limit, context) {
|
|
291
|
+
if (limit === undefined || limit === null) return
|
|
292
|
+
if (Number.isInteger(limit) && limit >= 0) return
|
|
293
|
+
const instead =
|
|
294
|
+
typeof limit === 'string' && /^\s*\d+\s*$/.test(limit)
|
|
295
|
+
? `Write \`limit: ${Number(limit)}\`, unquoted.`
|
|
296
|
+
: Number.isInteger(limit)
|
|
297
|
+
? 'For no limit, write `limit: 0` or leave it out.'
|
|
298
|
+
: 'Write a count, e.g. `limit: 5`, or leave it out for no limit.'
|
|
299
|
+
throw new Error(
|
|
300
|
+
`[uniweb] ${context}: \`limit: ${JSON.stringify(limit)}\` — \`limit:\` is a whole number of records, 0 or more, ` +
|
|
301
|
+
`and \`0\` means no limit. ${instead}`
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* ⛔ `merge:` IS RETIRED (2026-09-14) — refused whatever its value, `false` included.
|
|
307
|
+
* It never combined anything reliably: the build holds no parsed tagged data block for
|
|
308
|
+
* a fetch to merge with, and the prerender asks each key once. What a key receives is
|
|
309
|
+
* settled without it — a fetch fills the key it names, and a tagged data block under a
|
|
310
|
+
* key the component declares fills that key first.
|
|
311
|
+
*
|
|
312
|
+
* @param {Object} fetch - one authored binding
|
|
313
|
+
* @param {string} context - where the declaration sits, for the message
|
|
314
|
+
*/
|
|
315
|
+
function refuseMerge(fetch, context) {
|
|
316
|
+
if (fetch.merge === undefined) return
|
|
317
|
+
throw new Error(
|
|
318
|
+
`[uniweb] ${context}: \`merge:\` is retired — a fetch fills the key it names, and a tagged data block under a key ` +
|
|
319
|
+
`the component declares fills it first. Delete the \`merge:\` line.`
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
|
|
278
323
|
/**
|
|
279
324
|
* ⭐ WHAT A BINDING MAY NOT SAY — every refusal, in one place, for the two readers
|
|
280
325
|
* of an authored binding: the build's parse (`parseFetchConfig`) and the sync push,
|
|
@@ -294,11 +339,13 @@ export function refuseBinding(fetch, context, { level = null } = {}) {
|
|
|
294
339
|
}
|
|
295
340
|
if (typeof fetch !== 'object' || Array.isArray(fetch)) return
|
|
296
341
|
refuseRefineAndMisplacedCurrent(fetch, context, level)
|
|
342
|
+
refuseMerge(fetch, context)
|
|
297
343
|
refuseSourceKeys(fetch, context)
|
|
298
344
|
if (fetch.collection === undefined) refuseQueryName(fetch.query, context, { object: true })
|
|
299
345
|
refuseUnder(fetch.where, context)
|
|
300
346
|
refuseOutsideLanguage(fetch.where, context)
|
|
301
347
|
refuseBindingScope(fetch, context)
|
|
348
|
+
refuseLimit(fetch.limit, context)
|
|
302
349
|
}
|
|
303
350
|
|
|
304
351
|
/**
|
|
@@ -549,7 +596,6 @@ export function parseFetchConfig(fetch, context = 'fetch', { level = null } = {}
|
|
|
549
596
|
// site's records is prerendered, an external query is the browser's
|
|
550
597
|
// (`@uniweb/core/fetch-config`) — and the parser does not know which this is.
|
|
551
598
|
prerender: fetch.prerender,
|
|
552
|
-
merge: fetch.merge ?? false,
|
|
553
599
|
// A binding's adaptations of its query, kept as authored: the resolver makes them
|
|
554
600
|
// the fetch's `narrow` of the query's set (`@uniweb/core/fetch-config`,
|
|
555
601
|
// `setAndNarrow`) — its `where` filters the set, its `sort` re-orders it, its
|
|
@@ -605,97 +651,10 @@ export function _resetRetiredSchemaWarnings() {
|
|
|
605
651
|
warnedRetiredSchema.clear()
|
|
606
652
|
}
|
|
607
653
|
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
* prerender (or the dev server) executes it — a build-lane feature, documented as
|
|
613
|
-
* such. It rode every shipped payload regardless, and a key on the payload that
|
|
614
|
-
* nothing reads is a key a consumer will one day read. Stripped at the two emit points framework owns — the link lane's
|
|
615
|
-
* `site-content.json` and the bundle lane's embed — AFTER the build has consumed
|
|
616
|
-
* it. ⛔ Not from the sync wire: that carries the author's declaration, which
|
|
617
|
-
* `pull` must round-trip.
|
|
618
|
-
*/
|
|
619
|
-
const BUILD_ONLY_FETCH_KEYS = ['merge']
|
|
620
|
-
|
|
621
|
-
function stripFetch(fetch) {
|
|
622
|
-
if (!fetch || typeof fetch !== 'object') return fetch
|
|
623
|
-
if (Array.isArray(fetch)) return fetch.map(stripFetch)
|
|
624
|
-
let changed = false
|
|
625
|
-
const out = {}
|
|
626
|
-
for (const [key, value] of Object.entries(fetch)) {
|
|
627
|
-
if (BUILD_ONLY_FETCH_KEYS.includes(key)) {
|
|
628
|
-
changed = true
|
|
629
|
-
continue
|
|
630
|
-
}
|
|
631
|
-
out[key] = value
|
|
632
|
-
}
|
|
633
|
-
return changed ? out : fetch
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
function stripSections(sections) {
|
|
637
|
-
if (!Array.isArray(sections)) return sections
|
|
638
|
-
return sections.map((section) => {
|
|
639
|
-
if (!section || typeof section !== 'object') return section
|
|
640
|
-
const fetch = stripFetch(section.fetch)
|
|
641
|
-
const subsections = stripSections(section.subsections)
|
|
642
|
-
if (fetch === section.fetch && subsections === section.subsections) return section
|
|
643
|
-
const out = { ...section }
|
|
644
|
-
if (fetch !== section.fetch) out.fetch = fetch
|
|
645
|
-
if (subsections !== section.subsections) out.subsections = subsections
|
|
646
|
-
return out
|
|
647
|
-
})
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
function stripPageLike(page) {
|
|
651
|
-
if (!page || typeof page !== 'object') return page
|
|
652
|
-
const fetch = stripFetch(page.fetch)
|
|
653
|
-
const sections = stripSections(page.sections)
|
|
654
|
-
if (fetch === page.fetch && sections === page.sections) return page
|
|
655
|
-
const out = { ...page }
|
|
656
|
-
if (fetch !== page.fetch) out.fetch = fetch
|
|
657
|
-
if (sections !== page.sections) out.sections = sections
|
|
658
|
-
return out
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* A copy of a site-content payload with the build-only fetch keys removed from
|
|
663
|
-
* every fetch declaration it carries: `config.fetch`, each page's, each
|
|
664
|
-
* section's (and subsection's), each layout area's, and the `config` inside
|
|
665
|
-
* `fetchedData` entries. Structural sharing — untouched objects are the same
|
|
666
|
-
* objects, so this is cheap on a large site.
|
|
667
|
-
*
|
|
668
|
-
* @param {Object} siteContent
|
|
669
|
-
* @returns {Object}
|
|
670
|
-
*/
|
|
671
|
-
export function stripBuildOnlyFetchKeys(siteContent) {
|
|
672
|
-
if (!siteContent || typeof siteContent !== 'object') return siteContent
|
|
673
|
-
const out = { ...siteContent }
|
|
674
|
-
if (out.config && typeof out.config === 'object' && out.config.fetch !== undefined) {
|
|
675
|
-
const fetch = stripFetch(out.config.fetch)
|
|
676
|
-
if (fetch !== out.config.fetch) out.config = { ...out.config, fetch }
|
|
677
|
-
}
|
|
678
|
-
if (Array.isArray(out.pages)) out.pages = out.pages.map(stripPageLike)
|
|
679
|
-
if (out.layouts && typeof out.layouts === 'object') {
|
|
680
|
-
const layouts = {}
|
|
681
|
-
for (const [name, areas] of Object.entries(out.layouts)) {
|
|
682
|
-
if (!areas || typeof areas !== 'object') { layouts[name] = areas; continue }
|
|
683
|
-
const next = {}
|
|
684
|
-
for (const [area, page] of Object.entries(areas)) next[area] = stripPageLike(page)
|
|
685
|
-
layouts[name] = next
|
|
686
|
-
}
|
|
687
|
-
out.layouts = layouts
|
|
688
|
-
}
|
|
689
|
-
if (out.notFound) out.notFound = stripPageLike(out.notFound)
|
|
690
|
-
if (Array.isArray(out.fetchedData)) {
|
|
691
|
-
out.fetchedData = out.fetchedData.map((entry) => {
|
|
692
|
-
if (!entry || typeof entry !== 'object') return entry
|
|
693
|
-
const config = stripFetch(entry.config)
|
|
694
|
-
return config === entry.config ? entry : { ...entry, config }
|
|
695
|
-
})
|
|
696
|
-
}
|
|
697
|
-
return out
|
|
698
|
-
}
|
|
654
|
+
// ⛔ NO BUILD-ONLY FETCH KEYS, AND NO STRIP. `stripBuildOnlyFetchKeys` removed `merge`
|
|
655
|
+
// from every shipped payload after the build had read it; `merge` was its only key, and
|
|
656
|
+
// with it retired (2026-09-14, `refuseMerge`) the parse emits nothing a runtime does not
|
|
657
|
+
// read, so there is nothing left to strip.
|
|
699
658
|
|
|
700
659
|
/**
|
|
701
660
|
* Execute a fetch operation
|
|
@@ -747,13 +706,13 @@ export async function executeFetch(config, options = {}) {
|
|
|
747
706
|
if (path.endsWith('.json')) {
|
|
748
707
|
data = JSON.parse(content)
|
|
749
708
|
} else if (path.endsWith('.yaml') || path.endsWith('.yml')) {
|
|
750
|
-
data = yaml.load(content)
|
|
709
|
+
data = yaml.load(content, YAML_OPTIONS)
|
|
751
710
|
} else {
|
|
752
711
|
// Try JSON first, then YAML
|
|
753
712
|
try {
|
|
754
713
|
data = JSON.parse(content)
|
|
755
714
|
} catch {
|
|
756
|
-
data = yaml.load(content)
|
|
715
|
+
data = yaml.load(content, YAML_OPTIONS)
|
|
757
716
|
}
|
|
758
717
|
}
|
|
759
718
|
} else if (url) {
|
|
@@ -792,64 +751,29 @@ export async function executeFetch(config, options = {}) {
|
|
|
792
751
|
}
|
|
793
752
|
|
|
794
753
|
/**
|
|
795
|
-
*
|
|
754
|
+
* Put fetched data into a content object, under the key its fetch names.
|
|
755
|
+
*
|
|
756
|
+
* It REPLACES what the key held. ⛔ A fourth `merge` argument concatenated arrays and
|
|
757
|
+
* spread objects into what was there until 2026-09-14, for the retired `merge:` fetch
|
|
758
|
+
* key (`refuseMerge`); it is no longer read.
|
|
796
759
|
*
|
|
797
760
|
* @param {object} content - Existing content object with data property
|
|
798
761
|
* @param {any} fetchedData - Data from fetch
|
|
799
|
-
* @param {string} schema -
|
|
800
|
-
* @
|
|
801
|
-
* @returns {object} Updated content object
|
|
762
|
+
* @param {string} schema - the key to store under — the fetch's `as`
|
|
763
|
+
* @returns {object} Updated content object (a copy; the input is not mutated)
|
|
802
764
|
*
|
|
803
765
|
* @example
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
807
|
-
* // Replace (default)
|
|
808
|
-
* mergeDataIntoContent(content, fetched, 'team', false)
|
|
809
|
-
* // content.data.team = [{ name: 'Remote' }]
|
|
810
|
-
*
|
|
811
|
-
* // Merge
|
|
812
|
-
* mergeDataIntoContent(content, fetched, 'team', true)
|
|
813
|
-
* // content.data.team = [{ name: 'Local' }, { name: 'Remote' }]
|
|
766
|
+
* mergeDataIntoContent({ data: { team: [{ name: 'Local' }] } }, [{ name: 'Remote' }], 'team')
|
|
767
|
+
* // → { data: { team: [{ name: 'Remote' }] } }
|
|
814
768
|
*/
|
|
815
|
-
export function mergeDataIntoContent(content, fetchedData, schema
|
|
769
|
+
export function mergeDataIntoContent(content, fetchedData, schema) {
|
|
816
770
|
if (fetchedData === null || fetchedData === undefined || !schema) {
|
|
817
771
|
return content
|
|
818
772
|
}
|
|
819
|
-
|
|
820
|
-
// Create a new content object with updated data
|
|
821
|
-
const result = {
|
|
773
|
+
return {
|
|
822
774
|
...content,
|
|
823
|
-
data: { ...(content.data || {}) },
|
|
775
|
+
data: { ...(content.data || {}), [schema]: fetchedData },
|
|
824
776
|
}
|
|
825
|
-
|
|
826
|
-
if (merge && result.data[schema] !== undefined) {
|
|
827
|
-
// Merge mode: combine with existing data
|
|
828
|
-
const existing = result.data[schema]
|
|
829
|
-
|
|
830
|
-
if (Array.isArray(existing) && Array.isArray(fetchedData)) {
|
|
831
|
-
// Arrays: concatenate
|
|
832
|
-
result.data[schema] = [...existing, ...fetchedData]
|
|
833
|
-
} else if (
|
|
834
|
-
typeof existing === 'object' &&
|
|
835
|
-
existing !== null &&
|
|
836
|
-
typeof fetchedData === 'object' &&
|
|
837
|
-
fetchedData !== null &&
|
|
838
|
-
!Array.isArray(existing) &&
|
|
839
|
-
!Array.isArray(fetchedData)
|
|
840
|
-
) {
|
|
841
|
-
// Objects: shallow merge
|
|
842
|
-
result.data[schema] = { ...existing, ...fetchedData }
|
|
843
|
-
} else {
|
|
844
|
-
// Different types: fetched data wins
|
|
845
|
-
result.data[schema] = fetchedData
|
|
846
|
-
}
|
|
847
|
-
} else {
|
|
848
|
-
// Replace mode (default): fetched data overwrites
|
|
849
|
-
result.data[schema] = fetchedData
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
return result
|
|
853
777
|
}
|
|
854
778
|
|
|
855
779
|
/**
|
|
@@ -25,6 +25,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
25
25
|
import { existsSync } from 'node:fs'
|
|
26
26
|
import { join } from 'node:path'
|
|
27
27
|
import yaml from 'js-yaml'
|
|
28
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
28
29
|
|
|
29
30
|
const DEFAULT_TARGET_NAME = 'production'
|
|
30
31
|
const VALID_AUTOSAVE = new Set(['off', 'lastDeploy', 'full'])
|
|
@@ -40,7 +41,7 @@ export async function loadDeployYml(siteDir) {
|
|
|
40
41
|
const path = join(siteDir, 'deploy.yml')
|
|
41
42
|
if (!existsSync(path)) return null
|
|
42
43
|
const text = await readFile(path, 'utf8')
|
|
43
|
-
const doc = yaml.load(text) ?? {}
|
|
44
|
+
const doc = yaml.load(text, YAML_OPTIONS) ?? {}
|
|
44
45
|
return validate(doc, path)
|
|
45
46
|
}
|
|
46
47
|
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
// declaration: detailPage, filter, limit, merge, prerender, query, schema,
|
|
28
28
|
// sort, transform, where.
|
|
29
29
|
//
|
|
30
|
+
// (That list is the one recognized on that date. `filter`, `schema`, `transform`
|
|
31
|
+
// and `merge` have been retired since.)
|
|
32
|
+
//
|
|
30
33
|
// ⭐ The round trip has to invert the resolution, not copy it. `/data/<name>.json`
|
|
31
34
|
// is a materialization of a query, never its definition — so it is precisely the
|
|
32
35
|
// thing an authored file should not contain.
|
|
@@ -36,6 +39,11 @@
|
|
|
36
39
|
// would make the round trip lossy in a way nothing reports. `path` and `url` beside
|
|
37
40
|
// a `query` are recoverable from the query itself; anything else survives and the
|
|
38
41
|
// validator's warning stays the honest signal.
|
|
42
|
+
//
|
|
43
|
+
// ⛔ THE ONE EXCEPTION IS A RETIRED KEY THE BUILD REFUSES. `merge` (retired
|
|
44
|
+
// 2026-09-14) rode every section's fetch on the wire as `merge: false` — the parse
|
|
45
|
+
// emitted it as a default — so a site synced before then holds it in store. Written
|
|
46
|
+
// back, it would stop the next build; so it is dropped, whatever its value.
|
|
39
47
|
|
|
40
48
|
/** Which shape a declaration is — the same order `data-fetcher` uses. */
|
|
41
49
|
export function fetchShapeOf(fetch) {
|
|
@@ -50,20 +58,23 @@ const DERIVED_BY_SHAPE = {
|
|
|
50
58
|
source: []
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
/** Retired keys a store may still hold, which the build refuses — dropped from every shape. */
|
|
62
|
+
const RETIRED_KEYS = ['merge']
|
|
63
|
+
|
|
53
64
|
/**
|
|
54
65
|
* The declaration as an author would have written it — the wire's resolved form
|
|
55
|
-
* minus what the build derived.
|
|
66
|
+
* minus what the build derived, and minus a retired key the build would refuse.
|
|
56
67
|
*
|
|
57
68
|
* @param {object} fetch a `fetch:` declaration off the sync wire
|
|
58
69
|
* @returns {object} the same declaration, safe to write into authored config
|
|
59
70
|
*/
|
|
60
71
|
export function authorableFetch(fetch) {
|
|
61
72
|
const shape = fetchShapeOf(fetch)
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
if (!shape) return fetch
|
|
74
|
+
const drop = [...DERIVED_BY_SHAPE[shape], ...RETIRED_KEYS]
|
|
64
75
|
const out = {}
|
|
65
76
|
for (const [k, v] of Object.entries(fetch)) {
|
|
66
|
-
if (
|
|
77
|
+
if (drop.includes(k)) continue
|
|
67
78
|
out[k] = v
|
|
68
79
|
}
|
|
69
80
|
return out
|
|
@@ -86,8 +97,9 @@ export const DECLARATION_KEYS = Object.freeze(['query', 'fetch', 'data'])
|
|
|
86
97
|
/**
|
|
87
98
|
* The query names a declaration consists of, when that is ALL it says — what
|
|
88
99
|
* `query:` can express. Keys the build derives or defaults beside a query (`path`,
|
|
89
|
-
* `url`, `as` equal to the name, `prerender: true
|
|
90
|
-
*
|
|
100
|
+
* `url`, `as` equal to the name, `prerender: true`) say nothing an author wrote, and
|
|
101
|
+
* a retired `merge` of any value is not written back at all; any other key makes it a
|
|
102
|
+
* `fetch:`.
|
|
91
103
|
*
|
|
92
104
|
* @param {object|object[]} fetch - a declaration (or a list) off the wire
|
|
93
105
|
* @returns {string|string[]|null} the name(s), or null when `query:` cannot say it
|
|
@@ -101,7 +113,7 @@ export function queryNamesOf(fetch) {
|
|
|
101
113
|
if (key === 'query' || key === 'path' || key === 'url') continue
|
|
102
114
|
if (key === 'as' && value === one.query) continue
|
|
103
115
|
if (key === 'prerender' && value === true) continue
|
|
104
|
-
if (key
|
|
116
|
+
if (RETIRED_KEYS.includes(key)) continue
|
|
105
117
|
return null
|
|
106
118
|
}
|
|
107
119
|
return one.query
|
package/src/site/plugin.js
CHANGED
|
@@ -125,6 +125,39 @@ export function shouldPrefetchInDev(cfg) {
|
|
|
125
125
|
return true // remote build-time fetch — keep embedding in dev
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* What a change to a file at the site root asks the dev server to redo — or null
|
|
130
|
+
* for a file it does not read.
|
|
131
|
+
*
|
|
132
|
+
* - `'queries'` — re-collect the site, re-materialize every query file from the
|
|
133
|
+
* query list just collected, reload. `queries.yml` declares queries, and so does
|
|
134
|
+
* `site.yml` (`queries:`), beside the rest of the site's configuration.
|
|
135
|
+
* - `'records'` — re-materialize the query files, reload: `records.yml` decides
|
|
136
|
+
* which entities are records, as an entity change decides what one says.
|
|
137
|
+
* - `'content'` — re-collect the site, reload.
|
|
138
|
+
*
|
|
139
|
+
* ⛔ Until 2026-09-14 nothing watched `records.yml` or `queries.yml`, a `site.yml`
|
|
140
|
+
* edit re-collected the pages alone, and the query list was the one read at startup
|
|
141
|
+
* — so an edit to what DEFINES a query file changed nothing until a restart.
|
|
142
|
+
*
|
|
143
|
+
* @param {string|null} filename - the changed entry's name, as `fs.watch` reports it
|
|
144
|
+
* @returns {'queries'|'records'|'content'|null}
|
|
145
|
+
*/
|
|
146
|
+
export function siteRootChange(filename) {
|
|
147
|
+
switch (filename) {
|
|
148
|
+
case 'site.yml':
|
|
149
|
+
case 'queries.yml':
|
|
150
|
+
return 'queries'
|
|
151
|
+
case 'records.yml':
|
|
152
|
+
return 'records'
|
|
153
|
+
case 'theme.yml':
|
|
154
|
+
case 'head.html':
|
|
155
|
+
return 'content'
|
|
156
|
+
default:
|
|
157
|
+
return null
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
128
161
|
/**
|
|
129
162
|
* Execute the dev fetches that should be embedded (see shouldPrefetchInDev) and
|
|
130
163
|
* collect them as `fetchedData` for DataStore pre-population. Local file-based
|
|
@@ -184,7 +217,7 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
184
217
|
|
|
185
218
|
/**
|
|
186
219
|
* Process fetches for sections recursively
|
|
187
|
-
* Section-level fetches
|
|
220
|
+
* Section-level fetches fill parsedContent.data, each under its own key (not cascaded).
|
|
188
221
|
*
|
|
189
222
|
* @param {Array} sections - Sections to process
|
|
190
223
|
* @param {Object} fetchOptions - Options for executeFetch
|
|
@@ -203,8 +236,7 @@ async function processDevSectionFetches(sections, fetchOptions, resolve) {
|
|
|
203
236
|
section.parsedContent = mergeDataIntoContent(
|
|
204
237
|
section.parsedContent || {},
|
|
205
238
|
result.data,
|
|
206
|
-
sectionFetch.as
|
|
207
|
-
sectionFetch.merge
|
|
239
|
+
sectionFetch.as
|
|
208
240
|
)
|
|
209
241
|
}
|
|
210
242
|
}
|
|
@@ -642,6 +674,22 @@ export function siteContentPlugin(options = {}) {
|
|
|
642
674
|
}
|
|
643
675
|
}
|
|
644
676
|
|
|
677
|
+
/**
|
|
678
|
+
* Materialize every query the site declares into `public/data/` — at dev startup,
|
|
679
|
+
* and again whenever what a query file is made from changes (`siteRootChange`, and
|
|
680
|
+
* an entity change).
|
|
681
|
+
*
|
|
682
|
+
* @param {Object|undefined} queries - the collected `config.queries`
|
|
683
|
+
* @param {Object|undefined} config - the collected `config`, for the default locale a `sort` collates in
|
|
684
|
+
*/
|
|
685
|
+
async function materializeQueries(queries, config) {
|
|
686
|
+
if (!queries) return
|
|
687
|
+
const byQuery = await processQueries(resolvedSitePath, queries, resolvedEntitiesDir, basePath, {
|
|
688
|
+
locale: resolveDefaultLocale(config ?? {}) ?? null,
|
|
689
|
+
})
|
|
690
|
+
await writeQueryFiles(resolvedSitePath, byQuery, queries)
|
|
691
|
+
}
|
|
692
|
+
|
|
645
693
|
/**
|
|
646
694
|
* Read head.html from site root (if it exists)
|
|
647
695
|
*/
|
|
@@ -827,10 +875,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
827
875
|
|
|
828
876
|
if (queriesConfig) {
|
|
829
877
|
console.log('[site-content] Materializing queries...')
|
|
830
|
-
|
|
831
|
-
locale: resolveDefaultLocale(earlyContent.config) ?? null,
|
|
832
|
-
})
|
|
833
|
-
await writeQueryFiles(resolvedSitePath, byQuery, queriesConfig)
|
|
878
|
+
await materializeQueries(queriesConfig, earlyContent.config)
|
|
834
879
|
}
|
|
835
880
|
} catch (err) {
|
|
836
881
|
console.warn('[site-content] Early query materialization failed:', err.message)
|
|
@@ -914,18 +959,26 @@ export function siteContentPlugin(options = {}) {
|
|
|
914
959
|
|
|
915
960
|
// Watch for content changes in dev mode
|
|
916
961
|
if (shouldWatch) {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
//
|
|
962
|
+
// Debounce rebuilds. One that `queries.yml` or `site.yml` asked for also
|
|
963
|
+
// re-materializes the query files, from the query list the rebuild has just
|
|
964
|
+
// collected — and a content change debounced after it, in the same window,
|
|
965
|
+
// does not cancel that.
|
|
921
966
|
let rebuildTimeout = null
|
|
922
|
-
|
|
967
|
+
let rebuildQueries = false
|
|
968
|
+
const scheduleRebuild = (withQueries = false) => {
|
|
969
|
+
rebuildQueries = rebuildQueries || withQueries
|
|
923
970
|
if (rebuildTimeout) clearTimeout(rebuildTimeout)
|
|
924
971
|
rebuildTimeout = setTimeout(async () => {
|
|
972
|
+
const materialize = rebuildQueries
|
|
973
|
+
rebuildQueries = false
|
|
925
974
|
console.log('[site-content] Content changed, rebuilding...')
|
|
926
975
|
try {
|
|
927
976
|
siteContent = await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })
|
|
928
977
|
headHtml = await loadHeadHtml()
|
|
978
|
+
if (materialize) {
|
|
979
|
+
queriesConfig = siteContent.config?.queries
|
|
980
|
+
await materializeQueries(queriesConfig, siteContent.config)
|
|
981
|
+
}
|
|
929
982
|
// Execute fetches for the updated content
|
|
930
983
|
await executeDevFetches(siteContent, resolvedSitePath)
|
|
931
984
|
console.log(`[site-content] Rebuilt ${siteContent.pages?.length || 0} pages`)
|
|
@@ -945,12 +998,9 @@ export function siteContentPlugin(options = {}) {
|
|
|
945
998
|
recordRebuildTimeout = setTimeout(async () => {
|
|
946
999
|
console.log('[site-content] Records changed, regenerating JSON...')
|
|
947
1000
|
try {
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
const processed = await processQueries(resolvedSitePath, byQuery, resolvedEntitiesDir, basePath)
|
|
952
|
-
await writeQueryFiles(resolvedSitePath, processed, byQuery)
|
|
953
|
-
}
|
|
1001
|
+
// The query list the last collect read — at startup, or by a rebuild
|
|
1002
|
+
// that `queries.yml` or `site.yml` asked for
|
|
1003
|
+
await materializeQueries(queriesConfig || siteContent?.config?.queries, siteContent?.config)
|
|
954
1004
|
// Send full reload to client
|
|
955
1005
|
server.ws.send({ type: 'full-reload' })
|
|
956
1006
|
} catch (err) {
|
|
@@ -959,13 +1009,16 @@ export function siteContentPlugin(options = {}) {
|
|
|
959
1009
|
}, 100)
|
|
960
1010
|
}
|
|
961
1011
|
|
|
1012
|
+
// A watcher passes `(eventType, filename)`; a content change never asks for the queries.
|
|
1013
|
+
const onContentChange = () => scheduleRebuild(false)
|
|
1014
|
+
|
|
962
1015
|
// Track all watchers for cleanup
|
|
963
1016
|
const watchers = []
|
|
964
1017
|
|
|
965
1018
|
// Watch pages directory (resolved from site.yml pagesDir or default)
|
|
966
1019
|
if (existsSync(resolvedPagesPath)) {
|
|
967
1020
|
try {
|
|
968
|
-
watchers.push(watch(resolvedPagesPath, { recursive: true },
|
|
1021
|
+
watchers.push(watch(resolvedPagesPath, { recursive: true }, onContentChange))
|
|
969
1022
|
console.log(`[site-content] Watching ${resolvedPagesPath}`)
|
|
970
1023
|
} catch (err) {
|
|
971
1024
|
console.warn('[site-content] Could not watch pages directory:', err.message)
|
|
@@ -982,7 +1035,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
982
1035
|
for (const mountPath of resolvedMountPaths) {
|
|
983
1036
|
if (!existsSync(mountPath)) continue
|
|
984
1037
|
try {
|
|
985
|
-
watchers.push(watch(mountPath, { recursive: true },
|
|
1038
|
+
watchers.push(watch(mountPath, { recursive: true }, onContentChange))
|
|
986
1039
|
console.log(`[site-content] Watching ${mountPath} (mounted)`)
|
|
987
1040
|
} catch (err) {
|
|
988
1041
|
console.warn(`[site-content] Could not watch mounted directory ${mountPath}:`, err.message)
|
|
@@ -992,33 +1045,28 @@ export function siteContentPlugin(options = {}) {
|
|
|
992
1045
|
// Watch layout directory (resolved from site.yml layoutDir or default)
|
|
993
1046
|
if (existsSync(resolvedLayoutPath)) {
|
|
994
1047
|
try {
|
|
995
|
-
watchers.push(watch(resolvedLayoutPath, { recursive: true },
|
|
1048
|
+
watchers.push(watch(resolvedLayoutPath, { recursive: true }, onContentChange))
|
|
996
1049
|
console.log(`[site-content] Watching ${resolvedLayoutPath}`)
|
|
997
1050
|
} catch (err) {
|
|
998
1051
|
console.warn('[site-content] Could not watch layout directory:', err.message)
|
|
999
1052
|
}
|
|
1000
1053
|
}
|
|
1001
1054
|
|
|
1002
|
-
// Watch site.yml
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
// Watch theme.yml
|
|
1055
|
+
// Watch the site root — `site.yml`, `queries.yml`, `records.yml`, `theme.yml`,
|
|
1056
|
+
// `head.html` — as ONE directory rather than a watch per file: a file watch
|
|
1057
|
+
// cannot be set on a file created after the server started (a first
|
|
1058
|
+
// `records.yml`), and an editor that saves by replacing a file ends its watch.
|
|
1059
|
+
// Not recursive, so what the build writes below the root (`public/data/`) is
|
|
1060
|
+
// not seen here. What each change redoes is `siteRootChange`.
|
|
1010
1061
|
try {
|
|
1011
|
-
watchers.push(watch(
|
|
1062
|
+
watchers.push(watch(resolvedSitePath, (eventType, filename) => {
|
|
1063
|
+
const change = siteRootChange(filename)
|
|
1064
|
+
if (change === 'queries') scheduleRebuild(true)
|
|
1065
|
+
else if (change === 'records') scheduleRecordRebuild()
|
|
1066
|
+
else if (change === 'content') scheduleRebuild(false)
|
|
1067
|
+
}))
|
|
1012
1068
|
} catch (err) {
|
|
1013
|
-
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
// Watch head.html
|
|
1017
|
-
const headHtmlPath = resolve(resolvedSitePath, 'head.html')
|
|
1018
|
-
try {
|
|
1019
|
-
watchers.push(watch(headHtmlPath, scheduleRebuild))
|
|
1020
|
-
} catch {
|
|
1021
|
-
// head.html may not exist, that's ok
|
|
1069
|
+
console.warn('[site-content] Could not watch the site root:', err.message)
|
|
1022
1070
|
}
|
|
1023
1071
|
|
|
1024
1072
|
// ⭐ WATCH THE POOL, NOT A DIRECTORY PER QUERY. This used to resolve
|
|
@@ -1235,18 +1283,26 @@ export function siteContentPlugin(options = {}) {
|
|
|
1235
1283
|
}
|
|
1236
1284
|
}
|
|
1237
1285
|
|
|
1238
|
-
// Handle localized collection data (e.g., /fr/data/articles.json)
|
|
1286
|
+
// Handle localized collection data (e.g., /fr/data/articles.json), and a
|
|
1287
|
+
// `deferred:` query's per-record files (/fr/data/articles/<slug>.json) — one
|
|
1288
|
+
// record each, translated the same way.
|
|
1239
1289
|
const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/${DATA_DIR}\\/(.+\\.json)$`))
|
|
1240
1290
|
if (localeDataMatch) {
|
|
1241
1291
|
const locale = localeDataMatch[1]
|
|
1242
1292
|
const filename = localeDataMatch[2]
|
|
1243
|
-
const queryName = filename.replace('.json', '')
|
|
1244
1293
|
const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
|
|
1245
1294
|
|
|
1246
1295
|
if (existsSync(sourcePath)) {
|
|
1247
1296
|
try {
|
|
1248
1297
|
const raw = await readFile(sourcePath, 'utf-8')
|
|
1249
1298
|
const items = JSON.parse(raw)
|
|
1299
|
+
const record = filename.includes('/') && items && typeof items === 'object' && !Array.isArray(items)
|
|
1300
|
+
if (!Array.isArray(items) && !record) {
|
|
1301
|
+
res.setHeader('Content-Type', 'application/json')
|
|
1302
|
+
res.end(raw)
|
|
1303
|
+
return
|
|
1304
|
+
}
|
|
1305
|
+
const queryName = record ? filename.slice(0, filename.lastIndexOf('/')) : filename.replace(/\.json$/, '')
|
|
1250
1306
|
|
|
1251
1307
|
// Load collection translations for this locale
|
|
1252
1308
|
const translations = await loadRecordTranslations(locale) || {}
|