@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 +7 -7
- package/src/site/records-config.js +9 -4
- package/src/uwx/folder.js +27 -14
- package/src/uwx/records-project.js +19 -13
- package/src/uwx/records.js +3 -3
- package/src/uwx/sync-package.js +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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 `
|
|
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',
|
|
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
|
-
|
|
250
|
-
|
|
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',
|
|
11
|
-
//
|
|
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',
|
|
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',
|
|
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',
|
|
68
|
-
|
|
69
|
-
|
|
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 `
|
|
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.
|
|
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 `
|
|
106
|
-
*
|
|
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> },
|
|
10
|
-
// (its `$children`) whose `
|
|
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
|
-
//
|
|
116
|
-
//
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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,
|
package/src/uwx/records.js
CHANGED
|
@@ -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
|
-
// `
|
|
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 `
|
|
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 `
|
|
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
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -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.
|