@uniweb/build 0.38.0 → 0.39.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/content-reader": "^1.2.4",
61
- "@uniweb/projections": "^0.5.8",
62
- "@uniweb/schemas": "^0.2.13",
63
60
  "@uniweb/content-writer": "^0.3.4",
64
61
  "@uniweb/semantic-parser": "^1.4.0",
65
- "@uniweb/theming": "^0.1.15"
62
+ "@uniweb/theming": "^0.1.15",
63
+ "@uniweb/content-reader": "^1.2.4",
64
+ "@uniweb/projections": "^0.5.9",
65
+ "@uniweb/schemas": "^0.2.13"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.15.0"
68
+ "@uniweb/runtime": "^0.16.0"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
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.20.0"
77
+ "@uniweb/core": "^0.21.0"
78
78
  },
79
79
  "peerDependenciesMeta": {
80
80
  "vite": {
@@ -143,7 +143,7 @@ export async function readRecordsConfig(siteRoot) {
143
143
  *
144
144
  * ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
145
145
  * a second placement. The wire could carry many-to-many — `folder.js` nests, and
146
- * placements are keyed by their `path_segment` chain — but `core/src/where.js`'s
146
+ * placements are keyed by their `name` chain — but `core/src/where.js`'s
147
147
  * `matchUnder` is STRING-ONLY, so a record with two paths would match nothing
148
148
  * under `where: { path: { under: … } }`, silently. Widening `under` is a
149
149
  * predicate the backend also evaluates natively, so it is a cross-lane change to
@@ -184,7 +184,7 @@ export function resolveFolder(entries, pool) {
184
184
  const slug = slugForEntity(entity)
185
185
  const path = pathSegs.join('/')
186
186
  placements.set(key, { entity, path, slug })
187
- return { kind: 'ref', path_segment: slug, $entityId: key }
187
+ return { kind: 'ref', name: slug, $entityId: key }
188
188
  }
189
189
 
190
190
  const resolveEntry = (entry, pathSegs, index, trail) => {
@@ -246,8 +246,13 @@ export function resolveFolder(entries, pool) {
246
246
  errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
247
247
  return []
248
248
  }
249
- const branch = { kind: 'branch', path_segment: segment }
250
- if (entry.label !== undefined && entry.label !== null) branch.name = String(entry.label)
249
+ // `name` is the handle (the URL segment, sibling-unique); `label` is the
250
+ // display text. The store renamed the pair on 2026-09-04 `path_segment` →
251
+ // `name`, and the old `name` (display) → `label` — so one word means one
252
+ // thing from records.yml (`folder:` / `label:`) to the wire to the door's
253
+ // `$name`.
254
+ const branch = { kind: 'branch', name: segment }
255
+ if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
251
256
  const kids = Array.isArray(entry.records) ? entry.records : []
252
257
  if (kids.length === 0) {
253
258
  warnings.push(
package/src/uwx/folder.js CHANGED
@@ -7,10 +7,18 @@
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', path_segment, ... }`
11
- // with `entry: <uuid>` once the record was minted (back-filled into its file),
10
+ // - a LEAF references one record entity: `{ kind: 'ref', name, ... }` with
11
+ // `entry: <uuid>` once the record was minted (back-filled into its file),
12
12
  // or `$ref: "<id>"` while brand-new (resolved within this payload).
13
- // - a BRANCH is a sub-folder: `{ kind: 'branch', path_segment, name?, $children }`.
13
+ // - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, $children }`.
14
+ //
15
+ // ⭐ `name` IS THE HANDLE — the URL segment, sibling-unique, the door's `$name` —
16
+ // and `label` is the display text, a localized map (`{ en: "Blog" }`). The store
17
+ // renamed the pair on 2026-09-04 (`path_segment` → `name`; the old display `name`
18
+ // → `label`); this emitter writes the new shape only and the pull reader
19
+ // (`records-project.js`) reads the new shape only. No alias on either side: the
20
+ // old key's PRESENCE was the version signal, and there is no population to
21
+ // carry.
14
22
  //
15
23
  // ⭐ THE ORGANIZATION IS AUTHORED, IN `records.yml`, AND IT IS THE ONLY SOURCE.
16
24
  // It used to be DERIVED — one branch per collection, mirroring the `collections/`
@@ -42,7 +50,7 @@ export const FOLDER_ENTITY_KEY = '@folder'
42
50
  // it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
43
51
  // resolution (a registry data-schema read) as a follow-up.
44
52
  function refLeaf(entity) {
45
- const leaf = { kind: 'ref', path_segment: entity.slug }
53
+ const leaf = { kind: 'ref', name: entity.slug }
46
54
  if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
47
55
  else leaf.$ref = entity.id // the payload-local handle
48
56
  return leaf
@@ -60,13 +68,16 @@ function refLeaf(entity) {
60
68
  * @param {Map<string, object>} byEntityId - record entities, keyed by pool id
61
69
  * @param {string[]} missing - collects ids that resolved to no entity
62
70
  */
63
- function contentsFromNodes(nodes, byEntityId, missing) {
71
+ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
64
72
  const out = []
65
73
  for (const node of nodes || []) {
66
74
  if (node.kind === 'branch') {
67
- const branch = { kind: 'branch', path_segment: node.path_segment }
68
- if (node.name !== undefined) branch.name = node.name
69
- branch.$children = contentsFromNodes(node.$children, byEntityId, missing)
75
+ const branch = { kind: 'branch', name: node.name }
76
+ // The display text is a LOCALIZED field on the wire — a `{ locale: value }`
77
+ // map, like every localized scalar this producer sends — keyed by the
78
+ // site's source locale.
79
+ if (node.label !== undefined) branch.label = { [sourceLocale]: String(node.label) }
80
+ branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale)
70
81
  out.push(branch)
71
82
  continue
72
83
  }
@@ -82,7 +93,7 @@ function contentsFromNodes(nodes, byEntityId, missing) {
82
93
 
83
94
  /**
84
95
  * Walk a folder document's `contents` tree, visiting every item with the
85
- * slash-joined `path_segment` chain that addresses it.
96
+ * slash-joined `name` chain that addresses it.
86
97
  *
87
98
  * ⛔ IT MUST RECURSE INTO `$children`. `contents` is SELF-NESTING: a walk of the
88
99
  * top level sees the branches and misses every record under them — which is 6 of
@@ -92,7 +103,7 @@ function contentsFromNodes(nodes, byEntityId, missing) {
92
103
  function walkFolderItems(contents, cb, prefix = '') {
93
104
  for (const item of contents || []) {
94
105
  if (!item || typeof item !== 'object') continue
95
- const seg = typeof item.path_segment === 'string' ? item.path_segment : null
106
+ const seg = typeof item.name === 'string' ? item.name : null
96
107
  const path = seg ? (prefix ? `${prefix}/${seg}` : seg) : prefix
97
108
  if (seg) cb(path, item)
98
109
  walkFolderItems(item.$children, cb, path)
@@ -102,8 +113,8 @@ function walkFolderItems(contents, cb, prefix = '') {
102
113
  /**
103
114
  * Harvest per-item identity from the folder document the backend returns.
104
115
  *
105
- * ⭐ THE KEY IS THE `path_segment` CHAIN, and it is the right one because the
106
- * backend's own model declares `path_segment` SIBLING-UNIQUE — so the chain is
116
+ * ⭐ THE KEY IS THE `name` CHAIN, and it is the right one because the backend's
117
+ * own model declares `name` SIBLING-UNIQUE — so the chain is
107
118
  * unique within the folder, stable across pushes, and derivable identically on
108
119
  * both sides without either lane holding the other's ids.
109
120
  *
@@ -163,9 +174,11 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
163
174
  * @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
164
175
  * from the folder document a previous push returned. Absent on a first
165
176
  * push, where every item is genuinely new.
177
+ * @param {string} [params.sourceLocale='en'] - the locale a branch `label` is
178
+ * keyed under on the wire
166
179
  * @returns {{ id, uuid, model, file, document, warnings }|null}
167
180
  */
168
- export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null }) {
181
+ export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null, sourceLocale = 'en' }) {
169
182
  // ⛔ `missing` AND `empty` ARE DIFFERENT, AND THE ASYMMETRY IS DELIBERATE.
170
183
  //
171
184
  // no records.yml → null. INERT: nothing is sent, and the server's
@@ -186,7 +199,7 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
186
199
  for (const e of recordEntities || []) byEntityId.set(e.id, e)
187
200
 
188
201
  const missing = []
189
- const contents = contentsFromNodes(folderNodes, byEntityId, missing)
202
+ const contents = contentsFromNodes(folderNodes, byEntityId, missing, sourceLocale)
190
203
  // ⚠️ `id` IS THE ENTITY'S POOL PATH, NOT A FOLDER PATH — say so, because the two
191
204
  // read identically and a reader who takes it for a placement concludes the
192
205
  // emitter is dropping a branch it never had. *(Measured 2026-08-31: the backend
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Identity & placement. A record's on-disk home is `(collection, slug)`:
8
8
  // - `slug` and `collection` come from the FOLDER document — each ref leaf is
9
- // `{ entry: { model, entity: <uuid> }, path_segment: <slug> }` inside a branch
10
- // (its `$children`) whose `path_segment` names the folder it was placed in.
9
+ // `{ entry: { model, entity: <uuid> }, name: <slug> }` inside a branch
10
+ // (its `$children`) whose `name` names the folder it was placed in.
11
11
  // The folder is the authoritative organization on a read (the record
12
12
  // document's own `$id` envelope is not guaranteed to be echoed back), with
13
13
  // the record document's `$id` (its pool identity) as a fallback when present.
@@ -40,6 +40,7 @@ import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './proje
40
40
  import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
41
41
  import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
42
42
  import { isContentBodyField } from './data-schema.js'
43
+ import { unwrapLocalized } from './backfill.js'
43
44
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
44
45
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
45
46
 
@@ -111,9 +112,13 @@ function briefHasContentBody(declaration) {
111
112
 
112
113
  // Build `uuid → { collection, slug }` from the folder document's ref leaves. The
113
114
  // folder is a self-nesting tree under `contents`, nesting via `$children` (the
114
- // site-content invariant — folder.js). A leaf sits in a branch whose
115
- // `path_segment` is the collection; the leaf's `path_segment` is the slug and its
116
- // `entry` is the entity_ref open form `{ model, entity: <uuid> }`. Nested branches
115
+ // site-content invariant — folder.js). A leaf sits in a branch whose `name` is
116
+ // the collection; the leaf's `name` is the slug (the handle) and its `entry` is
117
+ // the entity_ref open form `{ model, entity: <uuid> }`. `path_segment` is not
118
+ // read: the store renamed it on 2026-09-04 and a pull emits the new shape only —
119
+ // a reader that kept the old key would index every record as
120
+ // `{ folderPath: null, slug: undefined }` and rewrite records.yml with
121
+ // `folder: undefined` branches (measured on this reader, 2026-09-04). Nested branches
117
122
  // are walked; the collection is the NEAREST enclosing branch segment (correct for
118
123
  // the default one-branch-per-collection org; a deeply nested virtual org may
119
124
  // differ — see the module header).
@@ -122,11 +127,11 @@ function indexFolder(folderDoc) {
122
127
  const walk = (nodes, folderPath) => {
123
128
  for (const node of nodes || []) {
124
129
  if (node?.kind === 'branch') {
125
- walk(node.$children, node.path_segment ?? folderPath)
130
+ walk(node.$children, node.name ?? folderPath)
126
131
  } else if (node?.kind === 'ref' && node.entry) {
127
132
  // `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
128
133
  const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
129
- if (uuid) byUuid.set(uuid, { folderPath, slug: node.path_segment })
134
+ if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
130
135
  }
131
136
  }
132
137
  }
@@ -381,7 +386,7 @@ export function declarationsToQueriesYml({ document, siteRoot }) {
381
386
  * each record landed with.
382
387
  * @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
383
388
  */
384
- export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
389
+ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
385
390
  const warnings = []
386
391
 
387
392
  const walk = (nodes) => {
@@ -389,10 +394,11 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
389
394
  for (const node of nodes || []) {
390
395
  if (!node || typeof node !== 'object') continue
391
396
  if (node.kind === 'branch') {
392
- const entry = { folder: node.path_segment }
397
+ const entry = { folder: node.name }
393
398
  // Only a BRANCH takes a label. A record carries its own title; the folder
394
- // does not caption its rows.
395
- if (node.name !== undefined) entry.label = node.name
399
+ // does not caption its rows. On the wire the label is a localized map;
400
+ // records.yml carries the source-locale string (a bare string passes).
401
+ if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
396
402
  entry.records = walk(node.$children)
397
403
  out.push(entry)
398
404
  continue
@@ -404,7 +410,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
404
410
  // means the folder and the pool disagree, and writing the file without it
405
411
  // would quietly unpublish that record on the next push.
406
412
  warnings.push(
407
- `records.yml: a folder leaf ("${node.path_segment ?? '?'}") references a record that ` +
413
+ `records.yml: a folder leaf ("${node.name ?? '?'}") references a record that ` +
408
414
  `was not written locally — the file was left unchanged rather than dropping it.`
409
415
  )
410
416
  return null
@@ -523,7 +529,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
523
529
  //
524
530
  // The folder ENTITY still carries no `$uuid` we persist: the backend owns the
525
531
  // site's folder, keyed by the site-content uuid.
526
- const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid })
532
+ const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
527
533
  warnings.push(...records.warnings)
528
534
 
529
535
  // Flush localized record-field translations to locales/records/{locale}.json,
@@ -104,14 +104,14 @@ function stripSigils(value) {
104
104
  //
105
105
  // ⭐ Neither encoding is content. What the folder SAYS is "this branch contains
106
106
  // this record, here, in this order" — and that is already hashed: a leaf carries
107
- // `path_segment` (the record's slug) inside a branch carrying the collection's.
107
+ // `name` (the record's handle) inside a branch carrying the collection's.
108
108
  // A `folders:` branch's entries are COLLECTION names, so every leaf under one
109
109
  // comes from a single collection, where a slug is unique. Position plus segment
110
110
  // therefore identify the record on their own; `$ref` adds a payload-local handle
111
111
  // and `entry` adds identity, and both are exactly what `$uuid` is stripped for.
112
112
  //
113
113
  // ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
114
- // is: point a leaf at a different record and its `path_segment` moves with it.
114
+ // is: point a leaf at a different record and its `name` moves with it.
115
115
  const isFolderRefLeaf = value.kind === 'ref'
116
116
  const out = {}
117
117
  for (const [k, v] of Object.entries(value)) {
@@ -287,7 +287,7 @@ export function recordsToEntities({
287
287
  // The qualification is a CONSTRAINT, not a style: the sync response is keyed per
288
288
  // (`$model`, `$id`), so a bare slug would collide whenever two queries over the
289
289
  // same Model reuse one (see the duplicate check below). ⇒ Do not describe this
290
- // value as "the slug" — the folder leaf's `path_segment` is the bare segment, and
290
+ // value as "the slug" — the folder leaf's `name` is the bare segment, and
291
291
  // conflating the two has already misdirected a naming decision.
292
292
  const id = record.$id || `${queryName}/${slug}`
293
293
  const uuid = record.$uuid || null
@@ -276,6 +276,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
276
276
  // are complete — new records by `$ref`, already-minted ones by `entry: <uuid>`.
277
277
  const folder = buildFolderEntity({
278
278
  recordEntities: col.entities,
279
+ ...(sourceLocale ? { sourceLocale } : {}),
279
280
  // ⭐ AUTHORED, from `records.yml`. It used to be derived — one branch per
280
281
  // collection — which made the folder a shadow of a directory layout rather
281
282
  // than something the author states.