@uniweb/build 0.29.1 → 0.30.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.
Files changed (41) hide show
  1. package/package.json +5 -5
  2. package/src/content/index.js +6 -6
  3. package/src/dev-backend.js +31 -31
  4. package/src/i18n/freeform.js +44 -24
  5. package/src/i18n/index.js +22 -22
  6. package/src/i18n/{collections.js → records.js} +114 -51
  7. package/src/i18n/sync.js +9 -8
  8. package/src/site/build-site-data.js +9 -12
  9. package/src/site/config.js +1 -1
  10. package/src/site/content-collector.js +35 -40
  11. package/src/site/data-fetcher.js +23 -10
  12. package/src/site/entity-pool.js +211 -0
  13. package/src/site/fetch-shapes.js +13 -12
  14. package/src/site/foundation-ref.js +1 -1
  15. package/src/site/index.js +4 -4
  16. package/src/site/plugin.js +58 -63
  17. package/src/site/queries-config.js +324 -0
  18. package/src/site/{collection-processor.js → query-processor.js} +180 -95
  19. package/src/site/records-config.js +299 -0
  20. package/src/site/schemaless-data.js +2 -2
  21. package/src/utils/numeric-prefix.js +63 -0
  22. package/src/uwx/backfill.js +5 -5
  23. package/src/uwx/data-schema.js +2 -2
  24. package/src/uwx/entity-source.js +122 -0
  25. package/src/uwx/folder.js +92 -77
  26. package/src/uwx/index.js +33 -13
  27. package/src/uwx/locale-sync.js +2 -2
  28. package/src/uwx/project-writer.js +36 -10
  29. package/src/uwx/queries-config.js +11 -0
  30. package/src/uwx/records-project.js +535 -0
  31. package/src/uwx/{collections.js → records.js} +152 -69
  32. package/src/uwx/site-diff.js +6 -6
  33. package/src/uwx/site-project.js +30 -5
  34. package/src/uwx/site.js +295 -27
  35. package/src/uwx/sync-package.js +32 -18
  36. package/src/validate-data.js +17 -19
  37. package/src/site/collections-config.js +0 -260
  38. package/src/uwx/collection-source.js +0 -180
  39. package/src/uwx/collections-config.js +0 -9
  40. package/src/uwx/collections-project.js +0 -335
  41. /package/src/search/{collections.js → records-index.js} +0 -0
@@ -7,7 +7,7 @@
7
7
  * Supports:
8
8
  * - Simple string paths: "/data/team.json"
9
9
  * - Full config objects with schema, prerender, merge, transform options
10
- * - Collection references: { collection: 'articles', limit: 3 }
10
+ * - Named-query references: { query: 'articles', limit: 3 }
11
11
  * - Local JSON/YAML files
12
12
  * - Remote URLs
13
13
  * - Transform paths to extract nested data
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
20
20
  import { join } from 'node:path'
21
21
  import { existsSync } from 'node:fs'
22
22
  import yaml from 'js-yaml'
23
- import { matchWhere, collectionDataUrl } from '@uniweb/core'
23
+ import { matchWhere, queryDataUrl } from '@uniweb/core'
24
24
 
25
25
  /**
26
26
  * Infer schema name from path or URL
@@ -236,7 +236,7 @@ export function applyPostProcessing(data, config) {
236
236
  * // Returns: { path: '/team', schema: 'person', prerender: false, merge: false }
237
237
  *
238
238
  * // Collection reference
239
- * parseFetchConfig({ collection: 'articles', limit: 3, sort: 'date desc' })
239
+ * parseFetchConfig({ query: 'articles', limit: 3, sort: 'date desc' })
240
240
  * // Returns: { path: '/data/articles.json', schema: 'articles', limit: 3, sort: 'date desc', ... }
241
241
  */
242
242
  // ─── Unrecognized-key reporting ───────────────────────────────────────
