@uniweb/build 0.66.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.66.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,12 +57,12 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-writer": "^0.3.4",
61
60
  "@uniweb/content-reader": "^1.2.5",
62
61
  "@uniweb/schemas": "^0.8.0",
63
62
  "@uniweb/projections": "^0.7.6",
64
- "@uniweb/theming": "^0.1.15",
65
- "@uniweb/semantic-parser": "^1.4.1"
63
+ "@uniweb/content-writer": "^0.3.4",
64
+ "@uniweb/semantic-parser": "^1.4.1",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/runtime": "^0.27.8"
@@ -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/runtime": "^0.27.8",
78
- "@uniweb/core": "^0.32.0"
77
+ "@uniweb/core": "^0.32.0",
78
+ "@uniweb/runtime": "^0.27.8"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -932,6 +932,16 @@ function pickBrief(record, keys) {
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) {
@@ -985,10 +995,20 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
985
995
  //
986
996
  // ⚠️ It stays a SCALAR. `@uniweb/core`'s `withinScope` matches strings only, so
987
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.
988
1002
  items = items.map((result, i) => {
989
- const branch = pooled[i] ? (config.placements?.get(pooled[i].id)?.path ?? '') : ''
990
- if (Array.isArray(result)) return result.map((item) => item && { ...item, [BRANCH_KEY]: branch })
991
- return result && { ...result, [BRANCH_KEY]: branch }
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)
992
1012
  })
993
1013
 
994
1014
  // Flatten one level: a .bib file contributes its entries individually. Every other
@@ -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