@uniweb/build 0.57.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-reader": "^1.2.5",
61
- "@uniweb/schemas": "^0.3.3",
62
60
  "@uniweb/content-writer": "^0.3.4",
61
+ "@uniweb/content-reader": "^1.2.5",
62
+ "@uniweb/projections": "^0.7.3",
63
63
  "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/theming": "^0.1.15",
65
- "@uniweb/projections": "^0.7.3"
64
+ "@uniweb/schemas": "^0.3.3",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.3"
68
+ "@uniweb/runtime": "^0.27.4"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.29.7",
78
- "@uniweb/runtime": "^0.27.3"
77
+ "@uniweb/core": "^0.29.8",
78
+ "@uniweb/runtime": "^0.27.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -0,0 +1,63 @@
1
+ // A foundation's NAME — what it registers as (`@<org>/<name>`) and what a site pins
2
+ // (`@<org>/<name>@<version>`). A registry keys foundations by it: (name, version) is
3
+ // unique on a backend, and the uuid a backend mints for one never leaves that backend,
4
+ // so the name is the identity that travels.
5
+ //
6
+ // ⭐ ONE RULE, and every reader asks it: the build (`schema.json`'s `_self.name`),
7
+ // `uniweb register`, and `push` / `publish` looking a local foundation up in a
8
+ // catalog. **`main.js`'s `name`, else `package.json`'s `name`.**
9
+ //
10
+ // ⛔ `package.json::name` IS WORKSPACE PLUMBING FIRST. pnpm needs it unique in the
11
+ // workspace, and a site depends on it by that name (`"src": "file:../src"`,
12
+ // `site.yml::foundation: src`). The scaffold names it `src` for exactly that reason,
13
+ // which is why it is only the fallback here — and why `src` and `foundation` are
14
+ // refused as a foundation's name: they name the folder, not the foundation, and every
15
+ // project in an org would register the same one.
16
+ //
17
+ // ⛔ `package.json::uniweb.id` WAS A REGISTRY-NAME OVERRIDE, 2026-07-17 → 2026-09-21.
18
+ // It is retired: the name lives in `main.js`, and two homes for one fact is how `push`
19
+ // and `register` came to look the same foundation up under different names.
20
+
21
+ /** Names that say where the code lives, not what the foundation is. */
22
+ export const FORBIDDEN_FOUNDATION_NAMES = new Set(['src', 'foundation'])
23
+
24
+ // One segment of a name: lowercase letters, digits and inner hyphens.
25
+ const SEGMENT = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
26
+
27
+ /**
28
+ * The foundation's name, by the one rule: `main.js`'s `name`, else `package.json`'s.
29
+ *
30
+ * @param {{ config?: object, pkg?: object }} sources - `main.js`'s default export
31
+ * and the parsed `package.json`
32
+ * @returns {{ name: string|null, source: 'main.js'|'package.json'|null }}
33
+ */
34
+ export function foundationNameOf({ config, pkg } = {}) {
35
+ const fromConfig = typeof config?.name === 'string' ? config.name.trim() : ''
36
+ if (fromConfig) return { name: fromConfig, source: 'main.js' }
37
+ const fromPkg = typeof pkg?.name === 'string' ? pkg.name.trim() : ''
38
+ if (fromPkg) return { name: fromPkg, source: 'package.json' }
39
+ return { name: null, source: null }
40
+ }
41
+
42
+ /**
43
+ * Is this a name a foundation can register under? `marketing`, or `@acme/marketing`
44
+ * when the name carries its own scope.
45
+ *
46
+ * @param {unknown} name
47
+ * @returns {string|null} why it cannot, or null when it can
48
+ */
49
+ export function checkFoundationName(name) {
50
+ if (typeof name !== 'string' || !name.trim()) return 'the foundation has no name'
51
+ const scoped = /^@([^/]+)\/([^/]+)$/.exec(name)
52
+ if (!scoped && name.includes('/')) {
53
+ return `"${name}" is not a foundation name — it is a name, or @org/name`
54
+ }
55
+ const [scope, bare] = scoped ? [scoped[1], scoped[2]] : [null, name]
56
+ if (FORBIDDEN_FOUNDATION_NAMES.has(bare)) {
57
+ return `"${bare}" names the folder, not the foundation — and every project in an org would register the same one`
58
+ }
59
+ if (!SEGMENT.test(bare) || (scope !== null && !SEGMENT.test(scope))) {
60
+ return `"${name}" is not a foundation name — use lowercase letters, digits and hyphens (like "marketing")`
61
+ }
62
+ return null
63
+ }
@@ -158,7 +158,7 @@ function isFieldTranslatable(fieldDef) {
158
158
  * ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
159
159
  * either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
160
160
  * parametric page's URL names; `$uuid`), and a record's top-level `slug` and `path`
161
- * are its handle and its placement in `records.yml`. Until 2026-09-14 they became
161
+ * are its handle and its placement in `folder.yml`. Until 2026-09-14 they became
162
162
  * translation units, and a translation whose source happened to equal one rewrote it,
163
163
  * so a record's page stopped matching its URL.
164
164
  *
package/src/index.js CHANGED
@@ -11,8 +11,16 @@ export {
11
11
  discoverComponents,
12
12
  buildSchema,
13
13
  getExposedComponents,
14
+ readFoundationName,
14
15
  } from './schema.js'
15
16
 
17
+ // A foundation's name — the one rule the build, `register` and `push` share
18
+ export {
19
+ foundationNameOf,
20
+ checkFoundationName,
21
+ FORBIDDEN_FOUNDATION_NAMES,
22
+ } from './foundation-name.js'
23
+
16
24
  // Data-conformance checking (your content vs the schemas your foundation declares)
17
25
  export {
18
26
  validateItem,
package/src/prerender.js CHANGED
@@ -407,7 +407,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
407
407
  for (const paramValue of values) {
408
408
 
409
409
  // Create concrete route: /blog/:slug → /blog/my-post. Under `[...path]` the
410
- // record's URL is its placement (the folder `records.yml` put it in, carried
410
+ // record's URL is its placement (the folder `folder.yml` put it in, carried
411
411
  // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
412
412
  // decoded: the server decodes the request before looking the file up.
413
413
  const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
package/src/schema.js CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  deriveRecordsSupport,
24
24
  unnameableIn,
25
25
  } from './foundation/derive-supports.js'
26
+ import { foundationNameOf } from './foundation-name.js'
27
+ import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
26
28
 
27
29
  // Component meta file name
28
30
  const META_FILE_NAME = 'meta.js'
@@ -176,13 +178,21 @@ export async function loadPackageJson(srcDir) {
176
178
  const content = await readFile(packagePath, 'utf-8')
177
179
  const pkg = JSON.parse(content)
178
180
 
179
- // Extract only identity fields for schema.
180
- // `uniweb.id` is the REGISTERED name (registry identity), decoupled from the
181
- // workspace package `name` (pnpm linking / file: deps / site.yml). It lets a
182
- // foundation keep a scaffold-default package name like "src" while registering
183
- // under a distinct id (e.g. "docs" → @org/docs). Falls back to `name`.
181
+ // Extract only identity fields for schema. `name` here is the package's — the
182
+ // FALLBACK for the foundation's name, which `main.js` supplies first
183
+ // (`foundation-name.js`, applied in `buildSchema`).
184
+ //
185
+ // ⛔ `uniweb.id` is no longer read (retired 2026-09-21): it was a registry-name
186
+ // override, and the name lives in `main.js` now. Said here rather than refused —
187
+ // a build does not register, and `uniweb register` refuses it by name.
188
+ if (pkg.uniweb?.id !== undefined) {
189
+ console.warn(
190
+ `[uniweb] ${packagePath}: \`uniweb.id\` is no longer read — a foundation's name is ` +
191
+ `\`name\` in main.js's default export (name: '${pkg.uniweb.id}').`
192
+ )
193
+ }
184
194
  return {
185
- name: pkg.uniweb?.id || pkg.name,
195
+ name: pkg.name,
186
196
  version: pkg.version,
187
197
  description: pkg.description,
188
198
  // `uniweb.supports` joins them because it is the same KIND of fact: static,
@@ -197,6 +207,43 @@ export async function loadPackageJson(srcDir) {
197
207
  }
198
208
  }
199
209
 
210
+ /**
211
+ * A local foundation's name, read from its source by the one rule
212
+ * (`foundation-name.js`) — for `uniweb register` and for `push` / `publish` looking
213
+ * the foundation up, which need it before a build, or without one.
214
+ *
215
+ * ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21). This is the reader
216
+ * whose answer registers, so a leftover stops here rather than registering the
217
+ * foundation under a different name than the one it was given.
218
+ *
219
+ * @param {string} foundationDir - the foundation package's root
220
+ * @returns {Promise<{ name: string|null, source: 'main.js'|'package.json'|null, mainFile: string }>}
221
+ * `mainFile` is the foundation's `main.js`, whether or not it exists yet
222
+ */
223
+ export async function readFoundationName(foundationDir) {
224
+ const pkgPath = join(foundationDir, 'package.json')
225
+ let pkg = {}
226
+ if (existsSync(pkgPath)) {
227
+ try {
228
+ pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) || {}
229
+ } catch {
230
+ pkg = {}
231
+ }
232
+ }
233
+ if (pkg.uniweb?.id !== undefined) {
234
+ throw new Error(
235
+ `${pkgPath}: \`uniweb.id\` is no longer read — a foundation's name is \`name\` in ` +
236
+ `main.js's default export. Move it there (name: '${pkg.uniweb.id}') and remove uniweb.id.`
237
+ )
238
+ }
239
+ const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
240
+ const config = await loadFoundationConfig(srcDir)
241
+ return {
242
+ ...foundationNameOf({ config, pkg }),
243
+ mainFile: join(srcDir, FOUNDATION_FILE_NAMES[0]),
244
+ }
245
+ }
246
+
200
247
  /**
201
248
  * Load the foundation's declarations from `src/main.js`.
202
249
  *
@@ -310,7 +357,12 @@ function warnMisplacedCapabilities(module, filePath) {
310
357
  * still resolve from the project's own `node_modules`.
311
358
  */
312
359
  async function importFoundationConfig(filePath) {
313
- const href = pathToFileURL(filePath).href
360
+ // ⭐ IMPORTED BY CONTENT, as `loadMetaFile` imports `meta.js`: Node caches a module
361
+ // by URL, so a second read in one process returned the FIRST content. That matters
362
+ // since 2026-09-21, when `register` can write a `name` into this file and `push`
363
+ // reads the name again after the release, in the same process that read it before.
364
+ const content = createHash('sha1').update(await readFile(filePath)).digest('hex').slice(0, 16)
365
+ const href = `${pathToFileURL(filePath).href}?content=${content}`
314
366
  try {
315
367
  return await import(href)
316
368
  } catch (error) {
@@ -333,7 +385,7 @@ async function importFoundationConfig(filePath) {
333
385
  external: ['react', 'react/*', 'react-dom', 'react-dom/*'],
334
386
  logLevel: 'silent',
335
387
  })
336
- return await import(pathToFileURL(outfile).href)
388
+ return await import(`${pathToFileURL(outfile).href}?content=${content}`)
337
389
  } finally {
338
390
  await rm(outfile, { force: true }).catch(() => {})
339
391
  }
@@ -932,13 +984,18 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
932
984
  const supports = composeSupports(identity.supports, derived)
933
985
  reportSupports(srcDir, identity.supports, derived, supports.supports)
934
986
 
987
+ // The foundation's name, by the one rule every reader shares (`foundation-name.js`):
988
+ // `main.js`'s `name`, else `package.json`'s. ⚠️ Until 2026-09-21 the `main.js` value
989
+ // was described here as "editor-facing identity", as if a display name — but it is
990
+ // the name that registers, and the only name the framework sends.
991
+ const { name } = foundationNameOf({ config: foundationConfig, pkg: identity })
992
+
935
993
  return {
936
994
  _self: {
937
995
  ...configWithoutExtension,
938
996
  ...identity,
939
997
  ...supports,
940
- // foundation.js overrides package.json for editor-facing identity
941
- ...(foundationConfig.name && { name: foundationConfig.name }),
998
+ ...(name && { name }),
942
999
  ...(foundationConfig.description && { description: foundationConfig.description }),
943
1000
  ...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
944
1001
  ...(isExtension && { role: 'extension' }),
@@ -822,7 +822,7 @@ function resolveDisplayTitle(declaredTitle, segment, sections) {
822
822
  return extractH1(sections[0]?.content) || prettifySlug(segment)
823
823
  }
824
824
 
825
- // ⭐ The rule now lives in `utils/numeric-prefix.js` — `records.yml` needs the
825
+ // ⭐ The rule now lives in `utils/numeric-prefix.js` — `records/folder.yml` needs the
826
826
  // identical one, and a records reader cannot import this module without closing
827
827
  // a cycle. Imported at the top; still re-exported below for existing callers.
828
828
 
@@ -4,10 +4,18 @@
4
4
  // [Diego]). The directory is the file side of the site's records folder: the
5
5
  // backend's folder holds references to entities, and putting a ref in it is what
6
6
  // makes one a record — putting a file in this directory is the same act. Nothing
7
- // else is needed, and nothing lists it. (`records.yml` only organizes records into
8
- // sub-folders; see `records-config.js`.) A file whose name starts with `_` or `.`
7
+ // else is needed, and nothing lists it. A file whose name starts with `_` or `.`
9
8
  // is not read, so it is not a record.
10
9
  //
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
+ //
11
19
  // ⛔ A RECORD IS NOT "PUBLISHED" BY BEING HERE. `push` sends the site's records to a
12
20
  // backend, and they are served once the SITE is published — publishing the site
13
21
  // publishes its folder and what is in it. Nothing about a single record decides that.
@@ -15,7 +23,7 @@
15
23
  // ⛔ THE PATH DECLARES THE MODEL, AND NOTHING ELSE. `collections/<name>/` used to
16
24
  // mean three things at once — these files are entities, their schema is
17
25
  // `@/<name>`, and they are grouped as `<name>` for placement. A schema folder here
18
- // declares only the model; grouping is `records.yml`'s.
26
+ // declares only the model; grouping is `folder.yml`'s.
19
27
  //
20
28
  // ⇒ So nothing here reads a query or a folder. A site's records are a fact about
21
29
  // the filesystem — plus `site.yml::paths.records`, which says where they live.
@@ -32,13 +40,13 @@
32
40
  // ⛔ BARE, NOT `records/@std/`. Measured: `@` is a reserved indicator in YAML
33
41
  // 1.2, so a bare `@std/person/*.md` scalar throws in js-yaml — and the message is
34
42
  // `bad indentation of a sequence entry`, which names neither the cause nor the
35
- // fix. The `@` form would force quotes on every pattern in `records.yml` that
43
+ // fix. The `@` form would force quotes on every pattern in `folder.yml` that
36
44
  // names a scoped schema.
37
45
  //
38
46
  // ⛔ AND NO NESTING BELOW THE SCHEMA DIR, which is what makes the depth rule
39
47
  // total: it is the FILE's depth that decides, so one path answers the question
40
48
  // with nothing to classify and no ambiguous case to resolve. Organization is a
41
- // `folder:` in `records.yml`, never a directory.
49
+ // `folder:` in `folder.yml`, never a directory.
42
50
  //
43
51
  // ⛔ THE DIRECTORY WAS `entities/` UNTIL 2026-09-21, and `site.yml::paths.entities`
44
52
  // the key that moved it. Both are refused by name rather than read (no alias —
@@ -57,6 +65,15 @@ export const RECORDS_DIR = 'records'
57
65
  /** The directory's name until 2026-09-21. Read only to refuse it. */
58
66
  const RETIRED_DIR = 'entities'
59
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'
76
+
60
77
  function isDirectory(path) {
61
78
  try {
62
79
  return statSync(path).isDirectory()
@@ -92,10 +109,38 @@ export function refuseRetiredRecordsDir(siteRoot, rel) {
92
109
  throw new Error(
93
110
  `[uniweb] ${RETIRED_DIR}/ is not read — a site's records live in \`${rel}/\` now. ` +
94
111
  `Rename the directory (\`git mv ${RETIRED_DIR} ${rel}\`) and every file in it is a record, ` +
95
- `as before; records.yml no longer lists them.`
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.`
96
135
  )
97
136
  }
98
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
+
99
144
  /**
100
145
  * Where a site's records live — `site.yml::paths.records`, else `records/`.
101
146
  *
@@ -120,7 +165,7 @@ export function resolveRecordsDir(siteRoot, paths) {
120
165
  )
121
166
  }
122
167
  const rel = typeof p.records === 'string' && p.records.trim() ? p.records.trim().replace(/\/+$/, '') : RECORDS_DIR
123
- refuseRetiredRecordsDir(siteRoot, rel)
168
+ refuseRetiredLayout(siteRoot, rel)
124
169
  return { rel, abs: resolve(siteRoot, rel) }
125
170
  }
126
171
 
@@ -211,7 +256,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
211
256
  let rel
212
257
  if (opts.dir) {
213
258
  rel = String(opts.dir).replace(/\/+$/, '')
214
- refuseRetiredRecordsDir(siteRoot, rel)
259
+ refuseRetiredLayout(siteRoot, rel)
215
260
  } else {
216
261
  rel = resolveRecordsDir(siteRoot).rel
217
262
  }
@@ -243,7 +288,7 @@ export async function readEntityPool(siteRoot, opts = {}) {
243
288
  `${rel}/${[...dirs, e.name].join('/')}/ is nested below a schema folder. ` +
244
289
  `\`${rel}/\` declares a model and nothing else — \`${[...dirs].join('/')}\` ` +
245
290
  `already names ${read}, so there is no meaning left for a folder inside it. ` +
246
- `Organise records in records.yml (a \`folder:\` entry), not on disk.`
291
+ `Organise records in ${rel}/${FOLDER_YML} (a \`folder:\` entry), not on disk.`
247
292
  )
248
293
  continue
249
294
  }
@@ -251,13 +296,16 @@ export async function readEntityPool(siteRoot, opts = {}) {
251
296
  continue
252
297
  }
253
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
254
301
  const ext = extname(e.name).toLowerCase()
255
302
  if (!ENTITY_EXTENSIONS.has(ext)) continue
256
303
  if (dirs.length === 0) {
257
304
  errors.push(
258
305
  `${rel}/${e.name} sits directly in \`${rel}/\`, which names no model. ` +
259
306
  `Move it under a schema folder — \`${rel}/<name>/\` for \`@/<name>\`, ` +
260
- `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.`
261
309
  )
262
310
  continue
263
311
  }
@@ -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 folder each record sits in, which a query's `scope:` reads.
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':
@@ -1096,10 +1098,10 @@ export function siteContentPlugin(options = {}) {
1096
1098
  }
1097
1099
  }
1098
1100
 
1099
- // Watch the site root — `site.yml`, `queries.yml`, `records.yml`, `theme.yml`,
1100
- // `head.html` — as ONE directory rather than a watch per file: a file watch
1101
- // cannot be set on a file created after the server started (a first
1102
- // `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.
1103
1105
  // Not recursive, so what the build writes below the root (`public/data/`) is
1104
1106
  // not seen here. What each change redoes is `siteRootChange`.
1105
1107
  try {
@@ -17,7 +17,7 @@
17
17
  //
18
18
  // ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
19
19
  // `records/{schema}/` holds the site's records (placing a file there is what makes
20
- // it one), `records.yml` sorts them into folders, and a query asks for a set. This
20
+ // it one), `records/folder.yml` sorts them into folders, and a query asks for a set. This
21
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
@@ -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
 
@@ -136,7 +136,7 @@ function parseQueryConfig(name, config) {
136
136
  // model, so the site's records of a schema are the query's records.
137
137
  schema: config.schema || null,
138
138
  url: config.url || null,
139
- // The folder branch the query reads (`records.yml` placement). ⛔ Not read
139
+ // The folder branch the query reads (`folder.yml` placement). ⛔ Not read
140
140
  // here until 2026-09-11: a named query's `scope` was ignored on this lane.
141
141
  scope: typeof config.scope === 'string' ? config.scope : null,
142
142
  sort: config.sort || null,
@@ -702,7 +702,7 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
702
702
  })
703
703
  )
704
704
 
705
- // ⭐ `path` IS THE FOLDER `records.yml` PLACED THE RECORD IN — `''` at the top —
705
+ // ⭐ `path` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
706
706
  // and it is the whole reason folders exist: `scope: archive` is how a query asks
707
707
  // for a slice. Structure is query scope, not navigation.
708
708
  //
@@ -820,18 +820,19 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
820
820
  }
821
821
 
822
822
  // ⭐ EVERY FILE IN `records/` IS A RECORD (ruled 2026-09-21 [Diego]), so every one
823
- // is compiled; `records.yml` only says which folder each sits in (`path`), which
824
- // is what a query's `scope` reads. ⛔ Until 2026-09-21 `records.yml` listed the
825
- // records, and an unlisted file was left out of `/data/<name>.json` here.
823
+ // is compiled; `records/folder.yml` only says which folder each sits in (`path`),
824
+ // which is what a query's `scope` reads. ⛔ Until 2026-09-21 `records.yml`, at the
825
+ // site root, listed the records, and an unlisted file was left out of
826
+ // `/data/<name>.json` here.
826
827
  //
827
828
  // ⛔ A MALFORMED FILE STILL STOPS THE BUILD. It read as absent here until
828
829
  // 2026-09-14 while the sync lane refused the same file (`uwx/records.js`); what an
829
830
  // author meant to organize cannot be guessed.
830
- const recordsCfg = await readRecordsConfig(siteDir)
831
+ const recordsCfg = await readRecordsConfig(siteDir, { dir: pool.dir })
831
832
  if (recordsCfg.error) {
832
833
  throw new Error(
833
834
  `[uniweb] ${recordsCfg.error}\n` +
834
- ` records.yml says which folder each record sits in, and a query's \`scope:\` reads that — fix it to build.`
835
+ ` ${recordsCfg.file} says which folder each record sits in, and a query's \`scope:\` reads that — fix it to build.`
835
836
  )
836
837
  }
837
838
  const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })