@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 +9 -9
- package/src/foundation-name.js +89 -0
- package/src/i18n/records.js +1 -1
- package/src/index.js +9 -0
- package/src/prerender.js +1 -1
- package/src/runtime-schema.js +4 -2
- package/src/schema.js +95 -10
- package/src/site/content-collector.js +1 -1
- package/src/site/entity-pool.js +58 -10
- package/src/site/foundation-ref.js +15 -2
- package/src/site/plugin.js +9 -7
- package/src/site/queries-config.js +3 -3
- package/src/site/query-processor.js +8 -7
- package/src/site/records-config.js +73 -32
- package/src/utils/numeric-prefix.js +1 -1
- package/src/uwx/backfill.js +22 -2
- package/src/uwx/entity-source.js +1 -1
- package/src/uwx/folder.js +3 -3
- package/src/uwx/index.js +7 -4
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +56 -61
- package/src/uwx/records.js +55 -49
- package/src/uwx/registry-package.js +40 -6
- package/src/uwx/self-scope.js +110 -23
- package/src/uwx/site-project.js +5 -2
- package/src/uwx/site.js +42 -70
- package/src/uwx/sync-package.js +20 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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/
|
|
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/
|
|
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.
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
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
|
+
}
|
package/src/i18n/records.js
CHANGED
|
@@ -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 `
|
|
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 `
|
|
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/runtime-schema.js
CHANGED
|
@@ -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
|
|
198
|
-
//
|
|
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
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/site/entity-pool.js
CHANGED
|
@@ -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.
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.`,
|
package/src/site/plugin.js
CHANGED
|
@@ -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
|
|
136
|
-
*
|
|
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`, `
|
|
1100
|
-
// `
|
|
1101
|
-
// cannot be set on a file created after the server started
|
|
1102
|
-
// `
|
|
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/
|
|
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 (`
|
|
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 `
|
|
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`),
|
|
824
|
-
// is what a query's `scope` reads. ⛔ Until 2026-09-21 `records.yml
|
|
825
|
-
// records, and an unlisted file was left out of
|
|
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
|
-
`
|
|
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 })
|