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