@uniweb/build 0.57.0 → 0.59.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.59.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
- "@uniweb/content-writer": "^0.3.4",
60
+ "@uniweb/schemas": "^0.3.4",
63
61
  "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/theming": "^0.1.15",
65
- "@uniweb/projections": "^0.7.3"
62
+ "@uniweb/content-writer": "^0.3.4",
63
+ "@uniweb/projections": "^0.7.3",
64
+ "@uniweb/content-reader": "^1.2.5",
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/runtime": "^0.27.4",
78
+ "@uniweb/core": "^0.29.8"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -0,0 +1,89 @@
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
+ // ⭐ THE SCOPE IS PART OF THE NAME (2026-09-22 [Diego]) — `@acme/marketing` — so the
22
+ // org a foundation registers under lives where the rest of its name does. A bare name
23
+ // has no scope yet; `uniweb register` gives it one and writes it into `main.js`.
24
+ // ⛔ `package.json::uniweb.scope` held it until then, a second home for half of one
25
+ // fact, and the halves disagreed: a foundation named `@acme/fnd` registered its data
26
+ // schemas under whatever `uniweb.scope` said (measured: `@proximify/member`).
27
+
28
+ /** Names that say where the code lives, not what the foundation is. */
29
+ export const FORBIDDEN_FOUNDATION_NAMES = new Set(['src', 'foundation'])
30
+
31
+ // One segment of a name: lowercase letters, digits and inner hyphens.
32
+ const SEGMENT = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
33
+
34
+ /**
35
+ * The foundation's name, by the one rule: `main.js`'s `name`, else `package.json`'s.
36
+ *
37
+ * @param {{ config?: object, pkg?: object }} sources - `main.js`'s default export
38
+ * and the parsed `package.json`
39
+ * @returns {{ name: string|null, source: 'main.js'|'package.json'|null }}
40
+ */
41
+ export function foundationNameOf({ config, pkg } = {}) {
42
+ const fromConfig = typeof config?.name === 'string' ? config.name.trim() : ''
43
+ if (fromConfig) return { name: fromConfig, source: 'main.js' }
44
+ const fromPkg = typeof pkg?.name === 'string' ? pkg.name.trim() : ''
45
+ if (fromPkg) return { name: fromPkg, source: 'package.json' }
46
+ return { name: null, source: null }
47
+ }
48
+
49
+ /**
50
+ * Is this a name a foundation can register under? `marketing`, or `@acme/marketing`
51
+ * when the name carries its own scope.
52
+ *
53
+ * @param {unknown} name
54
+ * @returns {string|null} why it cannot, or null when it can
55
+ */
56
+ export function checkFoundationName(name) {
57
+ if (typeof name !== 'string' || !name.trim()) return 'the foundation has no name'
58
+ const scoped = /^@([^/]+)\/([^/]+)$/.exec(name)
59
+ if (!scoped && name.includes('/')) {
60
+ return `"${name}" is not a foundation name — it is a name, or @org/name`
61
+ }
62
+ const [scope, bare] = scoped ? [scoped[1], scoped[2]] : [null, name]
63
+ if (FORBIDDEN_FOUNDATION_NAMES.has(bare)) {
64
+ return `"${bare}" names the folder, not the foundation — and every project in an org would register the same one`
65
+ }
66
+ if (!SEGMENT.test(bare) || (scope !== null && !SEGMENT.test(scope))) {
67
+ return `"${name}" is not a foundation name — use lowercase letters, digits and hyphens (like "marketing")`
68
+ }
69
+ return null
70
+ }
71
+
72
+ /**
73
+ * A foundation's name in its two halves: the scope it registers under, and the name
74
+ * within that scope. `@acme/marketing` → `{ scope: '@acme', bare: 'marketing' }`;
75
+ * `marketing` → `{ scope: null, bare: 'marketing' }` — a name that has not registered
76
+ * yet, since `register` writes the scope it chooses into the name.
77
+ *
78
+ * ⭐ The scope is what a foundation's own `@/x` data schemas resolve into — at
79
+ * register, and in a site's records and queries, which name those same Models.
80
+ *
81
+ * @param {unknown} name
82
+ * @returns {{ scope: string|null, bare: string|null }} `scope` as `@org`
83
+ */
84
+ export function splitFoundationName(name) {
85
+ const text = typeof name === 'string' ? name.trim() : ''
86
+ if (!text) return { scope: null, bare: null }
87
+ const scoped = /^@([^/]+)\/([^/]+)$/.exec(text)
88
+ return scoped ? { scope: `@${scoped[1]}`, bare: scoped[2] } : { scope: null, bare: text }
89
+ }
@@ -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,17 @@ 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
+ splitFoundationName,
22
+ FORBIDDEN_FOUNDATION_NAMES,
23
+ } from './foundation-name.js'
24
+
16
25
  // Data-conformance checking (your content vs the schemas your foundation declares)
