@uniweb/build 0.51.0 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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', merge: false }
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', merge: false }
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', 'merge',
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
- * Keys a fetch declaration carries for THE BUILD ONLY, which no runtime reads.
610
- *
611
- * `merge` decides how a section-level fetch lands in `parsedContent.data` when
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
- * Merge fetched data into existing content
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 - Schema key to store under
800
- * @param {boolean} [merge=false] - If true, merge with existing data; if false, replace
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
- * const content = { data: { team: [{ name: 'Local' }] } }
805
- * const fetched = [{ name: 'Remote' }]
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, merge = false) {
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
 
@@ -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
- const derived = DERIVED_BY_SHAPE[shape]
63
- if (!derived || derived.length === 0) return fetch
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 (derived.includes(k)) continue
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`, `merge: false`) say nothing an
90
- * author wrote; any other key makes it a `fetch:`.
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 === 'merge' && value === false) continue
116
+ if (RETIRED_KEYS.includes(key)) continue
105
117
  return null
106
118
  }
107
119
  return one.query
@@ -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 merge data into parsedContent.data (not cascaded).
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
- const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath, {
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
- const siteYmlPath = resolve(resolvedSitePath, 'site.yml')
918
- const themeYmlPath = resolve(resolvedSitePath, 'theme.yml')
919
-
920
- // Debounce rebuilds
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
- const scheduleRebuild = () => {
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
- // Use queriesConfig (cached from configResolved) or siteContent
949
- const byQuery = queriesConfig || siteContent?.config?.queries
950
- if (byQuery) {
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 }, scheduleRebuild))
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 }, scheduleRebuild))
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 }, scheduleRebuild))
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
- try {
1004
- watchers.push(watch(siteYmlPath, scheduleRebuild))
1005
- } catch (err) {
1006
- // site.yml may not exist, that's ok
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(themeYmlPath, scheduleRebuild))
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
- // theme.yml may not exist, that's ok
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) || {}