@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.65.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.5",
62
- "@uniweb/theming": "^0.1.15",
62
+ "@uniweb/projections": "^0.7.6",
63
63
  "@uniweb/content-writer": "^0.3.4",
64
- "@uniweb/content-reader": "^1.2.5",
65
- "@uniweb/semantic-parser": "^1.4.1"
64
+ "@uniweb/semantic-parser": "^1.4.1",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.7"
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.31.0",
78
- "@uniweb/runtime": "^0.27.7"
77
+ "@uniweb/core": "^0.32.0",
78
+ "@uniweb/runtime": "^0.27.8"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -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`), and a record's top-level `slug` and `path`
365
- * are its handle and its placement in `folder.yml`. Until 2026-09-14 they became
366
- * translation units, and a translation whose source happened to equal one rewrote it,
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 && (key === 'slug' || key === 'path'))
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. Under `[...path]` the
414
- // record's URL is its placement (the folder `folder.yml` put it in, carried
415
- // as `path`) plus its handle — the split rule in reverse. ⛔ A FILE PATH, so
416
- // decoded: the server decodes the request before looking the file up.
417
- const capture = catchAll ? joinPathCapture({ dir: item.path, slug: paramValue }) : null
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
@@ -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's placement (`path`), `where`,
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 a `deferred:` query never wrote,
76
- // and the step reports the outcome so the caller can see it rather than crash a build.
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 gives a collection an internal structure, and it is
764
- * what the `path` field and the `under` predicate address. Before this walk the
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.path ? `${item.path}/` : ''
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', 'path', 'excerpt', 'image', 'draft'].includes(key)) out[key] = value
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
- // ⭐ `path` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
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 path = pooled[i] ? (config.placements?.get(pooled[i].id)?.path ?? '') : ''
983
- if (Array.isArray(result)) return result.map((item) => item && { ...item, path })
984
- return result && { ...result, path }
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 and what the build derives (`excerpt`, `image`, `path`). A record of a schema with no
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 (`path`),
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
- await writeFile(join(recordsDir, filename), JSON.stringify(item[WHOLE_RECORD] ?? item, null, 2))
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 `path` is one string:
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 sub-folders in file order, then every
168
- * record no sub-folder names; `placements` maps EVERY record's id to its
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} ("${pattern}") lists records at the top level. Every file in ` +
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
- if (!pattern) {
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; anything else says \`folder:\`.`
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
- // - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, $children }`.
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
- out.push(refLeaf(entity))
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
- // A record at the top of the folder needs no line — but one that did not land
534
- // is still said: the next push sends the folder the directory holds, which
535
- // would not have it.
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 (!out.includes(rel)) out.push(rel)
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
  }
@@ -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