@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 +8 -8
- package/src/foundation-name.js +63 -0
- package/src/i18n/records.js +1 -1
- package/src/index.js +8 -0
- package/src/prerender.js +1 -1
- package/src/schema.js +67 -10
- package/src/site/content-collector.js +1 -1
- package/src/site/entity-pool.js +58 -10
- package/src/site/plugin.js +9 -7
- package/src/site/queries-config.js +2 -2
- 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 +4 -3
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +27 -25
- package/src/uwx/records.js +35 -39
- package/src/uwx/registry-package.js +8 -1
- package/src/uwx/sync-package.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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/
|
|
65
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/schemas": "^0.3.3",
|
|
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/core": "^0.29.
|
|
78
|
-
"@uniweb/runtime": "^0.27.
|
|
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
|
+
}
|
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,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 `
|
|
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
|
-
//
|
|
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
|
+
}
|
|
184
194
|
return {
|
|
185
|
-
name: pkg.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
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
|
|
|
@@ -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 })
|