@@ -252,8 +252,8 @@ export function applyPostProcessing(data, config) {
252
252
  // once per key name per process so a 200-record build does not print 200 lines.
253
253
  const RECOGNIZED_FETCH_KEYS = {
254
254
  refine: new Set(['refine', 'inherit', 'detail', 'limit', 'sort', 'where', 'filter']),
255
- collection: new Set([
256
- 'collection', 'schema', 'prerender', 'merge', 'transform',
255
+ query: new Set([
256
+ 'query', 'schema', 'prerender', 'merge', 'transform',
257
257
  'where', 'limit', 'sort', 'detailPage', 'filter',
258
258
  ]),
259
259
  source: new Set([
@@ -331,14 +331,27 @@ export function parseFetchConfig(fetch) {
331
331
  }
332
332
  }
333
333
 
334
- // Collection reference: { collection: 'articles', limit: 3 }
335
- if (fetch.collection) {
336
- warnUnknownFetchKeys(fetch, 'collection')
334
+ // THE RETIRED SPELLING IS AN ERROR, NOT A WARNING. An unrecognized key is
335
+ // warned about and IGNORED, so `fetch: { collection: X }` would fall through to
336
+ // the source shape, find neither `path` nor `url`, and resolve to null — a
337
+ // SILENTLY EMPTY result, which is worse than the old name simply working. The
338
+ // author sees a page render with no data and nothing saying why.
339
+ if (fetch.collection !== undefined) {
340
+ throw new Error(
341
+ `[uniweb] fetch: \`collection: ${JSON.stringify(fetch.collection)}\` is retired. ` +
342
+ `Write \`query: ${JSON.stringify(fetch.collection)}\` and declare it in queries.yml. ` +
343
+ `A query names a schema and the folder supplies its records.`
344
+ )
345
+ }
346
+
347
+ // Named-query reference: { query: 'articles', limit: 3 }
348
+ if (fetch.query) {
349
+ warnUnknownFetchKeys(fetch, 'query')
337
350
  if (fetch.filter !== undefined) warnFilterDeprecated()
338
351
  return {
339
- path: collectionDataUrl(fetch.collection),
352
+ path: queryDataUrl(fetch.query),
340
353
  url: undefined,
341
- schema: fetch.schema || fetch.collection,
354
+ schema: fetch.schema || fetch.query,
342
355
  prerender: fetch.prerender ?? true,
343
356
  merge: fetch.merge ?? false,
344
357
  transform: fetch.transform,
@@ -0,0 +1,211 @@
1
+ // A site's ENTITY POOL — every stored thing on disk, and the model each one has.
2
+ //
3
+ // ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. That is the whole point of
4
+ // this directory, and it is the de-conflation the records model is built on:
5
+ // `collections/<name>/` used to mean three things at once — these files are
6
+ // entities, their schema is `@/<name>`, and they are grouped as `<name>` for
7
+ // placement. `entities/{schema}/` declares only the first two. Grouping moved to
8
+ // `records.yml`, which is what makes an entity a RECORD.
9
+ //
10
+ // ⇒ So nothing here reads a query, a folder, or any config. A pool is a fact
11
+ // about the filesystem.
12
+ //
13
+ // ⭐ DEPTH NAMES THE SCOPE — the schema-ref grammar, spelled as directories:
14
+ //
15
+ // entities/person/ada.md → @/person (the foundation's own)
16
+ // entities/std/person/ada.md → @std/person (the shared standard set)
17
+ // entities/acme/project/x.md → @acme/project (an org's)
18
+ //
19
+ // matching `build/src/resolve-data-schema.js`, which is the only grammar there
20
+ // is: a bare directory name can mean `@/<name>` and nothing else.
21
+ //
22
+ // ⛔ BARE, NOT `entities/@std/`. Measured: `@` is a reserved indicator in YAML
23
+ // 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
24
+ // `bad indentation of a sequence entry`, which names neither the cause nor the
25
+ // fix. The `@` form would force quotes on the most common line in `records.yml`
26
+ // and put two spellings in one list.
27
+ //
28
+ // ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
29
+ // total: it is the FILE's depth that decides, so one path answers the question
30
+ // with nothing to classify and no ambiguous case to resolve.
31
+ //
32
+ // ⭐ That costs nothing — it CLOSES a divergence that was live and silent.
33
+ // `uwx/collection-source.js::reportNestedRecords` warns today that records below
34
+ // a collection's top level "build and render locally but are absent from the
35
+ // synced set": the delivery lane recursed and the sync lane was one level deep.
36
+ // `records.yml` replaces on-disk nesting outright, so the two lanes stop
37
+ // disagreeing rather than being taught to agree.
38
+
39
+ import { readdir } from 'node:fs/promises'
40
+ import { existsSync } from 'node:fs'
41
+ import { join, extname, basename } from 'node:path'
42
+
43
+ /** Where a site's entities live, relative to its root. */
44
+ export const ENTITIES_DIR = 'entities'
45
+
46
+ /** Source extensions an entity file may have. Mirrors the sync-lane reader. */
47
+ export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
48
+
49
+ const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
50
+
51
+ /**
52
+ * The schema ref a pool path names.
53
+ *
54
+ * @param {string[]} dirs - the directory segments below `entities/`
55
+ * @returns {string|null} the ref, or null when the depth names no schema
56
+ */
57
+ export function schemaForPoolDirs(dirs) {
58
+ if (dirs.length === 1) return `@/${dirs[0]}`
59
+ if (dirs.length === 2) return `@${dirs[0]}/${dirs[1]}`
60
+ return null
61
+ }
62
+
63
+ /**
64
+ * Where a schema's entities live — the inverse of `schemaForPoolDirs`.
65
+ *
66
+ * ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
67
+ * exists at all: the reader derives a model from a path and the pull side derives
68
+ * a path from a model, and if those two ever disagree a pulled record lands
69
+ * somewhere the next build reads as a different schema. Same rule as
70
+ * `deferredFromSchema` — a deriver and its recognizer must not be two copies.
71
+ *
72
+ * @param {string} schema - a ref: `@/name` or `@org/name`
73
+ * @returns {string[]|null} the directory segments below `entities/`, or null for
74
+ * a ref this layout cannot express
75
+ */
76
+ export function poolDirsForSchema(schema) {
77
+ if (typeof schema !== 'string') return null
78
+ const self = /^@\/([^/]+)$/.exec(schema)
79
+ if (self) return [self[1]]
80
+ const scoped = /^@([^/]+)\/([^/]+)$/.exec(schema)
81
+ if (scoped) return [scoped[1], scoped[2]]
82
+ return null
83
+ }
84
+
85
+ /**
86
+ * Both readings of a 2-segment pool path, for an error that has to name them.
87
+ *
88
+ * ⚠️ A reader who mistakes `entities/person/2024/ada.md` for "the `person`
89
+ * schema, organised by year" needs to be told what the build actually did with
90
+ * it, not merely that something did not resolve. The wrong reading is the
91
+ * plausible one, so the message carries both.
92
+ */
93
+ export function poolPathReadings(dirs) {
94
+ return {
95
+ read: schemaForPoolDirs(dirs),
96
+ alternative: dirs.length === 2 ? `@/${dirs[0]}` : null,
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Read a site's entity pool.
102
+ *
103
+ * Returns entities in a stable, path-sorted order — the wire's package digest
104
+ * depends on it — each carrying the model its path declares.
105
+ *
106
+ * ⚠️ NOTHING HERE RESOLVES A SCHEMA. Whether `@std/person` is a schema this site
107
+ * can actually see is a question for whoever holds the foundation's built schema
108
+ * map, and only that caller can raise the error §4 of the model asks for. This
109
+ * reports the pool's SHAPE — a file with no schema above it, a file nested too
110
+ * deep — because those are answerable from the filesystem alone.
111
+ *
112
+ * @param {string} siteRoot
113
+ * @param {object} [opts]
114
+ * @param {string} [opts.dir] - override the pool directory (site-root-relative)
115
+ * @returns {Promise<{
116
+ * entities: Array<{ id, schema, slug, dirs, relPath, absPath, ext }>,
117
+ * errors: string[],
118
+ * exists: boolean,
119
+ * }>}
120
+ * `id` is the entity's path under `entities/` without its extension — unique
121
+ * by construction, stable across pushes, and derivable identically on both
122
+ * sides without either lane holding the other's ids.
123
+ */
124
+ export async function readEntityPool(siteRoot, opts = {}) {
125
+ const rel = opts.dir || ENTITIES_DIR
126
+ const base = join(siteRoot, rel)
127
+ if (!existsSync(base)) return { entities: [], errors: [], exists: false }
128
+
129
+ const entities = []
130
+ const errors = []
131
+
132
+ const walk = async (dir, dirs) => {
133
+ let listing
134
+ try {
135
+ listing = await readdir(dir, { withFileTypes: true })
136
+ } catch {
137
+ return
138
+ }
139
+ for (const e of listing.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
140
+ if (isHidden(e.name)) continue
141
+ const full = join(dir, e.name)
142
+ if (e.isDirectory()) {
143
+ if (dirs.length >= 2) {
144
+ // Deeper than a schema dir. Say what the path was read AS, because the
145
+ // author's intent (organising records inside a schema) is the reading
146
+ // this layout deliberately does not have.
147
+ const { read } = poolPathReadings(dirs)
148
+ errors.push(
149
+ `${rel}/${[...dirs, e.name].join('/')}/ is nested below a schema folder. ` +
150
+ `\`${rel}/\` declares a model and nothing else — \`${[...dirs].join('/')}\` ` +
151
+ `already names ${read}, so there is no meaning left for a folder inside it. ` +
152
+ `Organise records in records.yml (a \`folder:\` entry), not on disk.`
153
+ )
154
+ continue
155
+ }
156
+ await walk(full, [...dirs, e.name])
157
+ continue
158
+ }
159
+ if (!e.isFile()) continue
160
+ const ext = extname(e.name).toLowerCase()
161
+ if (!ENTITY_EXTENSIONS.has(ext)) continue
162
+ if (dirs.length === 0) {
163
+ errors.push(
164
+ `${rel}/${e.name} sits directly in \`${rel}/\`, which names no model. ` +
165
+ `Move it under a schema folder — \`${rel}/<name>/\` for \`@/<name>\`, ` +
166
+ `or \`${rel}/<org>/<name>/\` for \`@<org>/<name>\`.`
167
+ )
168
+ continue
169
+ }
170
+ // ⛔ THE SLUG IS THE FILENAME STEM, WHOLE — nothing is stripped from it.
171
+ // A leading number orders a set (`01-`, `02-`) at least as often as it is a
172
+ // DATE (`2026-03-…`), and the two are indistinguishable by shape, so
173
+ // consuming one into the record's name mangles the other. A number is read
174
+ // to SORT by (`compareByNumericPrefix`) and never to rename.
175
+ const slug = basename(e.name, ext)
176
+ entities.push({
177
+ id: [...dirs, slug].join('/'),
178
+ schema: schemaForPoolDirs(dirs),
179
+ slug,
180
+ file: e.name,
181
+ dirs: [...dirs],
182
+ relPath: [rel, ...dirs, e.name].join('/'),
183
+ poolPath: [...dirs, e.name].join('/'),
184
+ absPath: full,
185
+ ext,
186
+ })
187
+ }
188
+ }
189
+
190
+ await walk(base, [])
191
+ return { entities, errors, exists: true }
192
+ }
193
+
194
+ /**
195
+ * The pool grouped by the schema each path declares.
196
+ *
197
+ * ⭐ This is what a query resolves against: it names a `schema:` and the pool
198
+ * follows, so there is no disk path for it to name. (`source:` survives for
199
+ * REMOTE sources, where the address is genuinely external.)
200
+ *
201
+ * @returns {Map<string, Array>} schema ref → entities, in pool order
202
+ */
203
+ export function groupPoolBySchema(entities) {
204
+ const bySchema = new Map()
205
+ for (const e of entities || []) {
206
+ if (!e?.schema) continue
207
+ if (!bySchema.has(e.schema)) bySchema.set(e.schema, [])
208
+ bySchema.get(e.schema).push(e)
209
+ }
210
+ return bySchema
211
+ }
@@ -4,12 +4,13 @@
4
4
  // accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
5
5
  //
6
6
  // refine refine · inherit · detail · limit · sort · where · filter
7
- // collection collection · schema · … — and NOT `path`/`url`
7
+ // query query · schema · … — and NOT `path`/`url`
8
8
  // source path · url · schema · …
9
+
9
10
  //
10
- // The build RESOLVES a `collection:` shorthand into a concrete location, so the
11
- // declaration that rides the sync wire carries BOTH the authored `collection` and
12
- // the derived `path`. Projecting that back verbatim writes a file that is neither
11
+ // The build RESOLVES a `query:` shorthand into a concrete location, so the
12
+ // declaration that rides the sync wire carries BOTH the authored `query` and the
13
+ // derived `path`. Projecting that back verbatim writes a file that is neither
13
14
  // shape cleanly: `collection` wins the classification, and the `path` beside it is
14
15
  // then an unrecognized key on its own declaration.
15
16
  //
@@ -19,33 +20,33 @@
19
20
  // fetch:
20
21
  // path: /data/members.json ← derived; also a build artifact path
21
22
  // schema: members
22
- // collection: members ← what the author actually wrote
23
+ // query: members ← what the author actually wrote
23
24
  //
24
25
  // [uniweb] fetch: unrecognized key "path" was ignored. Keys recognized on this
25
- // declaration: collection, detailPage, filter, limit, merge, prerender, schema,
26
+ // declaration: detailPage, filter, limit, merge, prerender, query, schema,
26
27
  // sort, transform, where.
27
28
  //
28
29
  // ⭐ The round trip has to invert the resolution, not copy it. `/data/<name>.json`
29
- // is a materialization of a collection, never its definition — so it is precisely
30
- // the thing an authored file should not contain.
30
+ // is a materialization of a query, never its definition — so it is precisely the
31
+ // thing an authored file should not contain.
31
32
  //
32
33
  // ⚖️ DROPS ONLY WHAT IS DERIVABLE, not everything unrecognized. A key we do not know
33
34
  // might be one a newer producer authored, and silently discarding it on every pull
34
35
  // would make the round trip lossy in a way nothing reports. `path` and `url` beside
35
- // a `collection` are recoverable from the collection itself; anything else survives
36
- // and the validator's warning stays the honest signal.
36
+ // a `query` are recoverable from the query itself; anything else survives and the
37
+ // validator's warning stays the honest signal.
37
38
 
38
39
  /** Which of the three shapes a declaration is — the same order `data-fetcher` uses. */
39
40
  export function fetchShapeOf(fetch) {
40
41
  if (!fetch || typeof fetch !== 'object') return null
41
42
  if (fetch.refine === true || fetch.inherit === true) return 'refine'
42
- if (fetch.collection) return 'collection'
43
+ if (fetch.query) return 'query'
43
44
  return 'source'
44
45
  }
45
46
 
46
47
  /** Keys a shape derives rather than the author writing them. */
47
48
  const DERIVED_BY_SHAPE = {
48
- collection: ['path', 'url'],
49
+ query: ['path', 'url'],
49
50
  refine: [],
50
51
  source: []
51
52
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * It began in `./config.js`, which imports a Vite plugin. That made it unreachable
8
8
  * from any lane that must not pull Vite — and the sync lane, needing exactly this
9
- * answer, grew its own weaker copy instead (`../uwx/collections.js`), which read
9
+ * answer, grew its own weaker copy instead (`../uwx/records.js`), which read
10
10
  * `package.json` `dependencies.foundation`: a key no current template produces, so
11
11
  * it returned null for every scaffolded site. A third copy in the CLI describes
12
12
  * itself as mirroring "a subset of" this one.
package/src/site/index.js CHANGED
@@ -38,10 +38,10 @@ export {
38
38
  isPdfFile
39
39
  } from './advanced-processors.js'
40
40
  export {
41
- processCollections,
42
- writeCollectionFiles,
43
- getCollectionLastModified
44
- } from './collection-processor.js'
41
+ processQueries,
42
+ writeQueryFiles,
43
+ getQueryLastModified
44
+ } from './query-processor.js'
45
45
  export { collectSchemalessData, collectSchemalessDataAssets, rewriteSchemalessDataAssets } from './schemaless-data.js'
46
46
  export {
47
47
  parseFetchConfig,
@@ -49,7 +49,8 @@ import {
49
49
  import { collectSiteContent, mountEntriesOf } from './content-collector.js'
50
50
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
51
51
  import { processAdvancedAssets } from './advanced-processors.js'
52
- import { processCollections, writeCollectionFiles } from './collection-processor.js'
52
+ import { processQueries, writeQueryFiles } from './query-processor.js'
53
+ import { ENTITIES_DIR } from './entity-pool.js'
53
54
  import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
54
55
  import { shouldSplitContent } from './split-content.js'
55
56
  import { FONT_LINKS_MARKER } from './head-markers.js'
@@ -566,13 +567,13 @@ export function siteContentPlugin(options = {}) {
566
567
  let watcher = null
567
568
  let server = null
568
569
  let localeTranslations = {} // Cache: { locale: translations }
569
- let collectionTranslations = {} // Cache: { locale: collection translations }
570
+ let recordTranslations = {} // Cache: { locale: record translations }
570
571
  let localesDir = 'locales' // Default, updated from site config
571
- let collectionsConfig = null // Cached for watcher setup
572
+ let queriesConfig = null // Cached for watcher setup
572
573
  let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
573
574
  let resolvedMountPaths = [] // Absolute dirs mounted under pages/ via site.yml paths:
574
575
  let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
575
- let resolvedCollectionsBase = null // Resolved from site.yml collectionsDir
576
+ let resolvedEntitiesDir = null // site.yml `paths.entities`, site-root-relative
576
577
  let headHtml = '' // Contents of site/head.html for injection
577
578
  let basePath = '/' // Vite's config.base, always has trailing slash
578
579
 
@@ -602,12 +603,12 @@ export function siteContentPlugin(options = {}) {
602
603
  /**
603
604
  * Load collection translations for a specific locale
604
605
  */
605
- async function loadCollectionTranslations(locale) {
606
- if (collectionTranslations[locale]) {
607
- return collectionTranslations[locale]
606
+ async function loadRecordTranslations(locale) {
607
+ if (recordTranslations[locale]) {
608
+ return recordTranslations[locale]
608
609
  }
609
610
 
610
- const localePath = join(resolvedSitePath, localesDir, 'collections', `${locale}.json`)
611
+ const localePath = join(resolvedSitePath, localesDir, 'records', `${locale}.json`)
611
612
  if (!existsSync(localePath)) {
612
613
  return null
613
614
  }
@@ -615,7 +616,7 @@ export function siteContentPlugin(options = {}) {
615
616
  try {
616
617
  const content = await readFile(localePath, 'utf-8')
617
618
  const translations = JSON.parse(content)
618
- collectionTranslations[locale] = translations
619
+ recordTranslations[locale] = translations
619
620
  return translations
620
621
  } catch {
621
622
  return null
@@ -792,7 +793,7 @@ export function siteContentPlugin(options = {}) {
792
793
  try {
793
794
  // Do an early content collection to get the collections config
794
795
  const earlyContent = await collectForBundle(resolvedSitePath, { foundationPath })
795
- collectionsConfig = earlyContent.config?.collections
796
+ queriesConfig = earlyContent.config?.queries
796
797
 
797
798
  // Resolve content directory paths from site.yml paths: group
798
799
  const paths = earlyContent?.config?.paths || {}
@@ -802,18 +803,16 @@ export function siteContentPlugin(options = {}) {
802
803
  resolvedLayoutPath = paths.layout
803
804
  ? resolve(resolvedSitePath, paths.layout)
804
805
  : resolve(resolvedSitePath, 'layout')
805
- resolvedCollectionsBase = paths.collections
806
- ? resolve(resolvedSitePath, paths.collections)
807
- : null
806
+ resolvedEntitiesDir = paths.entities || null
808
807
  resolvedMountPaths = mountEntriesOf(paths).map(([, rel]) => resolve(resolvedSitePath, rel))
809
808
 
810
- if (collectionsConfig) {
811
- console.log('[site-content] Processing content collections...')
812
- const collections = await processCollections(resolvedSitePath, collectionsConfig, resolvedCollectionsBase, basePath)
813
- await writeCollectionFiles(resolvedSitePath, collections, collectionsConfig)
809
+ if (queriesConfig) {
810
+ console.log('[site-content] Materializing queries...')
811
+ const byQuery = await processQueries(resolvedSitePath, queriesConfig, resolvedEntitiesDir, basePath)
812
+ await writeQueryFiles(resolvedSitePath, byQuery, queriesConfig)
814
813
  }
815
814
  } catch (err) {
816
- console.warn('[site-content] Early collection processing failed:', err.message)
815
+ console.warn('[site-content] Early query materialization failed:', err.message)
817
816
  }
818
817
  }
819
818
 
@@ -828,9 +827,7 @@ export function siteContentPlugin(options = {}) {
828
827
  resolvedLayoutPath = paths.layout
829
828
  ? resolve(resolvedSitePath, paths.layout)
830
829
  : resolve(resolvedSitePath, 'layout')
831
- resolvedCollectionsBase = paths.collections
832
- ? resolve(resolvedSitePath, paths.collections)
833
- : null
830
+ resolvedEntitiesDir = paths.entities || null
834
831
  }
835
832
  },
836
833
 
@@ -845,13 +842,13 @@ export function siteContentPlugin(options = {}) {
845
842
  headHtml = await loadHeadHtml()
846
843
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
847
844
 
848
- // Process content collections if defined in site.yml
845
+ // Materialize each query if the site declares any.
849
846
  // In dev mode, this was already done in configResolved (before server starts)
850
847
  // In production, do it here
851
- if (isProduction && siteContent.config?.collections) {
852
- console.log('[site-content] Processing content collections...')
853
- const collections = await processCollections(resolvedSitePath, siteContent.config.collections, resolvedCollectionsBase, basePath)
854
- await writeCollectionFiles(resolvedSitePath, collections, siteContent.config.collections)
848
+ if (isProduction && siteContent.config?.queries) {
849
+ console.log('[site-content] Materializing queries...')
850
+ const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedEntitiesDir, basePath)
851
+ await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
855
852
  }
856
853
 
857
854
  // Execute data fetches in dev mode
@@ -867,7 +864,7 @@ export function siteContentPlugin(options = {}) {
867
864
 
868
865
  // Clear translation cache on rebuild
869
866
  localeTranslations = {}
870
- collectionTranslations = {}
867
+ recordTranslations = {}
871
868
  } catch (err) {
872
869
  console.error('[site-content] Failed to collect content:', err.message)
873
870
  // Production: a failed collect must fail the build — falling through
@@ -909,22 +906,22 @@ export function siteContentPlugin(options = {}) {
909
906
  }
910
907
 
911
908
  // Debounce collection rebuilds separately (writes to file system)
912
- let collectionRebuildTimeout = null
913
- const scheduleCollectionRebuild = () => {
914
- if (collectionRebuildTimeout) clearTimeout(collectionRebuildTimeout)
915
- collectionRebuildTimeout = setTimeout(async () => {
916
- console.log('[site-content] Collection content changed, regenerating JSON...')
909
+ let recordRebuildTimeout = null
910
+ const scheduleRecordRebuild = () => {
911
+ if (recordRebuildTimeout) clearTimeout(recordRebuildTimeout)
912
+ recordRebuildTimeout = setTimeout(async () => {
913
+ console.log('[site-content] Records changed, regenerating JSON...')
917
914
  try {
918
- // Use collectionsConfig (cached from configResolved) or siteContent
919
- const collections = collectionsConfig || siteContent?.config?.collections
920
- if (collections) {
921
- const processed = await processCollections(resolvedSitePath, collections, resolvedCollectionsBase, basePath)
922
- await writeCollectionFiles(resolvedSitePath, processed, collections)
915
+ // Use queriesConfig (cached from configResolved) or siteContent
916
+ const byQuery = queriesConfig || siteContent?.config?.queries
917
+ if (byQuery) {
918
+ const processed = await processQueries(resolvedSitePath, byQuery, resolvedEntitiesDir, basePath)
919
+ await writeQueryFiles(resolvedSitePath, processed, byQuery)
923
920
  }
924
921
  // Send full reload to client
925
922
  server.ws.send({ type: 'full-reload' })
926
923
  } catch (err) {
927
- console.error('[site-content] Collection rebuild failed:', err.message)
924
+ console.error('[site-content] Record rebuild failed:', err.message)
928
925
  }
929
926
  }, 100)
930
927
  }
@@ -991,23 +988,21 @@ export function siteContentPlugin(options = {}) {
991
988
  // head.html may not exist, that's ok
992
989
  }
993
990
 
994
- // Watch content/ folder for collection changes
995
- // Use collectionsConfig cached from configResolved (siteContent may be null here)
996
- if (collectionsConfig) {
997
- const contentPaths = new Set()
998
- const collectionBase = resolvedCollectionsBase || resolvedSitePath
999
- for (const config of Object.values(collectionsConfig)) {
1000
- const collectionPath = typeof config === 'string' ? config : config.path
1001
- if (collectionPath) {
1002
- contentPaths.add(resolve(collectionBase, collectionPath))
1003
- }
1004
- }
991
+ // WATCH THE POOL, NOT A DIRECTORY PER QUERY. This used to resolve
992
+ // `config.path` for each declaration and watch each one, so a schema
993
+ // folder no query had mentioned yet — a new one, mid-session — was
994
+ // watched by nothing and its records never rebuilt. `entities/` is one
995
+ // recursive root and covers every schema, present and future.
996
+ {
997
+ const contentPaths = new Set([
998
+ resolve(resolvedSitePath, resolvedEntitiesDir || ENTITIES_DIR)
999
+ ])
1005
1000
 
1006
1001
  for (const contentPath of contentPaths) {
1007
1002
  if (existsSync(contentPath)) {
1008
1003
  try {
1009
- watchers.push(watch(contentPath, { recursive: true }, scheduleCollectionRebuild))
1010
- console.log(`[site-content] Watching ${contentPath} for collection changes`)
1004
+ watchers.push(watch(contentPath, { recursive: true }, scheduleRecordRebuild))
1005
+ console.log(`[site-content] Watching ${contentPath} for record changes`)
1011
1006
  } catch (err) {
1012
1007
  console.warn('[site-content] Could not watch content directory:', err.message)
1013
1008
  }
@@ -1028,7 +1023,7 @@ export function siteContentPlugin(options = {}) {
1028
1023
  const localeWatcher = watch(localesPath, { recursive: false }, () => {
1029
1024
  console.log('[site-content] Translation files changed, clearing cache...')
1030
1025
  localeTranslations = {}
1031
- collectionTranslations = {}
1026
+ recordTranslations = {}
1032
1027
  server.ws.send({ type: 'full-reload' })
1033
1028
  })
1034
1029
  additionalWatchers.push(localeWatcher)
@@ -1053,16 +1048,16 @@ export function siteContentPlugin(options = {}) {
1053
1048
  }
1054
1049
 
1055
1050
  // Watch collection translations directory
1056
- const collectionsLocalesPath = resolve(localesPath, 'collections')
1057
- if (existsSync(collectionsLocalesPath)) {
1051
+ const recordLocalesPath = resolve(localesPath, 'records')
1052
+ if (existsSync(recordLocalesPath)) {
1058
1053
  try {
1059
- const collWatcher = watch(collectionsLocalesPath, { recursive: false }, () => {
1060
- console.log('[site-content] Collection translations changed, clearing cache...')
1061
- collectionTranslations = {}
1054
+ const collWatcher = watch(recordLocalesPath, { recursive: false }, () => {
1055
+ console.log('[site-content] Record translations changed, clearing cache...')
1056
+ recordTranslations = {}
1062
1057
  server.ws.send({ type: 'full-reload' })
1063
1058
  })
1064
1059
  additionalWatchers.push(collWatcher)
1065
- console.log(`[site-content] Watching ${collectionsLocalesPath} for collection translation changes`)
1060
+ console.log(`[site-content] Watching ${recordLocalesPath} for record translation changes`)
1066
1061
  } catch (err) {
1067
1062
  // collections locales dir may not exist, that's ok
1068
1063
  }
@@ -1212,7 +1207,7 @@ export function siteContentPlugin(options = {}) {
1212
1207
  if (localeDataMatch) {
1213
1208
  const locale = localeDataMatch[1]
1214
1209
  const filename = localeDataMatch[2]
1215
- const collectionName = filename.replace('.json', '')
1210
+ const queryName = filename.replace('.json', '')
1216
1211
  const sourcePath = join(resolvedSitePath, 'public', DATA_DIR, filename)
1217
1212
 
1218
1213
  if (existsSync(sourcePath)) {
@@ -1221,15 +1216,15 @@ export function siteContentPlugin(options = {}) {
1221
1216
  const items = JSON.parse(raw)
1222
1217
 
1223
1218
  // Load collection translations for this locale
1224
- const translations = await loadCollectionTranslations(locale) || {}
1219
+ const translations = await loadRecordTranslations(locale) || {}
1225
1220
 
1226
1221
  // Check for free-form translations
1227
1222
  const freeformDir = join(resolvedSitePath, localesDir, 'freeform', locale)
1228
1223
  const hasFreeform = existsSync(freeformDir)
1229
1224
 
1230
1225
  // Translate using the collections module
1231
- const { translateCollectionData } = await import('../i18n/collections.js')
1232
- const translated = await translateCollectionData(items, collectionName, resolvedSitePath, {
1226
+ const { translateRecordData } = await import('../i18n/records.js')
1227
+ const translated = await translateRecordData(items, queryName, resolvedSitePath, {
1233
1228
  locale,
1234
1229
  localesDir: join(resolvedSitePath, localesDir),
1235
1230
  translations,
@@ -1240,7 +1235,7 @@ export function siteContentPlugin(options = {}) {
1240
1235
  res.end(JSON.stringify(translated, null, 2))
1241
1236
  return
1242
1237
  } catch (err) {
1243
- console.warn(`[site-content] Failed to serve localized collection ${filename}: ${err.message}`)
1238
+ console.warn(`[site-content] Failed to serve localized records ${filename}: ${err.message}`)
1244
1239
  // Fall through to Vite's static server
1245
1240
  }
1246
1241
  }