@uniweb/build 0.56.0 → 0.58.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.
@@ -1,49 +1,175 @@
1
- // A site's ENTITY POOL — every stored thing on disk, and the model each one has.
1
+ // A site's RECORDS on disk — every file in `records/`, and the model each one has.
2
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.
3
+ // ⭐ PLACING A FILE IN `records/` IS WHAT MAKES IT A RECORD (ruled 2026-09-21
4
+ // [Diego]). The directory is the file side of the site's records folder: the
5
+ // backend's folder holds references to entities, and putting a ref in it is what
6
+ // makes one a record — putting a file in this directory is the same act. Nothing
7
+ // else is needed, and nothing lists it. A file whose name starts with `_` or `.`
8
+ // is not read, so it is not a record.
9
9
  //
10
- // ⇒ So nothing here reads a query, a folder, or any config. A pool is a fact
11
- // about the filesystem.
10
+ // ⭐ ONE FILE AT THE TOP IS NOT A RECORD: `folder.yml`, the folder's organization —
11
+ // which records sit in which sub-folder (`records-config.js`). The top of the
12
+ // directory is the one place no record can be (a file there names no model), so the
13
+ // name collides with nothing. It lives here because the directory IS the site's
14
+ // folder on the file side: its organization moves with `paths.records` and travels
15
+ // with a records directory that sites share, as a pages directory's own
16
+ // `folder.yml` does. ⛔ It was `records.yml` at the site root until 2026-09-21
17
+ // [Diego]; that file is refused by name (`refuseRetiredLayout`).
18
+ //
19
+ // ⛔ A RECORD IS NOT "PUBLISHED" BY BEING HERE. `push` sends the site's records to a
20
+ // backend, and they are served once the SITE is published — publishing the site
21
+ // publishes its folder and what is in it. Nothing about a single record decides that.
22
+ //
23
+ // ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. `collections/<name>/` used to
24
+ // mean three things at once — these files are entities, their schema is
25
+ // `@/<name>`, and they are grouped as `<name>` for placement. A schema folder here
26
+ // declares only the model; grouping is `folder.yml`'s.
27
+ //
28
+ // ⇒ So nothing here reads a query or a folder. A site's records are a fact about
29
+ // the filesystem — plus `site.yml::paths.records`, which says where they live.
12
30
  //
13
31
  // ⭐ DEPTH NAMES THE SCOPE — the schema-ref grammar, spelled as directories:
14
32
  //
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)
33
+ // records/person/ada.md → @/person (the foundation's own)
34
+ // records/std/person/ada.md → @std/person (the shared standard set)
35
+ // records/acme/project/x.md → @acme/project (an org's)
18
36
  //
19
37
  // matching `build/src/resolve-data-schema.js`, which is the only grammar there
20
38
  // is: a bare directory name can mean `@/<name>` and nothing else.
21
39
  //
22
- // ⛔ BARE, NOT `entities/@std/`. Measured: `@` is a reserved indicator in YAML
40
+ // ⛔ BARE, NOT `records/@std/`. Measured: `@` is a reserved indicator in YAML
23
41
  // 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
24
42
  // `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.
43
+ // fix. The `@` form would force quotes on every pattern in `folder.yml` that
44
+ // names a scoped schema.
27
45
  //
28
46
  // ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
29
47
  // 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.
48
+ // with nothing to classify and no ambiguous case to resolve. Organization is a
49
+ // `folder:` in `folder.yml`, never a directory.
31
50
  //
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.
51
+ // ⛔ THE DIRECTORY WAS `entities/` UNTIL 2026-09-21, and `site.yml::paths.entities`
52
+ // the key that moved it. Both are refused by name rather than read (no alias —
53
+ // there is no population to carry): a leftover `entities/` beside `records/` would
54
+ // otherwise hold records nothing reads, silently.
38
55
 
39
56
  import { readdir } from 'node:fs/promises'
40
- import { existsSync } from 'node:fs'
41
- import { join, extname, basename } from 'node:path'
57
+ import { existsSync, readFileSync, statSync } from 'node:fs'
58
+ import { join, extname, basename, resolve } from 'node:path'
59
+ import yaml from 'js-yaml'
60
+ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
61
+
62
+ /** Where a site's records live, relative to its root, unless `site.yml::paths.records` moves them. */
63
+ export const RECORDS_DIR = 'records'
64
+
65
+ /** The directory's name until 2026-09-21. Read only to refuse it. */
66
+ const RETIRED_DIR = 'entities'
67
+
68
+ /**
69
+ * The folder's organization, at the top of the records directory — the one file
70
+ * there that is not a record. See the header, and `records-config.js`.
71
+ */
72
+ export const FOLDER_YML = 'folder.yml'
73
+
74
+ /** Where the folder's organization lived until 2026-09-21: the site root. Read only to refuse it. */
75
+ const RETIRED_FOLDER_FILE = 'records.yml'
42
76
 
