@uniweb/build 0.65.0 → 0.66.1
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/i18n/records.js +7 -5
- package/src/prerender.js +7 -7
- package/src/site/data-fetcher.js +1 -1
- package/src/site/file-transport.js +3 -2
- package/src/site/query-processor.js +42 -12
- package/src/site/records-config.js +127 -46
- package/src/uwx/folder.js +12 -4
- package/src/uwx/records-project.js +20 -5
- package/src/uwx/records.js +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.1",
|
|
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",
|
|
60
61
|
"@uniweb/schemas": "^0.8.0",
|
|
61
|
-
"@uniweb/projections": "^0.7.
|
|
62
|
-
"@uniweb/theming": "^0.1.15",
|
|
62
|
+
"@uniweb/projections": "^0.7.6",
|
|
63
63
|
"@uniweb/content-writer": "^0.3.4",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
65
|
+
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.27.
|
|
68
|
+
"@uniweb/runtime": "^0.27.8"
|
|
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.
|
|
78
|
-
"@uniweb/runtime": "^0.27.
|
|
77
|
+
"@uniweb/core": "^0.32.0",
|
|
78
|
+
"@uniweb/runtime": "^0.27.8"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/i18n/records.js
CHANGED
|
@@ -361,10 +361,12 @@ function derivesExcerpt(record, whole, dataSchema, config) {
|
|
|
361
361
|
/**
|
|
362
362
|
* ⛔ A RECORD'S SYSTEM FIELDS ARE NEVER PROSE — never extracted, never translated, by
|
|
363
363
|
* either path. A `$`-prefixed key at any depth is the system's (`$name`, the handle a
|
|
364
|
-
* parametric page's URL names; `$uuid
|
|
365
|
-
*
|
|
366
|
-
* translation units, and a translation whose source
|
|
367
|
-
* so a record's page stopped matching its URL.
|
|
364
|
+
* parametric page's URL names; `$uuid`; `$branch`, the folder a compiled list holds a
|
|
365
|
+
* record under), and a top-level `slug` is how a record's reader hands the file's name
|
|
366
|
+
* on. Until 2026-09-14 they became translation units, and a translation whose source
|
|
367
|
+
* happened to equal one rewrote it, so a record's page stopped matching its URL.
|
|
368
|
+
* ⛔ A top-level `path` was one too until 2026-09-27, when the placement it named moved to
|
|
369
|
+
* `$branch` [Diego]: a field an author names `path` is theirs, and translated as its schema says.
|
|
368
370
|
*
|
|
369
371
|
* @param {string|number} key - a field name (an array index is never one)
|
|
370
372
|
* @param {boolean} topLevel - whether the key sits at the record's own level
|
|
@@ -372,7 +374,7 @@ function derivesExcerpt(record, whole, dataSchema, config) {
|
|
|
372
374
|
*/
|
|
373
375
|
function isRecordSystemField(key, topLevel) {
|
|
374
376
|
if (typeof key !== 'string') return false
|
|
375
|
-
return key.startsWith('$') || (topLevel &&
|
|
377
|
+
return key.startsWith('$') || (topLevel && key === 'slug')
|
|
376
378
|
}
|
|
377
379
|
|
|
378
380
|
/**
|
package/src/prerender.js
CHANGED
|
@@ -14,7 +14,6 @@ import { pathToFileURL } from 'node:url'
|
|
|
14
14
|
import {
|
|
15
15
|
resolveDefaultLocale,
|
|
16
16
|
resolveFetchConfigs,
|
|
17
|
-
joinPathCapture,
|
|
18
17
|
pageRouteQuery,
|
|
19
18
|
routeSelection,
|
|
20
19
|
routeParamValues,
|
|
@@ -410,13 +409,14 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
|
|
|
410
409
|
}
|
|
411
410
|
for (const paramValue of values) {
|
|
412
411
|
|
|
413
|
-
// Create concrete route: /blog/:slug → /blog/my-post
|
|
414
|
-
// record
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
412
|
+
// Create concrete route: /blog/:slug → /blog/my-post, and the same under
|
|
413
|
+
// `[...path]`: a record carries no branch, so its own URL is its handle alone
|
|
414
|
+
// (ruled 2026-09-27 [Diego]), the URL its `$route` names (`fillRoutePattern`).
|
|
415
|
+
// ⛔ Until then this prefixed the record's `path`. ⛔ A FILE PATH, so decoded: the
|
|
416
|
+
// server decodes the request before looking the file up.
|
|
417
|
+
const capture = catchAll ? String(paramValue) : null
|
|
418
418
|
const concreteRoute = catchAll
|
|
419
|
-
? page.route.replace(new RegExp(`:${catchAll}\\*$`), capture)
|
|
419
|
+
? page.route.replace(new RegExp(`:${catchAll}\\*$`), () => capture)
|
|
420
420
|
: page.route.replace(`:${paramName}`, paramValue)
|
|
421
421
|
|
|
422
422
|
// ⛔ TWO RECORDS, ONE ROUTE — normal the moment the route field is not
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -88,7 +88,7 @@ export function applyWhere(items, where) {
|
|
|
88
88
|
* `@uniweb/core`'s `evaluateQuery`, the one order of work the runtime's default
|
|
89
89
|
* fetcher uses too:
|
|
90
90
|
*
|
|
91
|
-
* 1. the query's set — `scope` over each record
|
|
91
|
+
* 1. the query's set — `scope` over the branch each record is held with (`$branch`), `where`,
|
|
92
92
|
* `sort`, `limit`;
|
|
93
93
|
* 2. the fetch's `narrow` of it — `where` (and `match`), `sort`, `limit`.
|
|
94
94
|
*
|
|
@@ -72,8 +72,9 @@ export function createFileTransport({ siteRoot, distDir = null, publicDir = 'pub
|
|
|
72
72
|
const body = await readFile(file, 'utf8')
|
|
73
73
|
return response(body, path.endsWith('.json') ? 'application/json' : 'text/plain')
|
|
74
74
|
}
|
|
75
|
-
// ⚖️ Not an error here. A page can ask for a per-record file
|
|
76
|
-
// and the step reports the outcome so the caller
|
|
75
|
+
// ⚖️ Not an error here. A page can ask for a per-record file that was never written — a
|
|
76
|
+
// record this run withholds as a draft, say — and the step reports the outcome so the caller
|
|
77
|
+
// can see it rather than crash a build.
|
|
77
78
|
return missing(path)
|
|
78
79
|
}
|
|
79
80
|
}
|
|
@@ -58,7 +58,7 @@ import { createHash } from 'node:crypto'
|
|
|
58
58
|
import yaml from 'js-yaml'
|
|
59
59
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
60
60
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
61
|
-
import { DATA_DIR, withoutRouteVariables, mapQueryPaths } from '@uniweb/core'
|
|
61
|
+
import { DATA_DIR, withoutRouteVariables, mapQueryPaths, BRANCH_KEY } from '@uniweb/core'
|
|
62
62
|
import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute, refuseLimit } from './data-fetcher.js'
|
|
63
63
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
64
64
|
import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-pool.js'
|
|
@@ -760,8 +760,8 @@ export function flatFormRefusal(where, ref, schema, misplaced) {
|
|
|
760
760
|
* Every source file in a collection, as paths relative to the collection root —
|
|
761
761
|
* `hello.md`, `2024/spring.md`, `2024/q1/notes.yml`.
|
|
762
762
|
*
|
|
763
|
-
* Nesting is how an author
|
|
764
|
-
*
|
|
763
|
+
* Nesting is how an author organizes the files; the folder branch a record sits in, which a
|
|
764
|
+
* query's `scope:` reads, is `records/folder.yml`'s to say (`records-config.js`). Before this walk the
|
|
765
765
|
* scan was a flat `readdir`, so a record in a subdirectory was not ignored with
|
|
766
766
|
* a warning — it was invisible, and the site simply rendered without it.
|
|
767
767
|
*
|
|
@@ -800,7 +800,7 @@ function warnDuplicateSlugs(items, queryName) {
|
|
|
800
800
|
for (const item of items) {
|
|
801
801
|
if (!item || item.$name === undefined) continue
|
|
802
802
|
const name = String(item.$name)
|
|
803
|
-
const where = item
|
|
803
|
+
const where = item[BRANCH_KEY] ? `${item[BRANCH_KEY]}/` : ''
|
|
804
804
|
if (seen.has(name)) {
|
|
805
805
|
console.warn(
|
|
806
806
|
`[query-processor] Query "${queryName}" has more than one record named ` +
|
|
@@ -927,11 +927,21 @@ function pickBrief(record, keys) {
|
|
|
927
927
|
return out
|
|
928
928
|
}
|
|
929
929
|
for (const [key, value] of Object.entries(record)) {
|
|
930
|
-
if (!key.startsWith('$') && !['slug', '
|
|
930
|
+
if (!key.startsWith('$') && !['slug', 'excerpt', 'image', 'draft'].includes(key)) out[key] = value
|
|
931
931
|
}
|
|
932
932
|
return out
|
|
933
933
|
}
|
|
934
934
|
|
|
935
|
+
// What a record's folder entry says about it, as the keys a record is answered with — `$tags`
|
|
936
|
+
// (in the order written) and `$label` — or null when it says nothing.
|
|
937
|
+
function entryKeys(placed) {
|
|
938
|
+
if (!placed) return null
|
|
939
|
+
const out = {}
|
|
940
|
+
if (Array.isArray(placed.tags) && placed.tags.length) out.$tags = [...placed.tags]
|
|
941
|
+
if (typeof placed.label === 'string' && placed.label) out.$label = placed.label
|
|
942
|
+
return Object.keys(out).length ? out : null
|
|
943
|
+
}
|
|
944
|
+
|
|
935
945
|
// A query's records in folder-tree order (`folderTreeOrder`), before anything reads them — so
|
|
936
946
|
// `where`, `sort` (stable) and `limit` start from the order a records service answers in.
|
|
937
947
|
function inFolderOrder(pooled, rank) {
|
|
@@ -966,10 +976,17 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
|
|
|
966
976
|
// BibTeX → CSL-JSON bibliography items).
|
|
967
977
|
let items = await readPooledRecords(pooled, config, siteDir, recordsRoot, basePath)
|
|
968
978
|
|
|
969
|
-
// ⭐ `
|
|
979
|
+
// ⭐ `$branch` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
|
|
970
980
|
// and it is the whole reason folders exist: `scope: archive` is how a query asks
|
|
971
981
|
// for a slice. Structure is query scope, not navigation.
|
|
972
982
|
//
|
|
983
|
+
// ⛔ THE COMPILED FILE'S OWN KEY, NOT THE RECORD'S (ruled 2026-09-27 [Diego]): the
|
|
984
|
+
// browser evaluates `scope` over this file, so the file holds each record's branch, and
|
|
985
|
+
// no answer carries it — `@uniweb/core`'s `evaluateQuery` reads it and drops it, as a
|
|
986
|
+
// records service answers no branch. ⛔ Until then it was `path`: it reached every
|
|
987
|
+
// component, wrote over an authored field named `path`, and made a `[...path]` record's
|
|
988
|
+
// link `<branch>/<name>`.
|
|
989
|
+
//
|
|
973
990
|
// ⛔ THIS WAS HARDCODED TO `''` FOR A WHILE, AND THE COMMENT SAID "until the
|
|
974
991
|
// folder producer lands". It landed, and this was not revisited — so every
|
|
975
992
|
// folder slice matched NOTHING on the delivery lane, silently, which is the one
|
|
@@ -978,10 +995,20 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
|
|
|
978
995
|
//
|
|
979
996
|
// ⚠️ It stays a SCALAR. `@uniweb/core`'s `withinScope` matches strings only, so
|
|
980
997
|
// an array would match nothing — one placement per record is the ruling.
|
|
998
|
+
//
|
|
999
|
+
// ⭐ AND WHAT THE FOLDER SAYS ABOUT THE RECORD — its entry's `tags` and `label` — rides
|
|
1000
|
+
// beside it as `$tags` and `$label`, in its list and in its own file, as a records service
|
|
1001
|
+
// answers them (ruled 2026-09-27 [Diego]). Absent when the entry says none.
|
|
981
1002
|
items = items.map((result, i) => {
|
|
982
|
-
const
|
|
983
|
-
|
|
984
|
-
|
|
1003
|
+
const placed = pooled[i] ? config.placements?.get(pooled[i].id) : null
|
|
1004
|
+
const said = entryKeys(placed)
|
|
1005
|
+
const at = (item) => {
|
|
1006
|
+
if (!item) return item
|
|
1007
|
+
const out = { ...item, ...said, [BRANCH_KEY]: placed?.path ?? '' }
|
|
1008
|
+
if (said && out[WHOLE_RECORD]) out[WHOLE_RECORD] = { ...out[WHOLE_RECORD], ...said }
|
|
1009
|
+
return out
|
|
1010
|
+
}
|
|
1011
|
+
return Array.isArray(result) ? result.map(at) : at(result)
|
|
985
1012
|
})
|
|
986
1013
|
|
|
987
1014
|
// Flatten one level: a .bib file contributes its entries individually. Every other
|
|
@@ -1053,7 +1080,8 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
|
|
|
1053
1080
|
// since 2026-09-11, could select anything from it.
|
|
1054
1081
|
|
|
1055
1082
|
// ⭐ EACH RECORD'S BRIEF — what its list holds: the brief's fields at the top, beside the record's
|
|
1056
|
-
// own keys
|
|
1083
|
+
// own keys, what the build derives (`excerpt`, `image`), and the branch the list holds for `scope`
|
|
1084
|
+
// (`$branch`, which no answer carries). A record of a schema with no
|
|
1057
1085
|
// brief, or of one with a single section, is its brief as it is.
|
|
1058
1086
|
const briefOf = briefBuilder(config.dataSchema)
|
|
1059
1087
|
if (briefOf) {
|
|
@@ -1127,7 +1155,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
|
|
|
1127
1155
|
}
|
|
1128
1156
|
|
|
1129
1157
|
// ⭐ EVERY FILE IN `records/` IS A RECORD (ruled 2026-09-21 [Diego]), so every one
|
|
1130
|
-
// is compiled; `records/folder.yml` only says which folder each sits in (`
|
|
1158
|
+
// is compiled; `records/folder.yml` only says which folder each sits in (`$branch`),
|
|
1131
1159
|
// which is what a query's `scope` reads. ⛔ Until 2026-09-21 `records.yml`, at the
|
|
1132
1160
|
// site root, listed the records, and an unlisted file was left out of
|
|
1133
1161
|
// `/data/<name>.json` here.
|
|
@@ -1347,7 +1375,9 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
|
|
|
1347
1375
|
const handle = item.$name
|
|
1348
1376
|
if (handle === undefined || handle === null || handle === '') continue
|
|
1349
1377
|
const filename = `${handle}.json`
|
|
1350
|
-
|
|
1378
|
+
// The record's own file answers it whole, so it holds no branch (the list does, for `scope`).
|
|
1379
|
+
const { [BRANCH_KEY]: _branch, ...own } = item
|
|
1380
|
+
await writeFile(join(recordsDir, filename), JSON.stringify(item[WHOLE_RECORD] ?? own, null, 2))
|
|
1351
1381
|
written.add(filename)
|
|
1352
1382
|
}
|
|
1353
1383
|
const pruned = await pruneOrphanedRecords(dataDir, name, written)
|
|
@@ -153,7 +153,7 @@ export async function readRecordsConfig(siteRoot, { dir } = {}) {
|
|
|
153
153
|
*
|
|
154
154
|
* ⛔ ONE PLACEMENT PER RECORD. Two entries matching one file is a hard error, not
|
|
155
155
|
* a second placement. The wire could carry many-to-many — `folder.js` nests, and
|
|
156
|
-
* a placement is banked by the record it references — but a record's
|
|
156
|
+
* a placement is banked by the record it references — but a record's branch is one string:
|
|
157
157
|
* `@uniweb/core`'s `withinScope` matches nothing that is not a string, so a record
|
|
158
158
|
* with two paths would fall outside every `scope:`, silently. The records service
|
|
159
159
|
* evaluates `scope` natively, so widening it is a cross-lane change to agree first,
|
|
@@ -164,9 +164,25 @@ export async function readRecordsConfig(siteRoot, { dir } = {}) {
|
|
|
164
164
|
* @param {object} [opts]
|
|
165
165
|
* @param {string} [opts.dir] - the records directory as written, for messages
|
|
166
166
|
* @returns {{ nodes: Array, placements: Map, errors: string[], warnings: string[] }}
|
|
167
|
-
* `nodes` is the folder tree — the declared
|
|
168
|
-
* record no
|
|
169
|
-
* `{ entity, path, slug }`, `path` being `''` at the top of the folder.
|
|
167
|
+
* `nodes` is the folder tree — the declared entries in file order, then every
|
|
168
|
+
* record no entry names; `placements` maps EVERY record's id to its
|
|
169
|
+
* `{ entity, path, slug, tags?, label? }`, `path` being `''` at the top of the folder.
|
|
170
|
+
*
|
|
171
|
+
* ⭐ WHAT THE FOLDER SAYS ABOUT A RECORD — its entry's `tags` and `label` (ruled
|
|
172
|
+
* 2026-09-27 [Diego]). A record's entry is a path, or `{ path, tags?, label? }`:
|
|
173
|
+
*
|
|
174
|
+
* - folder: team
|
|
175
|
+
* records:
|
|
176
|
+
* - team/grace.md
|
|
177
|
+
* - path: team/ada.md
|
|
178
|
+
* tags: [staff, featured]
|
|
179
|
+
* label: Ada Lovelace
|
|
180
|
+
*
|
|
181
|
+
* They are the ENTRY's, not the record's — a backend keeps them on the folder entry and
|
|
182
|
+
* answers them beside the record as `$tags` and `$label`, and so does the static lane.
|
|
183
|
+
* Tags keep the order written. At the top level an entry is allowed only when it says
|
|
184
|
+
* one of them: a bare path there would claim to place a record the directory already
|
|
185
|
+
* placed.
|
|
170
186
|
*/
|
|
171
187
|
export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
172
188
|
// The file as the author sees it, for every message below.
|
|
@@ -187,7 +203,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
187
203
|
byRelPath.set(e.poolPath, e)
|
|
188
204
|
}
|
|
189
205
|
|
|
190
|
-
const place = (entity, pathSegs, where) => {
|
|
206
|
+
const place = (entity, pathSegs, where, said = null) => {
|
|
191
207
|
const key = entity.id
|
|
192
208
|
const prior = claimedBy.get(key)
|
|
193
209
|
if (prior) {
|
|
@@ -200,60 +216,94 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
200
216
|
claimedBy.set(key, where)
|
|
201
217
|
const slug = slugForEntity(entity)
|
|
202
218
|
const path = pathSegs.join('/')
|
|
203
|
-
placements.set(key, { entity, path, slug })
|
|
204
|
-
return { kind: 'ref', name: slug, $entityId: key }
|
|
219
|
+
placements.set(key, { entity, path, slug, ...said })
|
|
220
|
+
return { kind: 'ref', name: slug, $entityId: key, ...said }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The records one path names — a file, or a pattern's matches — placed where the entry sits.
|
|
224
|
+
const placePath = (raw, pathSegs, where, said = null) => {
|
|
225
|
+
const pattern = typeof raw === 'string' ? raw.trim() : ''
|
|
226
|
+
if (!pattern) {
|
|
227
|
+
errors.push(`${file}: ${where} ${said ? 'has an empty `path:`' : 'is an empty string'}.`)
|
|
228
|
+
return []
|
|
229
|
+
}
|
|
230
|
+
if (!isPattern(pattern)) {
|
|
231
|
+
const hit = byRelPath.get(pattern)
|
|
232
|
+
if (!hit) {
|
|
233
|
+
errors.push(
|
|
234
|
+
`${file}: ${where} names "${pattern}", which is not in ${dir}/. ` +
|
|
235
|
+
`A path is relative to ${dir}/, extension included.`
|
|
236
|
+
)
|
|
237
|
+
return []
|
|
238
|
+
}
|
|
239
|
+
const leaf = place(hit, pathSegs, where, said)
|
|
240
|
+
return leaf ? [leaf] : []
|
|
241
|
+
}
|
|
242
|
+
// ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
|
|
243
|
+
// empty-branch defect respelled, and it produced a real, reachable, empty
|
|
244
|
+
// path with no warning.
|
|
245
|
+
const matches = [...byRelPath.entries()]
|
|
246
|
+
.filter(([rel]) => matchEntityPattern(pattern, rel))
|
|
247
|
+
.map(([, e]) => e)
|
|
248
|
+
if (matches.length === 0) {
|
|
249
|
+
errors.push(
|
|
250
|
+
`${file}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
|
|
251
|
+
`Check the schema folder name and the extension.`
|
|
252
|
+
)
|
|
253
|
+
return []
|
|
254
|
+
}
|
|
255
|
+
// Matches sort alphanumerically by filename, numeric-aware — so `1-`, `2-`,
|
|
256
|
+
// `10-` order as written rather than as strings, and `2025-…` precedes
|
|
257
|
+
// `2026-…`. Ordering only: the number never leaves the name.
|
|
258
|
+
matches.sort((a, b) => compareByNumericPrefix(a.slug, b.slug))
|
|
259
|
+
return matches.map((e) => place(e, pathSegs, where, said)).filter(Boolean)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// An entry's `tags` and `label` — text, as YAML may type a tag or a label a number
|
|
263
|
+
// (`2024`). Tags keep the order written; an empty one is dropped. Null when one is
|
|
264
|
+
// not text (said).
|
|
265
|
+
const saidOf = (entry, where) => {
|
|
266
|
+
const said = {}
|
|
267
|
+
if (entry.tags !== undefined && entry.tags !== null) {
|
|
268
|
+
const tags = []
|
|
269
|
+
for (const tag of Array.isArray(entry.tags) ? entry.tags : [entry.tags]) {
|
|
270
|
+
if (typeof tag !== 'string' && typeof tag !== 'number') {
|
|
271
|
+
errors.push(`${file}: ${where} has a tag that is not text: ${JSON.stringify(tag)}. Write each tag as a word.`)
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
const text = String(tag).trim()
|
|
275
|
+
if (text) tags.push(text)
|
|
276
|
+
}
|
|
277
|
+
if (tags.length) said.tags = tags
|
|
278
|
+
}
|
|
279
|
+
if (entry.label !== undefined && entry.label !== null) {
|
|
280
|
+
if (typeof entry.label !== 'string' && typeof entry.label !== 'number') {
|
|
281
|
+
errors.push(`${file}: ${where} has a \`label:\` that is not text. A label is the entry's display text.`)
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
const label = String(entry.label).trim()
|
|
285
|
+
if (label) said.label = label
|
|
286
|
+
}
|
|
287
|
+
return said
|
|
205
288
|
}
|
|
206
289
|
|
|
207
290
|
const resolveEntry = (entry, pathSegs, index, trail) => {
|
|
208
291
|
const where = `entry ${trail}[${index}]`
|
|
209
292
|
|
|
210
293
|
if (typeof entry === 'string') {
|
|
211
|
-
const pattern = entry.trim()
|
|
212
294
|
// ⛔ A PATH AT THE TOP LEVEL SELECTED RECORDS until 2026-09-21 — it was how a
|
|
213
295
|
// file became one. Every file in the directory is a record now, so the line
|
|
214
296
|
// would claim to choose while choosing nothing: refused, with the reason.
|
|
215
297
|
if (pathSegs.length === 0) {
|
|
216
298
|
errors.push(
|
|
217
|
-
`${file}: ${where} ("${
|
|
218
|
-
`${dir}/ is a record already — ${file} only sorts records into folders
|
|
299
|
+
`${file}: ${where} ("${entry.trim()}") lists records at the top level. Every file in ` +
|
|
300
|
+
`${dir}/ is a record already — ${file} only sorts records into folders, and says ` +
|
|
301
|
+
`what a folder knows about a record (\`tags:\`, \`label:\`). ` +
|
|
219
302
|
`Remove the entry, or put it under a \`folder:\`.`
|
|
220
303
|
)
|
|
221
304
|
return []
|
|
222
305
|
}
|
|
223
|
-
|
|
224
|
-
errors.push(`${file}: ${where} is an empty string.`)
|
|
225
|
-
return []
|
|
226
|
-
}
|
|
227
|
-
if (!isPattern(pattern)) {
|
|
228
|
-
const hit = byRelPath.get(pattern)
|
|
229
|
-
if (!hit) {
|
|
230
|
-
errors.push(
|
|
231
|
-
`${file}: ${where} names "${pattern}", which is not in ${dir}/. ` +
|
|
232
|
-
`A path is relative to ${dir}/, extension included.`
|
|
233
|
-
)
|
|
234
|
-
return []
|
|
235
|
-
}
|
|
236
|
-
const leaf = place(hit, pathSegs, where)
|
|
237
|
-
return leaf ? [leaf] : []
|
|
238
|
-
}
|
|
239
|
-
// ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
|
|
240
|
-
// empty-branch defect respelled, and it produced a real, reachable, empty
|
|
241
|
-
// path with no warning.
|
|
242
|
-
const matches = [...byRelPath.entries()]
|
|
243
|
-
.filter(([rel]) => matchEntityPattern(pattern, rel))
|
|
244
|
-
.map(([, e]) => e)
|
|
245
|
-
if (matches.length === 0) {
|
|
246
|
-
errors.push(
|
|
247
|
-
`${file}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
|
|
248
|
-
`Check the schema folder name and the extension.`
|
|
249
|
-
)
|
|
250
|
-
return []
|
|
251
|
-
}
|
|
252
|
-
// Matches sort alphanumerically by filename, numeric-aware — so `1-`, `2-`,
|
|
253
|
-
// `10-` order as written rather than as strings, and `2025-…` precedes
|
|
254
|
-
// `2026-…`. Ordering only: the number never leaves the name.
|
|
255
|
-
matches.sort((a, b) => compareByNumericPrefix(a.slug, b.slug))
|
|
256
|
-
return matches.map((e) => place(e, pathSegs, where)).filter(Boolean)
|
|
306
|
+
return placePath(entry, pathSegs, where)
|
|
257
307
|
}
|
|
258
308
|
|
|
259
309
|
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
@@ -263,6 +313,30 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
263
313
|
return []
|
|
264
314
|
}
|
|
265
315
|
|
|
316
|
+
// ⭐ A RECORD'S ENTRY WITH WHAT THE FOLDER SAYS ABOUT IT — `{ path, tags?, label? }`.
|
|
317
|
+
if (entry.path !== undefined) {
|
|
318
|
+
const extra = Object.keys(entry).filter((k) => !['path', 'tags', 'label'].includes(k))
|
|
319
|
+
if (extra.length) {
|
|
320
|
+
errors.push(
|
|
321
|
+
`${file}: ${where} is a record's entry (\`path:\`), which takes \`tags:\` and \`label:\` — ` +
|
|
322
|
+
`not ${extra.map((k) => `\`${k}:\``).join(', ')}.` +
|
|
323
|
+
(extra.includes('folder') || extra.includes('records') ? ' A folder is its own entry, `folder:`.' : '')
|
|
324
|
+
)
|
|
325
|
+
return []
|
|
326
|
+
}
|
|
327
|
+
const said = saidOf(entry, where)
|
|
328
|
+
if (!said) return []
|
|
329
|
+
if (pathSegs.length === 0 && !said.tags && !said.label) {
|
|
330
|
+
errors.push(
|
|
331
|
+
`${file}: ${where} ("${String(entry.path).trim()}") lists a record at the top level and says ` +
|
|
332
|
+
`nothing about it. Every file in ${dir}/ sits at the top already — give it \`tags:\` or a ` +
|
|
333
|
+
`\`label:\`, or remove the entry.`
|
|
334
|
+
)
|
|
335
|
+
return []
|
|
336
|
+
}
|
|
337
|
+
return placePath(entry.path, pathSegs, where, said)
|
|
338
|
+
}
|
|
339
|
+
|
|
266
340
|
if (entry.folder !== undefined) {
|
|
267
341
|
// ⚠️ COERCED, because YAML types a bare `2024` as a NUMBER — and a folder
|
|
268
342
|
// named for a year is the single most likely one anybody writes. Both the
|
|
@@ -298,6 +372,13 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
298
372
|
folderAt.set(at, where)
|
|
299
373
|
const branch = { kind: 'branch', name: segment }
|
|
300
374
|
if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
|
|
375
|
+
// A folder takes `tags:` as a record's entry does — kept, sent and pulled back, though
|
|
376
|
+
// no question answers a folder.
|
|
377
|
+
if (entry.tags !== undefined && entry.tags !== null) {
|
|
378
|
+
const said = saidOf({ tags: entry.tags }, where)
|
|
379
|
+
if (!said) return []
|
|
380
|
+
if (said.tags) branch.tags = said.tags
|
|
381
|
+
}
|
|
301
382
|
const kids = Array.isArray(entry.records) ? entry.records : []
|
|
302
383
|
if (kids.length === 0) {
|
|
303
384
|
warnings.push(
|
|
@@ -328,8 +409,8 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
|
|
|
328
409
|
errors.push(
|
|
329
410
|
`${file}: ${where} has no recognized kind. ` +
|
|
330
411
|
(pathSegs.length
|
|
331
|
-
? `Inside a folder, a path names records
|
|
332
|
-
: `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}
|
|
412
|
+
? `Inside a folder, a path names records — or \`path:\` with its \`tags:\` and \`label:\`; anything else says \`folder:\`.`
|
|
413
|
+
: `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}/ — or a record's \`path:\` with its \`tags:\` or \`label:\`.`)
|
|
333
414
|
)
|
|
334
415
|
return []
|
|
335
416
|
}
|
package/src/uwx/folder.js
CHANGED
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// - `contents` is the self-nesting tree (an array), nesting via `$children` — the
|
|
8
8
|
// same mechanism site-content pages/sections use. Each node holds REFERENCES,
|
|
9
9
|
// never content:
|
|
10
|
-
// - a LEAF references one record entity: `{ kind: 'ref', name, entry }` —
|
|
10
|
+
// - a LEAF references one record entity: `{ kind: 'ref', name, entry, label?, tags? }` —
|
|
11
11
|
// `entry: { schema, entity: <uuid> }` once the record was minted (back-filled
|
|
12
12
|
// into its file), or `entry: { $ref: "<$id>" }` while brand-new (resolved
|
|
13
|
-
// within this payload)
|
|
14
|
-
//
|
|
13
|
+
// within this payload); `label` (a localized map) and `tags` are what
|
|
14
|
+
// `records/folder.yml` says about the record, since 2026-09-27.
|
|
15
|
+
// - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, tags?, $children }`.
|
|
15
16
|
//
|
|
16
17
|
// ⭐ A LEAF'S `name` IS ITS RECORD'S HANDLE — the slug, the records service's `$name` —
|
|
17
18
|
// and a branch's `name` is the segment a query's `scope:` names. `label` is a branch's
|
|
@@ -99,6 +100,7 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
99
100
|
// map, like every localized scalar this producer sends — keyed by the
|
|
100
101
|
// site's source locale.
|
|
101
102
|
if (node.label !== undefined) branch.label = { [sourceLocale]: String(node.label) }
|
|
103
|
+
if (Array.isArray(node.tags) && node.tags.length) branch.tags = [...node.tags]
|
|
102
104
|
branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale)
|
|
103
105
|
out.push(branch)
|
|
104
106
|
continue
|
|
@@ -108,7 +110,13 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
108
110
|
missing.push(node.$entityId)
|
|
109
111
|
continue
|
|
110
112
|
}
|
|
111
|
-
|
|
113
|
+
const leaf = refLeaf(entity)
|
|
114
|
+
// ⭐ What the folder says about the record (`records/folder.yml`, ruled 2026-09-27
|
|
115
|
+
// [Diego]): its display text, localized as a branch's is, and its tags, in the order
|
|
116
|
+
// written. A records service answers them beside the record as `$label` and `$tags`.
|
|
117
|
+
if (node.label !== undefined) leaf.label = { [sourceLocale]: String(node.label) }
|
|
118
|
+
if (Array.isArray(node.tags) && node.tags.length) leaf.tags = [...node.tags]
|
|
119
|
+
out.push(leaf)
|
|
112
120
|
}
|
|
113
121
|
return out
|
|
114
122
|
}
|
|
@@ -493,7 +493,9 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
493
493
|
* there — so `folder.yml` carries the branches and the records placed in them, and
|
|
494
494
|
* a folder with no branches is a site with no `folder.yml`: a local one is REMOVED
|
|
495
495
|
* (it could only describe sub-folders the backend's folder no longer has). No state
|
|
496
|
-
* of the file removes a record, so this cannot empty anything.
|
|
496
|
+
* of the file removes a record, so this cannot empty anything. ⭐ The one exception,
|
|
497
|
+
* since 2026-09-27: a record whose entry has `tags` or a `label` is written wherever it
|
|
498
|
+
* sits, as `{ path, tags?, label? }`, since only `folder.yml` can say them.
|
|
497
499
|
*
|
|
498
500
|
* ⛔ A PULL THAT CARRIED NO FOLDER, or one with a placed record that was not written
|
|
499
501
|
* locally, leaves the file alone: it has nothing true to write.
|
|
@@ -524,22 +526,34 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
524
526
|
// does not caption its rows. On the wire the label is a localized map;
|
|
525
527
|
// folder.yml carries the source-locale string (a bare string passes).
|
|
526
528
|
if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
|
|
529
|
+
if (Array.isArray(node.tags) && node.tags.length) entry.tags = node.tags.map(String)
|
|
527
530
|
entry.records = walk(node.$children, true)
|
|
528
531
|
out.push(entry)
|
|
529
532
|
continue
|
|
530
533
|
}
|
|
531
534
|
const uuid = node.entry?.entity ?? node.entry
|
|
532
535
|
const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
+
// ⭐ What the folder says about the record — its entry's `tags` and `label` — comes
|
|
537
|
+
// back as the entry `{ path, tags?, label? }` (ruled 2026-09-27 [Diego]).
|
|
538
|
+
const said = {}
|
|
539
|
+
if (Array.isArray(node.tags) && node.tags.length) said.tags = node.tags.map(String)
|
|
540
|
+
if (node.label !== undefined && node.label !== null) {
|
|
541
|
+
const label = unwrapLocalized(node.label, sourceLocale)
|
|
542
|
+
if (typeof label === 'string' && label) said.label = label
|
|
543
|
+
}
|
|
544
|
+
const says = Object.keys(said).length > 0
|
|
545
|
+
// A record at the top of the folder needs no line unless the folder says something
|
|
546
|
+
// about it — but one that did not land is still said: the next push sends the folder
|
|
547
|
+
// the directory holds, which would not have it.
|
|
536
548
|
if (!inBranch) {
|
|
537
549
|
if (!rel) {
|
|
538
550
|
warnings.push(
|
|
539
551
|
`the folder holds a record ("${node.name ?? '?'}") that was not written locally — ` +
|
|
540
552
|
`a push from here would remove it from the folder.`
|
|
541
553
|
)
|
|
554
|
+
continue
|
|
542
555
|
}
|
|
556
|
+
if (says && !out.some((e) => e?.path === rel)) out.push({ path: rel, ...said })
|
|
543
557
|
continue
|
|
544
558
|
}
|
|
545
559
|
if (!rel) {
|
|
@@ -554,7 +568,8 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
554
568
|
continue
|
|
555
569
|
}
|
|
556
570
|
// A list file holds several records and is one path — listed once.
|
|
557
|
-
if (
|
|
571
|
+
if (out.some((e) => e === rel || e?.path === rel)) continue
|
|
572
|
+
out.push(says ? { path: rel, ...said } : rel)
|
|
558
573
|
}
|
|
559
574
|
return out
|
|
560
575
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -729,8 +729,13 @@ function placeProducedRecords(nodes, producedBy) {
|
|
|
729
729
|
out.push({ ...node, $children: placeProducedRecords(node.$children, producedBy) })
|
|
730
730
|
continue
|
|
731
731
|
}
|
|
732
|
+
// What the folder says about the record — its entry's `tags` and `label` — goes with
|
|
733
|
+
// every record the file produced.
|
|
734
|
+
const said = {}
|
|
735
|
+
if (node?.tags) said.tags = node.tags
|
|
736
|
+
if (node?.label !== undefined) said.label = node.label
|
|
732
737
|
for (const r of producedBy.get(node?.$entityId) || []) {
|
|
733
|
-
out.push({ kind: 'ref', name: r.slug, $entityId: r.id })
|
|
738
|
+
out.push({ kind: 'ref', name: r.slug, $entityId: r.id, ...said })
|
|
734
739
|
}
|
|
735
740
|
}
|
|
736
741
|
return out
|