@uniweb/build 0.14.18 → 0.14.20
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 +4 -4
- package/src/hosts/s3-cloudfront.js +2 -2
- package/src/i18n/extract.js +54 -75
- package/src/i18n/merge.js +68 -85
- package/src/resolve-data-schema.js +70 -24
- package/src/site/config.js +20 -40
- package/src/uwx/data-schema.js +14 -9
- package/src/uwx/locale-sync.js +68 -22
- package/src/uwx/registry-package.js +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.20",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,13 +59,13 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.33.2",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/content-writer": "0.2.
|
|
62
|
+
"@uniweb/content-writer": "0.2.6",
|
|
63
63
|
"@uniweb/theming": "0.1.4"
|
|
64
64
|
},
|
|
65
65
|
"optionalDependencies": {
|
|
66
|
+
"@uniweb/content-reader": "1.1.12",
|
|
66
67
|
"@uniweb/runtime": "0.8.20",
|
|
67
|
-
"@uniweb/schemas": "0.2.
|
|
68
|
-
"@uniweb/content-reader": "1.1.12"
|
|
68
|
+
"@uniweb/schemas": "0.2.4"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,8 +74,8 @@ const REDIRECTS_FILE = '_redirects'
|
|
|
74
74
|
* left it to be loaded by URL at runtime (linked).
|
|
75
75
|
*
|
|
76
76
|
* The user's site.yml `foundation:` declaration is the only thing that
|
|
77
|
-
* controls this — `uniweb build` never
|
|
78
|
-
*
|
|
77
|
+
* controls this — `uniweb build` never registers or ships foundation code,
|
|
78
|
+
* so the rule is purely the shape of the `foundation:` ref:
|
|
79
79
|
* - registry ref ('@ns/name@ver'), https:// URL, or { url } object → linked
|
|
80
80
|
* - everything else (file: ref, workspace path) → standalone
|
|
81
81
|
*/
|
package/src/i18n/extract.js
CHANGED
|
@@ -163,11 +163,10 @@ function extractFromProseMirrorDoc(doc, context, units) {
|
|
|
163
163
|
|
|
164
164
|
let headingIndex = { h1: 0, h2: 0, h3: 0, h4: 0 }
|
|
165
165
|
let paragraphIndex = 0
|
|
166
|
-
let linkIndex = 0
|
|
167
166
|
|
|
168
167
|
for (const node of doc.content) {
|
|
169
168
|
if (node.type === 'heading') {
|
|
170
|
-
const text =
|
|
169
|
+
const text = elementText(node)
|
|
171
170
|
if (!text) continue
|
|
172
171
|
|
|
173
172
|
const level = node.attrs?.level || 1
|
|
@@ -176,14 +175,13 @@ function extractFromProseMirrorDoc(doc, context, units) {
|
|
|
176
175
|
|
|
177
176
|
addUnit(units, text, field, context)
|
|
178
177
|
} else if (node.type === 'paragraph') {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
if (plainText && plainText.length > 0) {
|
|
178
|
+
// Whole-element keying: ONE unit per paragraph, with link text kept INLINE
|
|
179
|
+
// (not split into a separate link.label unit). The conformance gate is
|
|
180
|
+
// tests/i18n/structural-keying-vectors.json (vectors A–H).
|
|
181
|
+
const text = elementText(node)
|
|
182
|
+
if (text) {
|
|
185
183
|
const field = paragraphIndex === 0 ? 'paragraph' : `paragraph.${paragraphIndex}`
|
|
186
|
-
addUnit(units,
|
|
184
|
+
addUnit(units, text, field, context)
|
|
187
185
|
paragraphIndex++
|
|
188
186
|
}
|
|
189
187
|
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
|
@@ -205,47 +203,8 @@ function getHeadingField(level, index) {
|
|
|
205
203
|
}
|
|
206
204
|
|
|
207
205
|
/**
|
|
208
|
-
* Extract from
|
|
209
|
-
|
|
210
|
-
function extractFromParagraph(node, context, units, linkIndex) {
|
|
211
|
-
if (!node.content) return { linkIndex }
|
|
212
|
-
|
|
213
|
-
for (const child of node.content) {
|
|
214
|
-
if (child.type === 'text' && child.marks) {
|
|
215
|
-
const linkMark = child.marks.find(m => m.type === 'link')
|
|
216
|
-
if (linkMark && child.text) {
|
|
217
|
-
const field = linkIndex === 0 ? 'link.label' : `link.${linkIndex}.label`
|
|
218
|
-
addUnit(units, child.text, field, context)
|
|
219
|
-
linkIndex++
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
return { linkIndex }
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Extract plain text from paragraph, excluding link text
|
|
229
|
-
*/
|
|
230
|
-
function extractPlainTextFromParagraph(node) {
|
|
231
|
-
if (!node.content) return ''
|
|
232
|
-
|
|
233
|
-
const parts = []
|
|
234
|
-
for (const child of node.content) {
|
|
235
|
-
if (child.type === 'text') {
|
|
236
|
-
// Skip if it's a link
|
|
237
|
-
const isLink = child.marks?.some(m => m.type === 'link')
|
|
238
|
-
if (!isLink && child.text && child.text.trim()) {
|
|
239
|
-
parts.push(child.text)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return parts.join('').trim()
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Extract from list items
|
|
206
|
+
* Extract from list items — one whole-element unit per list item (link text
|
|
207
|
+
* stays inline, same rule as paragraphs; vectors G and H).
|
|
249
208
|
*/
|
|
250
209
|
function extractFromList(listNode, context, units) {
|
|
251
210
|
if (!listNode.content) return
|
|
@@ -254,7 +213,7 @@ function extractFromList(listNode, context, units) {
|
|
|
254
213
|
if (listItem.type === 'listItem' && listItem.content) {
|
|
255
214
|
for (const child of listItem.content) {
|
|
256
215
|
if (child.type === 'paragraph') {
|
|
257
|
-
const text =
|
|
216
|
+
const text = elementText(child)
|
|
258
217
|
if (text) {
|
|
259
218
|
addUnit(units, text, `list.${index}`, context)
|
|
260
219
|
}
|
|
@@ -265,36 +224,56 @@ function extractFromList(listNode, context, units) {
|
|
|
265
224
|
}
|
|
266
225
|
|
|
267
226
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* (
|
|
271
|
-
* `<N
|
|
227
|
+
* A block element's cleaned source text — the WHOLE-ELEMENT translation key.
|
|
228
|
+
* ALL inline marks (bold, italic, link, span, …) flatten into the text: link
|
|
229
|
+
* text stays INLINE (not split out), spans contribute their plain text (no
|
|
230
|
+
* `<N>` tags in the key). Inline atom nodes (image/icon/emoji/math/hardBreak)
|
|
231
|
+
* are skipped. Leading/trailing whitespace is trimmed; internal whitespace is
|
|
232
|
+
* preserved (the hash collapses it for matching — see hash.js normalizeText).
|
|
272
233
|
*/
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
// Check for mixed inline marks (span marks from `[text]{class}` syntax)
|
|
278
|
-
const hasInlineMarks = textChildren.length > 1 &&
|
|
279
|
-
textChildren.some(n => n.marks?.some(m => m.type === 'span'))
|
|
234
|
+
export function elementText(node) {
|
|
235
|
+
return collectInlineText(node).trim()
|
|
236
|
+
}
|
|
280
237
|
|
|
281
|
-
|
|
282
|
-
|
|
238
|
+
function collectInlineText(node) {
|
|
239
|
+
if (!node || !node.content) return ''
|
|
240
|
+
let out = ''
|
|
241
|
+
for (const child of node.content) {
|
|
242
|
+
if (child.type === 'text') {
|
|
243
|
+
out += child.text || ''
|
|
244
|
+
} else if (child.content) {
|
|
245
|
+
// recurse through inline wrappers into their text
|
|
246
|
+
out += collectInlineText(child)
|
|
247
|
+
}
|
|
248
|
+
// else: inline atom (image/icon/emoji/math/hardBreak) → contributes no text
|
|
283
249
|
}
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
284
252
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
253
|
+
/**
|
|
254
|
+
* The translatable block elements of a content doc, in document order, with the
|
|
255
|
+
* SAME coverage as extraction above (headings, paragraphs, and each list item's
|
|
256
|
+
* paragraphs). Shared by the merge resolver (push) and the pull-side
|
|
257
|
+
* structural-map derivation so all paths walk identically and keys never drift.
|
|
258
|
+
* Returns the element nodes themselves — callers read `.type`/`.content` and key
|
|
259
|
+
* them via elementText.
|
|
260
|
+
*/
|
|
261
|
+
export function blockElements(doc) {
|
|
262
|
+
const out = []
|
|
263
|
+
for (const node of doc?.content || []) {
|
|
264
|
+
if (node.type === 'heading' || node.type === 'paragraph') {
|
|
265
|
+
out.push(node)
|
|
266
|
+
} else if (node.type === 'bulletList' || node.type === 'orderedList') {
|
|
267
|
+
for (const listItem of node.content || []) {
|
|
268
|
+
if (listItem.type === 'listItem' && listItem.content) {
|
|
269
|
+
for (const child of listItem.content) {
|
|
270
|
+
if (child.type === 'paragraph') out.push(child)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
295
274
|
}
|
|
296
275
|
}
|
|
297
|
-
return
|
|
276
|
+
return out
|
|
298
277
|
}
|
|
299
278
|
|
|
300
279
|
/**
|
package/src/i18n/merge.js
CHANGED
|
@@ -12,8 +12,24 @@
|
|
|
12
12
|
* when no free-form translation exists.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { computeHash
|
|
15
|
+
import { computeHash } from './hash.js'
|
|
16
16
|
import { loadFreeformTranslation } from './freeform.js'
|
|
17
|
+
import { elementText, blockElements } from './extract.js'
|
|
18
|
+
|
|
19
|
+
// Inline-markdown → ProseMirror inline fragment, for resolving a whole-element
|
|
20
|
+
// translation VALUE (which carries marks/links/icons as inline markdown). Same
|
|
21
|
+
// lazy-import-with-fallback pattern as freeform.js / collection-processor.js, so
|
|
22
|
+
// the synchronous merge path has the converter ready at call time.
|
|
23
|
+
let markdownToProseMirror
|
|
24
|
+
try {
|
|
25
|
+
const contentReader = await import('@uniweb/content-reader')
|
|
26
|
+
markdownToProseMirror = contentReader.markdownToProseMirror
|
|
27
|
+
} catch {
|
|
28
|
+
markdownToProseMirror = (markdown) => ({
|
|
29
|
+
type: 'doc',
|
|
30
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: markdown.trim() }] }]
|
|
31
|
+
})
|
|
32
|
+
}
|
|
17
33
|
|
|
18
34
|
/**
|
|
19
35
|
* Merge translations into site content for a specific locale
|
|
@@ -253,100 +269,67 @@ async function translateSectionAsync(section, page, translations, options) {
|
|
|
253
269
|
}
|
|
254
270
|
|
|
255
271
|
/**
|
|
256
|
-
*
|
|
272
|
+
* Resolve a ProseMirror content doc for a target locale: replace each block
|
|
273
|
+
* element's inline content with its translation, looked up by the WHOLE-ELEMENT
|
|
274
|
+
* key (shared with extract.js via elementText, so the two never drift). A
|
|
275
|
+
* missing translation leaves the source element untouched (graceful per-element
|
|
276
|
+
* fallback). The translation VALUE is inline markdown — it carries marks, inline
|
|
277
|
+
* links (with their own, possibly re-targeted, href) and inline atoms — so this
|
|
278
|
+
* is lossless for emphasis/links, unlike the former plain-string substitution.
|
|
257
279
|
*/
|
|
258
280
|
function translateProseMirrorDoc(doc, context, translations, fallbackToSource) {
|
|
259
|
-
|
|
281
|
+
let changed = false
|
|
282
|
+
for (const el of blockElements(doc)) {
|
|
283
|
+
if (applyElementTranslation(el, context, translations, fallbackToSource)) changed = true
|
|
284
|
+
}
|
|
285
|
+
return changed
|
|
286
|
+
}
|
|
260
287
|
|
|
261
|
-
|
|
262
|
-
|
|
288
|
+
// Replace one block element's inline content with the parsed translation
|
|
289
|
+
// fragment. `lookupTranslation` with the already-trimmed key adds no surrounding
|
|
290
|
+
// whitespace and returns the source on a miss, so `value === key` means
|
|
291
|
+
// "no translation" → leave the element as-is. Returns true if it replaced.
|
|
292
|
+
function applyElementTranslation(node, context, translations, fallbackToSource) {
|
|
293
|
+
const key = elementText(node)
|
|
294
|
+
if (!key) return false
|
|
295
|
+
const value = lookupTranslation(key, context, translations, fallbackToSource)
|
|
296
|
+
if (value === key) return false
|
|
297
|
+
const fragment = inlineMarkdownToFragment(value)
|
|
298
|
+
if (fragment && fragment.length) {
|
|
299
|
+
node.content = fragment
|
|
300
|
+
return true
|
|
263
301
|
}
|
|
302
|
+
return false
|
|
264
303
|
}
|
|
265
304
|
|
|
266
305
|
/**
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
306
|
+
* Resolve ONE ProseMirror content doc for a single target locale: a deep clone of
|
|
307
|
+
* the source doc with each whole-element translated (inline content replaced from
|
|
308
|
+
* the table's inline-markdown value). `table` is `{ hash: value }` for that locale.
|
|
309
|
+
* Returns the resolved doc, or null when the table translated nothing in this doc
|
|
310
|
+
* (so the caller can omit an untranslated locale — it falls back to the source
|
|
311
|
+
* locale). Lets the sync producer emit a self-contained per-locale DOC instead of a
|
|
312
|
+
* source-keyed map (which a consumer would otherwise resolve against the source).
|
|
271
313
|
*/
|
|
272
|
-
function
|
|
273
|
-
if (!
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
textChildren.some(n => n.marks?.some(m => m.type === 'span'))
|
|
279
|
-
|
|
280
|
-
if (hasInlineMarks) {
|
|
281
|
-
// Build tagged source string (mirrors extraction)
|
|
282
|
-
let markCounter = 0
|
|
283
|
-
const markMap = [] // tag index → original marks array
|
|
284
|
-
const parts = []
|
|
285
|
-
for (const child of textChildren) {
|
|
286
|
-
const text = child.text || ''
|
|
287
|
-
if (child.marks?.some(m => m.type === 'span')) {
|
|
288
|
-
markCounter++
|
|
289
|
-
markMap.push(child.marks)
|
|
290
|
-
parts.push(`<${markCounter}>${text}</${markCounter}>`)
|
|
291
|
-
} else {
|
|
292
|
-
parts.push(text)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
const plainSource = stripInlineTags(parts.join('').trim())
|
|
296
|
-
|
|
297
|
-
const translated = lookupTranslation(plainSource, context, translations, fallbackToSource)
|
|
298
|
-
if (translated !== plainSource) {
|
|
299
|
-
// Parse tagged translation and rebuild text children
|
|
300
|
-
const { segments } = parseInlineTags(translated)
|
|
301
|
-
const newTextChildren = segments.map(seg => {
|
|
302
|
-
if (seg.markIndex !== undefined && markMap[seg.markIndex]) {
|
|
303
|
-
return { type: 'text', text: seg.text, marks: [...markMap[seg.markIndex]] }
|
|
304
|
-
}
|
|
305
|
-
return { type: 'text', text: seg.text }
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
// Replace text children in content, preserve non-text children in place
|
|
309
|
-
const result = []
|
|
310
|
-
let textInserted = false
|
|
311
|
-
for (const child of node.content) {
|
|
312
|
-
if (child.type === 'text') {
|
|
313
|
-
if (!textInserted) {
|
|
314
|
-
result.push(...newTextChildren)
|
|
315
|
-
textInserted = true
|
|
316
|
-
}
|
|
317
|
-
// Skip remaining old text children
|
|
318
|
-
} else {
|
|
319
|
-
result.push(child)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
node.content = result
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Recurse into non-text children
|
|
326
|
-
for (const child of node.content) {
|
|
327
|
-
if (child.type !== 'text') {
|
|
328
|
-
translateNode(child, context, translations, fallbackToSource)
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
return
|
|
332
|
-
}
|
|
314
|
+
export function resolveDocForLocale(sourceDoc, table, context = { page: '', section: '' }) {
|
|
315
|
+
if (!sourceDoc || sourceDoc.type !== 'doc' || !table) return null
|
|
316
|
+
const doc = JSON.parse(JSON.stringify(sourceDoc))
|
|
317
|
+
const changed = translateProseMirrorDoc(doc, context, table, true)
|
|
318
|
+
return changed ? doc : null
|
|
319
|
+
}
|
|
333
320
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
child.text = translated
|
|
345
|
-
}
|
|
346
|
-
} else {
|
|
347
|
-
translateNode(child, context, translations, fallbackToSource)
|
|
348
|
-
}
|
|
321
|
+
// Parse an inline-markdown translation value into a ProseMirror inline fragment.
|
|
322
|
+
// A value is expected to be one element's inline content (one paragraph once
|
|
323
|
+
// parsed); take that paragraph's inline children. If the converter yields
|
|
324
|
+
// several blocks, flatten their inline content rather than drop any.
|
|
325
|
+
function inlineMarkdownToFragment(value) {
|
|
326
|
+
if (typeof value !== 'string' || value.trim() === '') return null
|
|
327
|
+
const doc = markdownToProseMirror(value)
|
|
328
|
+
const inline = []
|
|
329
|
+
for (const block of doc?.content || []) {
|
|
330
|
+
if (Array.isArray(block.content)) inline.push(...block.content)
|
|
349
331
|
}
|
|
332
|
+
return inline.length ? inline : [{ type: 'text', text: value.trim() }]
|
|
350
333
|
}
|
|
351
334
|
|
|
352
335
|
/**
|
|
@@ -57,21 +57,21 @@ const TYPE_ALIASES = {
|
|
|
57
57
|
}
|
|
58
58
|
// Friendly type aliases that lower to a base kind + a carried `format` marker.
|
|
59
59
|
// `url`/`email` → `string` (server-validated value subtypes). `markdown`/`html` →
|
|
60
|
-
// `text` (
|
|
61
|
-
// what the retired `richtext` kind used to be: 2026-06-02 / uwx-format.md).
|
|
60
|
+
// `text` (a file-based rich-content body — round-trips as the raw source string).
|
|
62
61
|
const FORMAT_TYPE_ALIASES = {
|
|
63
62
|
url: { type: 'string', format: 'url' },
|
|
64
63
|
email: { type: 'string', format: 'email' },
|
|
65
64
|
markdown: { type: 'text', format: 'markdown' },
|
|
66
65
|
html: { type: 'text', format: 'html' },
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
66
|
+
// `richtext` → a ProseMirror rich document (`json` + `format: prosemirror`): the
|
|
67
|
+
// framework's standard way to represent rich text — the structured, lossless form
|
|
68
|
+
// the visual app edits (text, media, tables, code, data blocks, icons, and inline
|
|
69
|
+
// components). Synced to file mode as enhanced markdown via content-writer. Contrast
|
|
70
|
+
// `markdown`/`html`, which are source-string bodies (raw text, no structured editor).
|
|
71
|
+
richtext: { type: 'json', format: 'prosemirror' },
|
|
71
72
|
}
|
|
72
73
|
// The advertised format-aliasing type words (drives the "Known types" hint).
|
|
73
|
-
|
|
74
|
-
export const FORMAT_TYPES = new Set(['url', 'email', 'markdown', 'html'])
|
|
74
|
+
export const FORMAT_TYPES = new Set(['url', 'email', 'markdown', 'html', 'richtext'])
|
|
75
75
|
export const SECTION_KINDS = new Set(['single', 'multi', 'binder'])
|
|
76
76
|
|
|
77
77
|
// Scope → schema package resolution. The shared standard schemas are referenced
|
|
@@ -324,7 +324,7 @@ export function validateAndNormalizeSchema(schema, ref) {
|
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
const out = {}
|
|
327
|
-
for (const k of ['name', 'version', 'description', 'sortDate']) {
|
|
327
|
+
for (const k of ['name', 'version', 'description', 'sort_date', 'sortDate']) {
|
|
328
328
|
if (schema[k] !== undefined) out[k] = schema[k]
|
|
329
329
|
}
|
|
330
330
|
|
|
@@ -361,7 +361,18 @@ function normalizeSection(section, ref, path, briefState) {
|
|
|
361
361
|
if (!section || typeof section !== 'object' || Array.isArray(section)) {
|
|
362
362
|
throw new Error(`Data schema '${ref}': section '${path}' must be an object.`)
|
|
363
363
|
}
|
|
364
|
-
|
|
364
|
+
if (section.many !== undefined && typeof section.many !== 'boolean') {
|
|
365
|
+
throw new Error(`Data schema '${ref}': section '${path}' 'many' must be a boolean.`)
|
|
366
|
+
}
|
|
367
|
+
// Cardinality. Friendly sugar: `many: true` → a list of records; a section with
|
|
368
|
+
// only child `sections:` (no `fields:`) is a binder — inferred, never written.
|
|
369
|
+
// Explicit `kind:` is still honored (the lower-level form it normalizes to).
|
|
370
|
+
let kind = section.kind
|
|
371
|
+
if (kind === undefined) {
|
|
372
|
+
if (section.many === true) kind = 'multi'
|
|
373
|
+
else if (section.fields === undefined && section.sections !== undefined) kind = 'binder'
|
|
374
|
+
else kind = 'single'
|
|
375
|
+
}
|
|
365
376
|
if (!SECTION_KINDS.has(kind)) {
|
|
366
377
|
throw new Error(`Data schema '${ref}': section '${path}' has invalid kind '${kind}' (expected single | multi | binder).`)
|
|
367
378
|
}
|
|
@@ -369,7 +380,7 @@ function normalizeSection(section, ref, path, briefState) {
|
|
|
369
380
|
|
|
370
381
|
if (section.brief === true) {
|
|
371
382
|
if (kind !== 'single') {
|
|
372
|
-
throw new Error(`Data schema '${ref}': brief section '${path}' must be
|
|
383
|
+
throw new Error(`Data schema '${ref}': brief section '${path}' must be a single record (drop 'many').`)
|
|
373
384
|
}
|
|
374
385
|
if (++briefState.count > 1) {
|
|
375
386
|
throw new Error(`Data schema '${ref}': more than one section marked 'brief: true' (at most one).`)
|
|
@@ -395,18 +406,19 @@ function normalizeSection(section, ref, path, briefState) {
|
|
|
395
406
|
}
|
|
396
407
|
if (section.constraints !== undefined) out.constraints = section.constraints
|
|
397
408
|
|
|
398
|
-
// `
|
|
399
|
-
// Carried into the IR so the
|
|
400
|
-
// `self_nesting`. The
|
|
401
|
-
// (`parent_item_id`); no explicit field expresses it.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
409
|
+
// `tree: true` (friendly) / `nestable: true` (lower-level) — a list section whose
|
|
410
|
+
// records form a tree among themselves. Carried into the IR so the lowering maps
|
|
411
|
+
// it to the model's `self_nesting`. The parent/child link is internal to the
|
|
412
|
+
// backend (`parent_item_id`); no explicit field expresses it.
|
|
413
|
+
const treeFlag = section.tree ?? section.nestable
|
|
414
|
+
if (treeFlag !== undefined) {
|
|
415
|
+
if (typeof treeFlag !== 'boolean') {
|
|
416
|
+
throw new Error(`Data schema '${ref}': section '${path}' 'tree' must be a boolean.`)
|
|
405
417
|
}
|
|
406
|
-
if (
|
|
407
|
-
throw new Error(`Data schema '${ref}': section '${path}' is '
|
|
418
|
+
if (treeFlag && kind !== 'multi') {
|
|
419
|
+
throw new Error(`Data schema '${ref}': section '${path}' is 'tree: true' but not a list — only a 'many: true' section can form a tree.`)
|
|
408
420
|
}
|
|
409
|
-
if (
|
|
421
|
+
if (treeFlag) out.nestable = true
|
|
410
422
|
}
|
|
411
423
|
|
|
412
424
|
// `append_only` — a multi whose records are insert-only: the backend accepts
|
|
@@ -419,7 +431,7 @@ function normalizeSection(section, ref, path, briefState) {
|
|
|
419
431
|
throw new Error(`Data schema '${ref}': section '${path}' 'append_only' must be a boolean.`)
|
|
420
432
|
}
|
|
421
433
|
if (section.append_only && kind !== 'multi') {
|
|
422
|
-
throw new Error(`Data schema '${ref}': section '${path}' is 'append_only: true' but
|
|
434
|
+
throw new Error(`Data schema '${ref}': section '${path}' is 'append_only: true' but not a list — only a 'many: true' section can be append-only.`)
|
|
423
435
|
}
|
|
424
436
|
if (section.append_only) out.append_only = true
|
|
425
437
|
}
|
|
@@ -444,6 +456,40 @@ function normalizeField(field, ref, path) {
|
|
|
444
456
|
if (!field || typeof field !== 'object' || Array.isArray(field)) {
|
|
445
457
|
throw new Error(`Data schema '${ref}': field '${path}' must be an object or a type string.`)
|
|
446
458
|
}
|
|
459
|
+
|
|
460
|
+
// Sugar: `many: true` → a list. Wrap the field-minus-`many` as the array's item
|
|
461
|
+
// type (lowers to the canonical `multiple`). The common cases —
|
|
462
|
+
// `{ ref: '@/x', many: true }`, `{ type: string, many: true }` — read as "a list
|
|
463
|
+
// of X" with no `array`/`items` ceremony.
|
|
464
|
+
if (field.many !== undefined) {
|
|
465
|
+
if (typeof field.many !== 'boolean') {
|
|
466
|
+
throw new Error(`Data schema '${ref}': field '${path}' 'many' must be a boolean.`)
|
|
467
|
+
}
|
|
468
|
+
if (field.many) {
|
|
469
|
+
// Collection-level metadata (required, default, label, help, description)
|
|
470
|
+
// rides on the array; the type-bearing attributes describe each item.
|
|
471
|
+
const ITEM_KEYS = new Set(['type', 'ref', 'options', 'enum', 'fields', 'items', 'format'])
|
|
472
|
+
const out = { type: 'array' }
|
|
473
|
+
const item = {}
|
|
474
|
+
for (const [k, v] of Object.entries(field)) {
|
|
475
|
+
if (k === 'many') continue
|
|
476
|
+
if (ITEM_KEYS.has(k)) item[k] = v
|
|
477
|
+
else out[k] = v
|
|
478
|
+
}
|
|
479
|
+
out.items = normalizeField(item, ref, `${path}[]`)
|
|
480
|
+
return out
|
|
481
|
+
}
|
|
482
|
+
const { many, ...rest } = field // many: false → a single value
|
|
483
|
+
field = rest
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Sugar: infer `type` from `ref:`/`options:` when omitted — `{ ref: '@/x' }` is a
|
|
487
|
+
// reference; `{ options: '@/x' }` is a curated picklist value.
|
|
488
|
+
if (field.type === undefined) {
|
|
489
|
+
if (typeof field.ref === 'string') field = { ...field, type: 'ref' }
|
|
490
|
+
else if (typeof field.options === 'string') field = { ...field, type: 'string' }
|
|
491
|
+
}
|
|
492
|
+
|
|
447
493
|
const rawType = field.type
|
|
448
494
|
if (typeof rawType !== 'string') {
|
|
449
495
|
throw new Error(`Data schema '${ref}': field '${path}' has no 'type'.`)
|
|
@@ -455,8 +501,8 @@ function normalizeField(field, ref, path) {
|
|
|
455
501
|
if (field[k] !== undefined) out[k] = field[k]
|
|
456
502
|
}
|
|
457
503
|
|
|
458
|
-
// Resolve the type: format-aliases (url/email → string; markdown/html
|
|
459
|
-
//
|
|
504
|
+
// Resolve the type: format-aliases (url/email → string; markdown/html → text;
|
|
505
|
+
// richtext → json) carry a `format` marker; else the plain alias map; else verbatim.
|
|
460
506
|
const formatAlias = FORMAT_TYPE_ALIASES[rawType]
|
|
461
507
|
if (formatAlias) {
|
|
462
508
|
out.type = formatAlias.type
|
package/src/site/config.js
CHANGED
|
@@ -65,17 +65,14 @@ function normalizeBasePath(raw) {
|
|
|
65
65
|
* - `'local'` — workspace-local source (sibling directory, file: dep, or
|
|
66
66
|
* `../../foundations/<name>/`). The build inlines or runtime-links it
|
|
67
67
|
* depending on the operating mode.
|
|
68
|
-
* - `'url'` — loaded by URL at runtime.
|
|
68
|
+
* - `'url'` — loaded by URL at runtime. Two URL shapes:
|
|
69
69
|
* - `@org/name@ver` → catalog ref (resolves against the registry CDN)
|
|
70
|
-
* - `~siteId/name@ver` → site-bound ref (resolves to per-site storage
|
|
71
|
-
* at `sites/{siteId}/_src/...` on uniweb-edge,
|
|
72
|
-
* never enters the catalog namespace)
|
|
73
70
|
* - `https://...` → arbitrary URL
|
|
74
71
|
*
|
|
75
|
-
* Versionless registry refs (`@org/name
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
72
|
+
* Versionless registry refs (`@org/name`) are rejected with a specific error —
|
|
73
|
+
* they were a silent fall-through before. Versionless names that don't match
|
|
74
|
+
* any local resolution path are also rejected, with guidance toward the right
|
|
75
|
+
* shape.
|
|
79
76
|
*
|
|
80
77
|
* @param {string|Object} foundation - Foundation config from site.yml
|
|
81
78
|
* @param {string} siteRoot - Path to site directory
|
|
@@ -111,43 +108,26 @@ export function detectFoundationType(foundation, siteRoot) {
|
|
|
111
108
|
}
|
|
112
109
|
}
|
|
113
110
|
|
|
114
|
-
//
|
|
115
|
-
// `@org/name@version`
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// Vite skip the local-foundation bundling path and use the noop virtual
|
|
122
|
-
// module. Base URL defaults to the production worker but is overridable
|
|
123
|
-
// via UNIWEB_REGISTRY_URL for self-hosted / staging.
|
|
111
|
+
// Catalog registry ref:
|
|
112
|
+
// `@org/name@version` → resolves via the registry CDN.
|
|
113
|
+
// Link-mode by definition — the foundation lives on the hosting edge and is
|
|
114
|
+
// loaded at runtime. Surfacing this as `type: 'url'` makes Vite skip the
|
|
115
|
+
// local-foundation bundling path and use the noop virtual module. Base URL
|
|
116
|
+
// defaults to the production worker but is overridable via UNIWEB_REGISTRY_URL
|
|
117
|
+
// for self-hosted / staging.
|
|
124
118
|
const orgScopedMatch = /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
|
|
125
|
-
|
|
126
|
-
if (orgScopedMatch || siteBoundMatch) {
|
|
119
|
+
if (orgScopedMatch) {
|
|
127
120
|
const base = process.env.UNIWEB_REGISTRY_URL || 'https://site-router.uniweb-edge.workers.dev'
|
|
128
|
-
|
|
129
|
-
const [, ns, fn, ver] = orgScopedMatch
|
|
130
|
-
// Legacy plain-slash URL form (preserved — worker still accepts it
|
|
131
|
-
// for back-compat with sites built against earlier CLI releases).
|
|
132
|
-
return {
|
|
133
|
-
type: 'url',
|
|
134
|
-
url: `${base}/foundations/${ns}/${fn}/${ver}/foundation.js`,
|
|
135
|
-
cssUrl: `${base}/foundations/${ns}/${fn}/${ver}/assets/foundation.css`
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
// Site-bound URL form — sigil + canonical `<name>@<version>` shape.
|
|
139
|
-
// The worker dispatches on the `~` sigil to per-site storage rather
|
|
140
|
-
// than the catalog namespace.
|
|
141
|
-
const [, siteId, fn, ver] = siteBoundMatch
|
|
121
|
+
const [, ns, fn, ver] = orgScopedMatch
|
|
142
122
|
return {
|
|
143
123
|
type: 'url',
|
|
144
|
-
url: `${base}/foundations
|
|
145
|
-
cssUrl: `${base}/foundations
|
|
124
|
+
url: `${base}/foundations/${ns}/${fn}/${ver}/foundation.js`,
|
|
125
|
+
cssUrl: `${base}/foundations/${ns}/${fn}/${ver}/assets/foundation.css`
|
|
146
126
|
}
|
|
147
127
|
}
|
|
148
128
|
|
|
149
|
-
// Versionless scoped names (`@org/name
|
|
150
|
-
//
|
|
129
|
+
// Versionless scoped names (`@org/name`) are valid as *handles* — they
|
|
130
|
+
// resolve through the local checks below (file: dep,
|
|
151
131
|
// workspace sibling) when the developer is iterating locally on a
|
|
152
132
|
// foundation that will eventually be published as `@org/name@ver`.
|
|
153
133
|
// Tianyu's uniweb.io site uses this shape:
|
|
@@ -196,7 +176,7 @@ export function detectFoundationType(foundation, siteRoot) {
|
|
|
196
176
|
// Versionless scoped name that didn't resolve locally — likely a typo
|
|
197
177
|
// or a missing file: dep. Give a specific hint distinguishing the two
|
|
198
178
|
// common causes (forgot @version vs. forgot to wire the file: dep).
|
|
199
|
-
if (/^@[a-z0-9_-]+\//.test(name)
|
|
179
|
+
if (/^@[a-z0-9_-]+\//.test(name)) {
|
|
200
180
|
throw new Error(
|
|
201
181
|
`site.yml foundation: '${name}' did not resolve to a local source and no version was specified.\n` +
|
|
202
182
|
`If this is a workspace-local foundation, add it to the site's package.json:\n` +
|
|
@@ -216,7 +196,7 @@ export function detectFoundationType(foundation, siteRoot) {
|
|
|
216
196
|
` - a workspace-local sibling (a directory next to the site, named '${name}')\n` +
|
|
217
197
|
` - a 'file:' dep in the site's package.json\n` +
|
|
218
198
|
` - a directory in '../../foundations/${name}'\n` +
|
|
219
|
-
` - a versioned registry ref: '@org/${name}@<version>'
|
|
199
|
+
` - a versioned registry ref: '@org/${name}@<version>'\n` +
|
|
220
200
|
` - a full URL: 'https://...'\n` +
|
|
221
201
|
`Foundations are runtime federated modules, not npm packages — there is no fall-through to node_modules.`
|
|
222
202
|
)
|
package/src/uwx/data-schema.js
CHANGED
|
@@ -194,16 +194,21 @@ function lowerField(rawField, resolve, optResolve) {
|
|
|
194
194
|
return out
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
// A leaf (scalar) kind. `richtext` is
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
197
|
+
// A leaf (scalar) kind. `richtext` is NOT a kind — it is the author alias for a
|
|
198
|
+
// ProseMirror document (`json` + `format: prosemirror`), normalized upstream in
|
|
199
|
+
// resolve-data-schema.js, so normalized IR never carries a raw `richtext` kind. The
|
|
200
|
+
// only way one could reach here is a STALE prebuilt schema.json (a foundation built
|
|
201
|
+
// before the 2026-06-02 kind retirement and loaded from dist/meta/schema.json without
|
|
202
|
+
// re-resolving). Fail locally — rebuild the foundation — rather than ship a kind the
|
|
203
|
+
// backend rejects.
|
|
204
|
+
if (type === 'richtext') {
|
|
205
|
+
throw new Error(
|
|
206
|
+
'This foundation carries the retired `richtext` kind in its built schema — ' +
|
|
207
|
+
'rebuild it (`richtext` is now json + format: prosemirror).'
|
|
208
|
+
)
|
|
206
209
|
}
|
|
210
|
+
const leafType = type
|
|
211
|
+
const leafFormat = field.format
|
|
207
212
|
|
|
208
213
|
const out = { type: leafType }
|
|
209
214
|
if (field.label) out.label = field.label
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -18,9 +18,10 @@
|
|
|
18
18
|
|
|
19
19
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
20
20
|
import { join, dirname } from 'node:path'
|
|
21
|
-
import { proseMirrorToMarkdown } from '@uniweb/content-writer'
|
|
22
|
-
import { computeHash
|
|
23
|
-
import {
|
|
21
|
+
import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-writer'
|
|
22
|
+
import { computeHash } from '../i18n/hash.js'
|
|
23
|
+
import { blockElements, elementText } from '../i18n/extract.js'
|
|
24
|
+
import { resolveDocForLocale } from '../i18n/merge.js'
|
|
24
25
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
25
26
|
|
|
26
27
|
const FREEFORM_MANIFEST = '.manifest.json'
|
|
@@ -99,14 +100,18 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
|
|
|
99
100
|
|
|
100
101
|
/**
|
|
101
102
|
* Wrap a section's source-locale content DOC into its localized `content` form: the
|
|
102
|
-
* source locale stays the doc
|
|
103
|
-
*
|
|
104
|
-
* `locales/{locale}.json
|
|
105
|
-
*
|
|
106
|
-
*
|
|
103
|
+
* source locale stays the doc, and EACH TARGET LOCALE BECOMES A SELF-CONTAINED DOC
|
|
104
|
+
* (not a structural map) — resolved from the doc's source structure joined with
|
|
105
|
+
* `locales/{locale}.json` via the merge resolver. A self-contained doc is the
|
|
106
|
+
* portable, renderer-ready form — a source-keyed map is an authoring representation
|
|
107
|
+
* that must be resolved against the source before it can be rendered; the structural
|
|
108
|
+
* map lives only on disk (`locales/{locale}.json`) and is recovered on pull (see
|
|
109
|
+
* unwrapLocalizedContent).
|
|
107
110
|
*
|
|
108
111
|
* Returns the bare doc unchanged when there are no target locales / no translations
|
|
109
|
-
* for any of them (single-locale and pre-localization sites are untouched).
|
|
112
|
+
* for any of them (single-locale and pre-localization sites are untouched). A
|
|
113
|
+
* target locale with no translation for THIS doc is omitted (it falls back to the
|
|
114
|
+
* source locale), so the payload stays lean.
|
|
110
115
|
*
|
|
111
116
|
* @param {object} doc - the source-locale ProseMirror content doc
|
|
112
117
|
* @param {string} sourceLocale
|
|
@@ -117,17 +122,12 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
|
|
|
117
122
|
if (!isProseMirrorDoc(doc) || !targetLocales || targetLocales.length === 0 || !translations) {
|
|
118
123
|
return doc
|
|
119
124
|
}
|
|
120
|
-
const units = extractUnitsFromDoc(doc)
|
|
121
125
|
const result = { [sourceLocale]: doc }
|
|
122
126
|
for (const locale of targetLocales) {
|
|
123
127
|
const table = translations[locale]
|
|
124
128
|
if (!table) continue
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
const tgt = table[hash]
|
|
128
|
-
if (typeof tgt === 'string') map[stripInlineTags(unit.source)] = tgt
|
|
129
|
-
}
|
|
130
|
-
if (Object.keys(map).length > 0) result[locale] = map
|
|
129
|
+
const resolved = resolveDocForLocale(doc, table)
|
|
130
|
+
if (resolved) result[locale] = resolved
|
|
131
131
|
}
|
|
132
132
|
// Only wrap when at least one target carried a translation — else stay a bare doc.
|
|
133
133
|
return Object.keys(result).length > 1 ? result : doc
|
|
@@ -235,20 +235,66 @@ export function createTranslationCollector(sourceLocale) {
|
|
|
235
235
|
return { add, addStructuralMap, noteFreeform, byLocale, freeformPending }
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Derive a structural `{ source-text: target-inline-markdown }` map from a
|
|
240
|
+
* (source doc, target doc) pair — the inverse of resolveDocForLocale, used on pull
|
|
241
|
+
* to recover the compact `locales/{locale}.json` surface from the DOC the wire now
|
|
242
|
+
* carries. Returns the map when the target is structurally CONGRUENT with the
|
|
243
|
+
* source (same sequence of block elements by type) so it round-trips losslessly;
|
|
244
|
+
* returns null when the target diverges (the caller stores it as a free-form body
|
|
245
|
+
* instead). An element whose inline markdown is identical to the source is
|
|
246
|
+
* untranslated and omitted. The value is the target element's inline content as
|
|
247
|
+
* inline markdown (serializeInlineContent), so emphasis, inline icons, and
|
|
248
|
+
* per-locale link hrefs survive. Keyed by the source element's cleaned text, so
|
|
249
|
+
* `computeHash(key)` matches the extractor's unit hash — closing the round trip.
|
|
250
|
+
*/
|
|
251
|
+
function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
252
|
+
if (!isProseMirrorDoc(sourceDoc) || !isProseMirrorDoc(targetDoc)) return null
|
|
253
|
+
const src = blockElements(sourceDoc)
|
|
254
|
+
const tgt = blockElements(targetDoc)
|
|
255
|
+
if (src.length !== tgt.length) return null // structurally divergent → free-form
|
|
256
|
+
const map = {}
|
|
257
|
+
try {
|
|
258
|
+
for (let i = 0; i < src.length; i++) {
|
|
259
|
+
if (src[i].type !== tgt[i].type) return null // divergent → free-form
|
|
260
|
+
const key = elementText(src[i])
|
|
261
|
+
if (!key) continue
|
|
262
|
+
const srcMd = serializeInlineContent(src[i].content || [])
|
|
263
|
+
const tgtMd = serializeInlineContent(tgt[i].content || [])
|
|
264
|
+
if (srcMd === tgtMd) continue // unchanged → untranslated → omit
|
|
265
|
+
map[key] = tgtMd
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
// a mark we can't serialize → don't risk a lossy map; store as free-form
|
|
269
|
+
return null
|
|
270
|
+
}
|
|
271
|
+
return map
|
|
272
|
+
}
|
|
273
|
+
|
|
238
274
|
/**
|
|
239
275
|
* Unwrap a (possibly localized) `content` field to the SOURCE-locale ProseMirror
|
|
240
|
-
* doc for the `.md` body, capturing target locales into `collector
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
276
|
+
* doc for the `.md` body, capturing target locales into `collector`. The wire now
|
|
277
|
+
* carries a self-contained DOC per target locale: a target structurally congruent
|
|
278
|
+
* with the source is recovered as a structural `locales/{locale}.json` map (via
|
|
279
|
+
* deriveStructuralMap); a divergent target is a free-form body override → noted
|
|
280
|
+
* with its freeform path (`freeformRelPath`, from buildFreeformPath). A structural
|
|
281
|
+
* map still on the wire (legacy / transition) passes through unchanged. The
|
|
282
|
+
* reserved `@` (and `$`-prefixed) key is opaque metadata — NEVER a locale. A bare
|
|
283
|
+
* doc (no locale wrap) is returned unchanged.
|
|
244
284
|
*/
|
|
245
285
|
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath) {
|
|
246
286
|
if (!isLocalizedContent(content)) return content
|
|
247
287
|
const source = content[sourceLocale]
|
|
248
288
|
for (const [locale, value] of Object.entries(content)) {
|
|
249
289
|
if (locale === sourceLocale) continue
|
|
250
|
-
if (
|
|
251
|
-
|
|
290
|
+
if (locale === '@' || locale.startsWith('$')) continue // reserved metadata, not a locale
|
|
291
|
+
if (isProseMirrorDoc(value)) {
|
|
292
|
+
const map = deriveStructuralMap(source, value)
|
|
293
|
+
if (map) collector?.addStructuralMap?.(locale, map)
|
|
294
|
+
else collector?.noteFreeform?.(locale, value, source, freeformRelPath)
|
|
295
|
+
} else {
|
|
296
|
+
collector?.addStructuralMap?.(locale, value)
|
|
297
|
+
}
|
|
252
298
|
}
|
|
253
299
|
return source
|
|
254
300
|
}
|
|
@@ -48,9 +48,14 @@ try {
|
|
|
48
48
|
* @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`.
|
|
49
49
|
* @param {Object} [params.exporter] - `{ tool, version, instance }` for the envelope.
|
|
50
50
|
* @param {string} [params.exportedAt] - ISO timestamp (default: now).
|
|
51
|
+
* @param {string} [params.digest] - the foundation's content digest (`sha256:…`),
|
|
52
|
+
* computed by the CLI over what register ships (shipping-model.md §4.1). Rides
|
|
53
|
+
* in the foundation-schema entity's `info.digest`; the backend stores it
|
|
54
|
+
* OPAQUE and returns it on the foundation-latest read so `publish`/`status`
|
|
55
|
+
* can detect "code changed since release" with no local state.
|
|
51
56
|
* @returns {Object} the `.uwx` document (uwx/1; entities, names only, no uuids).
|
|
52
57
|
*/
|
|
53
|
-
export function buildRegistryPackage({ schema, foundationDir, scope, exporter, exportedAt } = {}) {
|
|
58
|
+
export function buildRegistryPackage({ schema, foundationDir, scope, exporter, exportedAt, digest } = {}) {
|
|
54
59
|
const self = schema?._self
|
|
55
60
|
if (!self || !self.name || !self.version) {
|
|
56
61
|
throw new Error('buildRegistryPackage: schema._self with name + version is required')
|
|
@@ -65,7 +70,7 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
|
|
|
65
70
|
|
|
66
71
|
const foundationEntity = {
|
|
67
72
|
model: FOUNDATION_SCHEMA,
|
|
68
|
-
info: buildInfo(self, org),
|
|
73
|
+
info: buildInfo(self, org, digest),
|
|
69
74
|
schema: buildSchemaBlob(schema),
|
|
70
75
|
i18n: { locales: loadI18nLocales(foundationDir) },
|
|
71
76
|
'data-schemas': { refs: buildRefs(dataSchemas, scoped) },
|
|
@@ -136,12 +141,15 @@ function wrapEntities(entities, exporter, exportedAt) {
|
|
|
136
141
|
|
|
137
142
|
// --- foundation-schema content (names only) ----------------------------------
|
|
138
143
|
|
|
139
|
-
// Identity card — decomposed so it's readable without opening the blob.
|
|
140
|
-
|
|
144
|
+
// Identity card — decomposed so it's readable without opening the blob. The
|
|
145
|
+
// optional `digest` (sha256:…) is the foundation's content fingerprint; the
|
|
146
|
+
// backend stores it opaque and returns it on the foundation-latest read.
|
|
147
|
+
function buildInfo(self, org, digest) {
|
|
141
148
|
// Scope a bare foundation name (`src` -> `@acme/src`); leave an already-scoped name.
|
|
142
149
|
const name = org && !String(self.name).startsWith('@') ? `@${org}/${self.name}` : self.name
|
|
143
150
|
const info = { name, version: self.version, role: self.role || 'foundation' }
|
|
144
151
|
if (self.description !== undefined) info.description = self.description
|
|
152
|
+
if (digest) info.digest = digest
|
|
145
153
|
return info
|
|
146
154
|
}
|
|
147
155
|
|