43
- /** Where a site's entities live, relative to its root. */
44
- export const ENTITIES_DIR = 'entities'
77
+ function isDirectory(path) {
78
+ try {
79
+ return statSync(path).isDirectory()
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+
85
+ function readSitePaths(siteRoot) {
86
+ const file = join(siteRoot, 'site.yml')
87
+ if (!existsSync(file)) return {}
88
+ try {
89
+ const doc = yaml.load(readFileSync(file, 'utf8'), YAML_OPTIONS)
90
+ return doc && typeof doc.paths === 'object' && doc.paths !== null ? doc.paths : {}
91
+ } catch {
92
+ // An unreadable site.yml is reported by whoever reads the site; this only
93
+ // needs the one key, and the default is the right answer without it.
94
+ return {}
95
+ }
96
+ }
45
97
 
46
- /** Source extensions an entity file may have. Mirrors the sync-lane reader. */
98
+ /**
99
+ * Refuse the retired `entities/` directory when it is not the one the site reads.
100
+ *
101
+ * ⛔ A RENAMED DIRECTORY FAILS SILENTLY — its files are simply not read, and a site
102
+ * with no records looks exactly like a site whose records moved. So its presence
103
+ * stops the build, the push and every other reader, naming the new place.
104
+ */
105
+ export function refuseRetiredRecordsDir(siteRoot, rel) {
106
+ const retired = resolve(siteRoot, RETIRED_DIR)
107
+ if (resolve(siteRoot, rel) === retired) return
108
+ if (!isDirectory(retired)) return
109
+ throw new Error(
110
+ `[uniweb] ${RETIRED_DIR}/ is not read — a site's records live in \`${rel}/\` now. ` +
111
+ `Rename the directory (\`git mv ${RETIRED_DIR} ${rel}\`) and every file in it is a record, ` +
112
+ `as before; nothing lists them any more.`
113
+ )
114
+ }
115
+
116
+ /**
117
+ * Refuse the retired `records.yml` at the site root: the folder's organization is
118
+ * `folder.yml` inside the records directory now.
119
+ *
120
+ * ⛔ SAME REASON AS THE DIRECTORY ABOVE. Left in place it would be read by nothing,
121
+ * every record would sit at the top of the folder, and the next push would send
122
+ * that — replacing the backend folder's sub-folders — with nothing to say why. Its
123
+ * contents move unchanged: the paths in it were always relative to the records
124
+ * directory.
125
+ */
126
+ export function refuseRetiredFolderFile(siteRoot, rel) {
127
+ const retired = resolve(siteRoot, RETIRED_FOLDER_FILE)
128
+ // A records directory at the site root itself would make that path a record's.
129
+ if (resolve(siteRoot, rel) === resolve(siteRoot)) return
130
+ if (!existsSync(retired)) return
131
+ throw new Error(
132
+ `[uniweb] ${RETIRED_FOLDER_FILE} is not read — the records folder's organization is ` +
133
+ `\`${rel}/${FOLDER_YML}\` now, inside the directory it organizes. Move it ` +
134
+ `(\`git mv ${RETIRED_FOLDER_FILE} ${rel}/${FOLDER_YML}\`); its contents are unchanged.`
135
+ )
136
+ }
137
+
138
+ /** Both retired layouts, refused wherever the records directory is resolved. */
139
+ function refuseRetiredLayout(siteRoot, rel) {
140
+ refuseRetiredRecordsDir(siteRoot, rel)
141
+ refuseRetiredFolderFile(siteRoot, rel)
142
+ }
143
+
144
+ /**
145
+ * Where a site's records live — `site.yml::paths.records`, else `records/`.
146
+ *
147
+ * ⭐ THE ONE RESOLVER, and every lane asks it: the build, the push, the pull and
148
+ * the CLI. ⛔ Until 2026-09-21 only the build honoured the key — then called
149
+ * `paths.entities` — while push read the default, so a site that moved its records
150
+ * built fine and then failed to push, with an error naming a directory it did not
151
+ * use (measured).
152
+ *
153
+ * @param {string} siteRoot
154
+ * @param {object} [paths] - `site.yml::paths`, when the caller has already read it;
155
+ * otherwise `site.yml` is read here.
156
+ * @returns {{ rel: string, abs: string }} `rel` as written (for messages), `abs`
157
+ * resolved against the site root — an absolute `paths.records` is honoured.
158
+ */
159
+ export function resolveRecordsDir(siteRoot, paths) {
160
+ const p = paths === undefined ? readSitePaths(siteRoot) : paths || {}
161
+ if (p.entities !== undefined) {
162
+ throw new Error(
163
+ `[uniweb] site.yml: \`paths.entities\` is now \`paths.records\` — the directory a site's ` +
164
+ `records live in. Rename the key.`
165
+ )
166
+ }
167
+ const rel = typeof p.records === 'string' && p.records.trim() ? p.records.trim().replace(/\/+$/, '') : RECORDS_DIR
168
+ refuseRetiredLayout(siteRoot, rel)
169
+ return { rel, abs: resolve(siteRoot, rel) }
170
+ }
171
+
172
+ /** Source extensions a record file may have. Mirrors the sync-lane reader. */
47
173
  export const ENTITY_EXTENSIONS = new Set(['.md', '.yml', '.yaml', '.json', '.bib'])
48
174
 
49
175
  const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
@@ -51,7 +177,7 @@ const isHidden = (name) => name.startsWith('_') || name.startsWith('.')
51
177
  /**
52
178
  * The schema ref a pool path names.
53
179
  *
54
- * @param {string[]} dirs - the directory segments below `entities/`
180
+ * @param {string[]} dirs - the directory segments below `records/`
55
181
  * @returns {string|null} the ref, or null when the depth names no schema
56
182
  */
57
183
  export function schemaForPoolDirs(dirs) {
@@ -61,7 +187,7 @@ export function schemaForPoolDirs(dirs) {
61
187
  }
62
188
 
63
189
  /**
64
- * Where a schema's entities live — the inverse of `schemaForPoolDirs`.
190
+ * Where a schema's records live — the inverse of `schemaForPoolDirs`.
65
191
  *
66
192
  * ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
67
193
  * exists at all: the reader derives a model from a path and the pull side derives
@@ -70,7 +196,7 @@ export function schemaForPoolDirs(dirs) {
70
196
  * `deferredFromSchema` — a deriver and its recognizer must not be two copies.
71
197
  *
72
198
  * @param {string} schema - a ref: `@/name` or `@org/name`
73
- * @returns {string[]|null} the directory segments below `entities/`, or null for
199
+ * @returns {string[]|null} the directory segments below `records/`, or null for
74
200
  * a ref this layout cannot express
75
201
  */
76
202
  export function poolDirsForSchema(schema) {
@@ -85,7 +211,7 @@ export function poolDirsForSchema(schema) {
85
211
  /**
86
212
  * Both readings of a 2-segment pool path, for an error that has to name them.
87
213
  *
88
- * ⚠️ A reader who mistakes `entities/person/2024/ada.md` for "the `person`
214
+ * ⚠️ A reader who mistakes `records/person/2024/ada.md` for "the `person`
89
215
  * schema, organised by year" needs to be told what the build actually did with
90
216
  * it, not merely that something did not resolve. The wrong reading is the
91
217
  * plausible one, so the message carries both.
@@ -98,33 +224,46 @@ export function poolPathReadings(dirs) {
98
224
  }
99
225
 
100
226
  /**
101
- * Read a site's entity pool.
227
+ * Read a site's records.
102
228
  *
103
- * Returns entities in a stable, path-sorted order — the wire's package digest
229
+ * Returns them in a stable, path-sorted order — the wire's package digest
104
230
  * depends on it — each carrying the model its path declares.
105
231
  *
106
232
  * ⚠️ NOTHING HERE RESOLVES A SCHEMA. Whether `@std/person` is a schema this site
107
233
  * can actually see is a question for whoever holds the foundation's built schema
108
234
  * 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.
235
+ * reports the directory's SHAPE — a file with no schema above it, a file nested
236
+ * too deep — because those are answerable from the filesystem alone.
111
237
  *
112
238
  * @param {string} siteRoot
113
239
  * @param {object} [opts]
114
- * @param {string} [opts.dir] - override the pool directory (site-root-relative)
240
+ * @param {string} [opts.dir] - the records directory, site-root-relative or
241
+ * absolute, when the caller already resolved it; otherwise `resolveRecordsDir`
242
+ * reads `site.yml::paths.records`.
115
243
  * @returns {Promise<{
116
244
  * entities: Array<{ id, schema, slug, dirs, relPath, absPath, ext }>,
117
245
  * errors: string[],
118
246
  * exists: boolean,
247
+ * dir: string,
119
248
  * }>}
120
- * `id` is the entity's path under `entities/` without its extension — unique
249
+ * `id` is the record's path under `records/` without its extension — unique
121
250
  * by construction, stable across pushes, and derivable identically on both
122
- * sides without either lane holding the other's ids.
251
+ * sides without either lane holding the other's ids. `exists` is whether the
252
+ * directory is there at all, which is not the same as holding nothing: a push
253
+ * leaves the backend's folder alone for a site with no records directory.
123
254
  */
124
255
  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 }
256
+ let rel
257
+ if (opts.dir) {
258
+ rel = String(opts.dir).replace(/\/+$/, '')
259
+ refuseRetiredLayout(siteRoot, rel)
260
+ } else {
261
+ rel = resolveRecordsDir(siteRoot).rel
262
+ }
263
+ // ⛔ `resolve`, not `join`: an absolute `paths.records` is supported, and
264
+ // `join(siteRoot, '/abs')` is a path under the site root that does not exist.
265
+ const base = resolve(siteRoot, rel)
266
+ if (!isDirectory(base)) return { entities: [], errors: [], exists: false, dir: rel }
128
267
 
129
268
  const entities = []
130
269
  const errors = []
@@ -149,7 +288,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
149
288
  `${rel}/${[...dirs, e.name].join('/')}/ is nested below a schema folder. ` +
150
289
  `\`${rel}/\` declares a model and nothing else — \`${[...dirs].join('/')}\` ` +
151
290
  `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.`
291
+ `Organise records in ${rel}/${FOLDER_YML} (a \`folder:\` entry), not on disk.`
153
292
  )
154
293
  continue
155
294
  }
@@ -157,13 +296,16 @@ export async function readEntityPool(siteRoot, opts = {}) {
157
296
  continue
158
297
  }
159
298
  if (!e.isFile()) continue
299
+ // The folder's organization, not a record — see the header.
300
+ if (dirs.length === 0 && e.name === FOLDER_YML) continue
160
301
  const ext = extname(e.name).toLowerCase()
161
302
  if (!ENTITY_EXTENSIONS.has(ext)) continue
162
303
  if (dirs.length === 0) {
163
304
  errors.push(
164
305
  `${rel}/${e.name} sits directly in \`${rel}/\`, which names no model. ` +
165
306
  `Move it under a schema folder — \`${rel}/<name>/\` for \`@/<name>\`, ` +
166
- `or \`${rel}/<org>/<name>/\` for \`@<org>/<name>\`.`
307
+ `or \`${rel}/<org>/<name>/\` for \`@<org>/<name>\`. The one file that belongs ` +
308
+ `at the top is \`${FOLDER_YML}\`, the folder's organization.`
167
309
  )
168
310
  continue
169
311
  }
@@ -188,7 +330,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
188
330
  }
189
331
 
190
332
  await walk(base, [])
191
- return { entities, errors, exists: true }
333
+ return { entities, errors, exists: true, dir: rel }
192
334
  }