17
26
  export {
18
27
  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
@@ -194,8 +194,10 @@ function leanDataSchema(value, dataSchemaMap) {
194
194
  if (ref) {
195
195
  const resolved = dataSchemaMap[ref]
196
196
  // ⛔ `flatRecordFields`, NOT `resolved.fields`. `dataSchemaMap` holds each
197
- // schema AS AUTHORED — resolution and lowering are different steps, and only
198
- // the second normalizes the two authoring forms. `fields:` at the top is the
197
+ // schema NORMALIZED but still in the form it was authored in — `fields:` or
198
+ // `sections:` — because only lowering (`uwx/data-schema.js`) folds the two
199
+ // forms into one. (This said "AS AUTHORED" until 2026-09-22; aliases and
200
+ // `many:` are already folded here.) `fields:` at the top is the
199
201
  // SUGAR for a one-section model; a sections-form schema has no such key, so
200
202
  // reading it directly returned null for every `@std/*` binding and the
201
203
  // section's `data:` declaration supplied no field defaults at all. Silent:
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, splitFoundationName } 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,29 @@ 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
+ }
194
+ // ⛔ `uniweb.scope` likewise (retired 2026-09-22) — the scope is part of the name.
195
+ // The build never read it; `uniweb register` refuses it, naming the line to write.
196
+ if (pkg.uniweb?.scope !== undefined) {
197
+ console.warn(
198
+ `[uniweb] ${packagePath}: \`uniweb.scope\` is no longer read — a foundation's scope is ` +
199
+ `part of its name in main.js's default export (name: '@<org>/<name>').`
200
+ )
201
+ }
184
202
  return {
185
- name: pkg.uniweb?.id || pkg.name,
203
+ name: pkg.name,
186
204
  version: pkg.version,
187
205
  description: pkg.description,
188
206
  // `uniweb.supports` joins them because it is the same KIND of fact: static,
@@ -197,6 +215,63 @@ export async function loadPackageJson(srcDir) {
197
215
  }
198
216
  }
199
217
 
218
+ /**
219
+ * A local foundation's name, read from its source by the one rule
220
+ * (`foundation-name.js`) — for `uniweb register` and for `push` / `publish` looking
221
+ * the foundation up, which need it before a build, or without one.
222
+ *
223
+ * ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21) and `uniweb.scope`
224
+ * (retired 2026-09-22 — the scope is part of the name). This is the reader whose
225
+ * answer registers, so a leftover stops here rather than registering the foundation
226
+ * under a different name than the one it was given.
227
+ *
228
+ * @param {string} foundationDir - the foundation package's root
229
+ * @returns {Promise<{ name: string|null, source: 'main.js'|'package.json'|null, mainFile: string }>}
230
+ * `mainFile` is the foundation's `main.js`, whether or not it exists yet
231
+ */
232
+ export async function readFoundationName(foundationDir) {
233
+ const pkgPath = join(foundationDir, 'package.json')
234
+ let pkg = {}
235
+ if (existsSync(pkgPath)) {
236
+ try {
237
+ pkg = JSON.parse(await readFile(pkgPath, 'utf-8')) || {}
238
+ } catch {
239
+ pkg = {}
240
+ }
241
+ }
242
+ if (pkg.uniweb?.id !== undefined) {
243
+ throw new Error(
244
+ `${pkgPath}: \`uniweb.id\` is no longer read — a foundation's name is \`name\` in ` +
245
+ `main.js's default export. Move it there (name: '${pkg.uniweb.id}') and remove uniweb.id.`
246
+ )
247
+ }
248
+ const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
249
+ const config = await loadFoundationConfig(srcDir)
250
+ const read = foundationNameOf({ config, pkg })
251
+ // ⛔ `package.json::uniweb.scope` is retired (2026-09-22): a foundation's scope is
252
+ // part of its name. Refused here, the reader whose answer registers, because a
253
+ // leftover would register the name and the data schemas under two different orgs.
254
+ if (pkg.uniweb?.scope !== undefined) {
255
+ throw new Error(retiredScopeMessage(pkgPath, pkg.uniweb.scope, read.name))
256
+ }
257
+ return {
258
+ ...read,
259
+ mainFile: join(srcDir, FOUNDATION_FILE_NAMES[0]),
260
+ }
261
+ }
262
+
263
+ /**
264
+ * What to say about a leftover `package.json::uniweb.scope`: the `main.js` line that
265
+ * replaces it, or — when `main.js` already scopes the name — that it can go.
266
+ */
267
+ function retiredScopeMessage(pkgPath, scope, name) {
268
+ const { scope: nameScope, bare } = splitFoundationName(name)
269
+ const intro = `${pkgPath}: \`uniweb.scope\` is no longer read — a foundation's scope is part of its name, \`name\` in main.js's default export.`
270
+ if (nameScope) return `${intro} main.js already names it '${name}', so remove uniweb.scope.`
271
+ const handle = String(scope ?? '').replace(/^@/, '').replace(/\/.*$/, '') || '<org>'
272
+ return `${intro} Move it there (name: '@${handle}/${bare || '<name>'}') and remove uniweb.scope.`
273
+ }
274
+
200
275
  /**
201
276
  * Load the foundation's declarations from `src/main.js`.
202
277
  *
@@ -310,7 +385,12 @@ function warnMisplacedCapabilities(module, filePath) {
310
385
  * still resolve from the project's own `node_modules`.
311
386
  */
312
387
  async function importFoundationConfig(filePath) {
313
- const href = pathToFileURL(filePath).href
388
+ // ⭐ IMPORTED BY CONTENT, as `loadMetaFile` imports `meta.js`: Node caches a module
389
+ // by URL, so a second read in one process returned the FIRST content. That matters
390
+ // since 2026-09-21, when `register` can write a `name` into this file and `push`
391
+ // reads the name again after the release, in the same process that read it before.
392
+ const content = createHash('sha1').update(await readFile(filePath)).digest('hex').slice(0, 16)
393
+ const href = `${pathToFileURL(filePath).href}?content=${content}`
314
394
  try {
315
395
  return await import(href)
316
396
  } catch (error) {
@@ -333,7 +413,7 @@ async function importFoundationConfig(filePath) {
333
413
  external: ['react', 'react/*', 'react-dom', 'react-dom/*'],
334
414
  logLevel: 'silent',
335
415
  })
336
- return await import(pathToFileURL(outfile).href)
416
+ return await import(`${pathToFileURL(outfile).href}?content=${content}`)
337
417
  } finally {
338
418
  await rm(outfile, { force: true }).catch(() => {})
339
419
  }
@@ -932,13 +1012,18 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
932
1012
  const supports = composeSupports(identity.supports, derived)
933
1013
  reportSupports(srcDir, identity.supports, derived, supports.supports)
934
1014
 
1015
+ // The foundation's name, by the one rule every reader shares (`foundation-name.js`):
1016
+ // `main.js`'s `name`, else `package.json`'s. ⚠️ Until 2026-09-21 the `main.js` value
1017
+ // was described here as "editor-facing identity", as if a display name — but it is
1018
+ // the name that registers, and the only name the framework sends.
1019
+ const { name } = foundationNameOf({ config: foundationConfig, pkg: identity })
1020
+
935
1021
  return {
936
1022
  _self: {
937
1023
  ...configWithoutExtension,
938
1024
  ...identity,
939
1025
  ...supports,
940
- // foundation.js overrides package.json for editor-facing identity
941
- ...(foundationConfig.name && { name: foundationConfig.name }),
1026
+ ...(name && { name }),
942
1027
  ...(foundationConfig.description && { description: foundationConfig.description }),
943
1028
  ...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
944
1029
  ...(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
  }
@@ -31,6 +31,20 @@
31
31
  import { existsSync, readFileSync } from 'node:fs'
32
32
  import { resolve, join } from 'node:path'
33
33
 
34
+ /**
35
+ * A catalog ref — `@org/name@version` — in its parts, or null for anything else.
36
+ *
37
+ * The grammar lives here once: `detectFoundationType` refuses a ref for a build, and a
38
+ * site's sync reads the ref's scope to qualify its `@/x` refs (`uwx/self-scope.js`).
39
+ *
40
+ * @param {unknown} value
41
+ * @returns {{ scope: string, name: string, version: string }|null} `scope` as `@org`
42
+ */
43
+ export function parseCatalogRef(value) {
44
+ const m = typeof value === 'string' ? /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(value) : null
45
+ return m ? { scope: `@${m[1]}`, name: m[2], version: m[3] } : null
46
+ }
47
+
34
48
  /**
35
49
  * Detect foundation type from the foundation config value
36
50
  *
@@ -104,8 +118,7 @@ export function detectFoundationType(foundation, siteRoot) {
104
118
  // match the documented backend selection — so `--backend`, `uniweb login
105
119
  // --backend` and the documented env var all left it pinned — and the artifact
106
120
  // names were the pre-`entry.js` ones the build stopped emitting.
107
- const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
108
- if (orgScopedMatch) {
121
+ if (parseCatalogRef(name)) {
109
122
  throw new Error(
110
123
  [
111
124
  `Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
@@ -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
 
@@ -368,7 +368,7 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
368
368
  * The `deferred:` a schema implies — every record field its **brief** does not name.
369
369
  *
370
370
  * ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
371
- * above uses it to FILL an unstated `deferred:`; `uwx/collections-project.js` uses it to
371
+ * above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
372
372
  * RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
373
373
  * into the author's file as though they had typed it.
374
374
  *
@@ -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 })