193
335
 
194
336
  /**
@@ -50,7 +50,7 @@ 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
52
  import { processQueries, writeQueryFiles } from './query-processor.js'
53
- import { ENTITIES_DIR } from './entity-pool.js'
53
+ import { RECORDS_DIR, resolveRecordsDir } from './entity-pool.js'
54
54
  import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
55
55
  import { shouldSplitContent } from './split-content.js'
56
56
  import { FONT_LINKS_MARKER } from './head-markers.js'
@@ -132,8 +132,10 @@ export function shouldPrefetchInDev(cfg) {
132
132
  * - `'queries'` — re-collect the site, re-materialize every query file from the
133
133
  * query list just collected, reload. `queries.yml` declares queries, and so does
134
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.
135
+ * - `'records'` — re-materialize the query files, reload. Only the retired
136
+ * `records.yml` maps here now, so that the rebuild REPORTS it: the folder's
137
+ * organization is `records/folder.yml` since 2026-09-21, and an edit to it is
138
+ * seen by the records directory's own recursive watch.
137
139
  * - `'content'` — re-collect the site, reload.
138
140
  *
139
141
  * ⛔ Until 2026-09-14 nothing watched `records.yml` or `queries.yml`, a `site.yml`
@@ -148,7 +150,7 @@ export function siteRootChange(filename) {
148
150
  case 'site.yml':
149
151
  case 'queries.yml':
150
152
  return 'queries'
151
- case 'records.yml':
153
+ case 'records.yml': // retired — watched so the rebuild refuses it
152
154
  return 'records'
153
155
  case 'theme.yml':
154
156
  case 'head.html':
@@ -665,7 +667,7 @@ export function siteContentPlugin(options = {}) {
665
667
  let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
666
668
  let resolvedMountPaths = [] // Absolute dirs mounted under pages/ via site.yml paths:
667
669
  let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
668
- let resolvedEntitiesDir = null // site.yml `paths.entities`, site-root-relative
670
+ let resolvedRecordsDir = null // site.yml `paths.records` as written, via `resolveRecordsDir`
669
671
  let headHtml = '' // Contents of site/head.html for injection
670
672
  let basePath = '/' // Vite's config.base, always has trailing slash
671
673
 
@@ -725,8 +727,11 @@ export function siteContentPlugin(options = {}) {
725
727
  */
726
728
  async function materializeQueries(queries, config) {
727
729
  if (!queries) return
728
- const byQuery = await processQueries(resolvedSitePath, queries, resolvedEntitiesDir, basePath, {
730
+ const byQuery = await processQueries(resolvedSitePath, queries, resolvedRecordsDir, basePath, {
729
731
  locale: resolveDefaultLocale(config ?? {}) ?? null,
732
+ // ⭐ A draft is previewable here, as a hidden page is — `pnpm dev` is where the
733
+ // author looks at it. A production build leaves it out (`buildStart`).
734
+ includeDrafts: !isProduction,
730
735
  })
731
736
  await writeQueryFiles(resolvedSitePath, byQuery, queries)
732
737
  }
@@ -911,7 +916,7 @@ export function siteContentPlugin(options = {}) {
911
916
  resolvedLayoutPath = paths.layout
912
917
  ? resolve(resolvedSitePath, paths.layout)
913
918
  : resolve(resolvedSitePath, 'layout')
914
- resolvedEntitiesDir = paths.entities || null
919
+ resolvedRecordsDir = resolveRecordsDir(resolvedSitePath, paths).rel
915
920
  resolvedMountPaths = mountEntriesOf(paths).map(([, rel]) => resolve(resolvedSitePath, rel))
916
921
 
917
922
  if (queriesConfig) {
@@ -934,7 +939,7 @@ export function siteContentPlugin(options = {}) {
934
939
  resolvedLayoutPath = paths.layout
935
940
  ? resolve(resolvedSitePath, paths.layout)
936
941
  : resolve(resolvedSitePath, 'layout')
937
- resolvedEntitiesDir = paths.entities || null
942
+ resolvedRecordsDir = resolveRecordsDir(resolvedSitePath, paths).rel
938
943
  }
939
944
  },
940
945
 
@@ -954,7 +959,7 @@ export function siteContentPlugin(options = {}) {
954
959
  // In production, do it here
955
960
  if (isProduction && siteContent.config?.queries) {
956
961
  console.log('[site-content] Materializing queries...')
957
- const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedEntitiesDir, basePath)
962
+ const byQuery = await processQueries(resolvedSitePath, siteContent.config.queries, resolvedRecordsDir, basePath)
958
963
  await writeQueryFiles(resolvedSitePath, byQuery, siteContent.config.queries)
959
964
  }
960
965
 
@@ -1093,10 +1098,10 @@ export function siteContentPlugin(options = {}) {
1093
1098
  }
1094
1099
  }
1095
1100
 
1096
- // Watch the site root — `site.yml`, `queries.yml`, `records.yml`, `theme.yml`,
1097
- // `head.html` — as ONE directory rather than a watch per file: a file watch
1098
- // cannot be set on a file created after the server started (a first
1099
- // `records.yml`), and an editor that saves by replacing a file ends its watch.
1101
+ // Watch the site root — `site.yml`, `queries.yml`, `theme.yml`, `head.html`,
1102
+ // and the retired `records.yml` — as ONE directory rather than a watch per
1103
+ // file: a file watch cannot be set on a file created after the server started
1104
+ // (a first `queries.yml`), and an editor that saves by replacing a file ends its watch.
1100
1105
  // Not recursive, so what the build writes below the root (`public/data/`) is
1101
1106
  // not seen here. What each change redoes is `siteRootChange`.
1102
1107
  try {
@@ -1113,11 +1118,11 @@ export function siteContentPlugin(options = {}) {
1113
1118
  // ⭐ WATCH THE POOL, NOT A DIRECTORY PER QUERY. This used to resolve
1114
1119
  // `config.path` for each declaration and watch each one, so a schema
1115
1120
  // folder no query had mentioned yet — a new one, mid-session — was
1116
- // watched by nothing and its records never rebuilt. `entities/` is one
1121
+ // watched by nothing and its records never rebuilt. `records/` is one
1117
1122
  // recursive root and covers every schema, present and future.
1118
1123
  {
1119
1124
  const contentPaths = new Set([
1120
- resolve(resolvedSitePath, resolvedEntitiesDir || ENTITIES_DIR)
1125
+ resolve(resolvedSitePath, resolvedRecordsDir || RECORDS_DIR)
1121
1126
  ])
1122
1127
 
1123
1128
  for (const contentPath of contentPaths) {
@@ -16,9 +16,9 @@
16
16
  // rather keep one file. Precedence (per-query, per-key): queries.yml > site.yml.
17
17
  //
18
18
  // ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
19
- // `entities/{schema}/` is the pool, `records.yml` is the folder (what makes an
20
- // entity a record), and a query asks the folder for a set. This file resolves the
21
- // LAST of those only.
19
+ // `records/{schema}/` holds the site's records (placing a file there is what makes
20
+ // it one), `records/folder.yml` sorts them into folders, and a query asks for a set. This
21
+ // file resolves the LAST of those only.
22
22
  //
23
23
  // ⚠️ `collections.yml` and `site.yml::collections` are GONE, with no alias and no
24
24
  // deprecation path — the model's §5 ruling, and there is nothing outside this
@@ -85,7 +85,7 @@ export function defaultSchema(name) {
85
85
  // address nothing local can derive.
86
86
  function normalizeQueryDecl(name, decl) {
87
87
  // ⭐ THE STRING SHORTHAND NAMES THE SCHEMA, because that is what a file-based
88
- // query actually needs — `entities/{schema}/` supplies the records, so there is
88
+ // query actually needs — `records/{schema}/` supplies the records, so there is
89
89
  // no directory left to name. (It named a PATH while the pool was
90
90
  // `collections/<name>/` and the same directory answered both questions.)
91
91
  if (typeof decl === 'string') return { name, schema: decl }
@@ -116,7 +116,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
116
116
  const ymlPath = join(siteRoot, QUERIES_YML_RELPATH)
117
117
  const hasQueriesYml = existsSync(ymlPath)
118
118
  // ⛔ A BARE MAP — `queries.yml` has no root key. The file IS the map, the way
119
- // `records.yml` IS the list. A `queries:` key inside it would be a name a query
119
+ // `records/folder.yml` IS the list. A `queries:` key inside it would be a name a query
120
120
  // could then collide with.
121
121
  const queriesYml = hasQueriesYml ? await readYamlFile(ymlPath) : {}
122
122
 
@@ -165,7 +165,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
165
165
  if (decl.path && decl.url === undefined) {
166
166
  console.warn(
167
167
  `[uniweb] query "${decl.name}": \`path: ${decl.path}\` is ignored. A query names a ` +
168
- `\`schema:\` and \`entities/{schema}/\` supplies its records — there is no directory ` +
168
+ `\`schema:\` and \`records/{schema}/\` supplies its records — there is no directory ` +
169
169
  `for it to name. Move the files under the schema folder instead.`
170
170
  )
171
171
  }
@@ -234,6 +234,16 @@ export function refuseQueryDeclaration(decl) {
234
234
  }
235
235
  refuseQueryRoute(decl, where)
236
236
  refuseLimit(decl.limit, where)
237
+ // ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
238
+ // backend while a query owned its records; every record in `records/` is pushed
239
+ // now, whatever reads it, so an ignored `sync: false` would push exactly what the
240
+ // author meant to hold back. Refused, naming what does that now.
241
+ if (decl.sync !== undefined) {
242
+ throw new Error(
243
+ `[uniweb] ${where}: \`sync:\` is retired — a query does not own its records. Every file in ` +
244
+ `\`records/\` is a record and is pushed; a file whose name starts with \`_\` is not a record.`
245
+ )
246
+ }
237
247
  const external = decl.url !== undefined
238
248
  if (!external) {
239
249
  for (const key of ['method', 'body', 'transform', 'record']) {