@uniweb/build 0.14.17 → 0.14.19

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.14.17",
3
+ "version": "0.14.19",
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.5",
62
+ "@uniweb/content-writer": "0.2.6",
63
63
  "@uniweb/theming": "0.1.4"
64
64
  },
65
65
  "optionalDependencies": {
66
+ "@uniweb/runtime": "0.8.20",
66
67
  "@uniweb/content-reader": "1.1.12",
67
- "@uniweb/schemas": "0.2.3",
68
- "@uniweb/runtime": "0.8.20"
68
+ "@uniweb/schemas": "0.2.3"
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 auto-publishes (that's exclusive
78
- * to `uniweb deploy` against uniweb-edge), so the rule is:
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
  */
@@ -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 = extractTextFromNode(node)
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
- const result = extractFromParagraph(node, context, units, linkIndex)
180
- linkIndex = result.linkIndex
181
-
182
- // Add paragraph text if it's substantial (not just links/buttons)
183
- const plainText = extractPlainTextFromParagraph(node)
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, plainText, field, context)
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 paragraph node, handling links specially
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 = extractTextFromNode(child)
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
- * Extract all text content from a node.
269
- * When a node has multiple text children with some carrying span marks
270
- * (e.g., `[text]{accent}`), wraps the marked spans in XLIFF-style
271
- * `<N>...</N>` tags so translators can reposition them.
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 extractTextFromNode(node) {
274
- if (!node.content) return ''
275
- const textChildren = node.content.filter(n => n.type === 'text')
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
- if (!hasInlineMarks) {
282
- return textChildren.map(n => n.text || '').join('').trim()
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
- // Wrap span-marked text in numbered tags
286
- let markCounter = 0
287
- const parts = []
288
- for (const child of textChildren) {
289
- const text = child.text || ''
290
- if (child.marks?.some(m => m.type === 'span')) {
291
- markCounter++
292
- parts.push(`<${markCounter}>${text}</${markCounter}>`)
293
- } else {
294
- parts.push(text)
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 parts.join('').trim()
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, stripInlineTags, parseInlineTags } from './hash.js'
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
- * Translate text nodes in a ProseMirror document
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
- if (!doc.content) return
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
- for (const node of doc.content) {
262
- translateNode(node, context, translations, fallbackToSource)
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
- * Recursively translate a node and its children.
268
- * For nodes with mixed marked/unmarked text children (inline span marks),
269
- * translates the node as a whole unit using XLIFF-style inline tags,
270
- * then rebuilds children with original marks re-applied.
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 translateNode(node, context, translations, fallbackToSource) {
273
- if (!node.content) return
274
-
275
- // Check for inline span marks across multiple text children
276
- const textChildren = node.content.filter(n => n.type === 'text')
277
- const hasInlineMarks = textChildren.length > 1 &&
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
- // Default: translate each child individually
335
- for (const child of node.content) {
336
- if (child.type === 'text' && child.text) {
337
- const translated = lookupTranslation(
338
- child.text,
339
- context,
340
- translations,
341
- fallbackToSource
342
- )
343
- if (translated !== child.text) {
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
  /**
@@ -409,6 +409,21 @@ function normalizeSection(section, ref, path, briefState) {
409
409
  if (section.nestable) out.nestable = true
410
410
  }
411
411
 
412
+ // `append_only` — a multi whose records are insert-only: the backend accepts
413
+ // appends but refuses edits or deletes of existing items, so the section is
414
+ // tamper-evident (activity logs, submissions, audit trails). Carried into the IR
415
+ // verbatim for the submission lowering to emit as the model's `append_only`.
416
+ // Like `nestable`, only a `multi` section can be append-only.
417
+ if (section.append_only !== undefined) {
418
+ if (typeof section.append_only !== 'boolean') {
419
+ throw new Error(`Data schema '${ref}': section '${path}' 'append_only' must be a boolean.`)
420
+ }
421
+ if (section.append_only && kind !== 'multi') {
422
+ throw new Error(`Data schema '${ref}': section '${path}' is 'append_only: true' but kind '${kind}' — only a 'multi' section can be append-only.`)
423
+ }
424
+ if (section.append_only) out.append_only = true
425
+ }
426
+
412
427
  return out
413
428
  }
414
429
 
@@ -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. Three URL shapes:
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`, `~siteId/name`) are rejected with
76
- * a specific error — they were a silent fall-through before. Versionless
77
- * names that don't match any local resolution path are also rejected, with
78
- * guidance toward the right shape.
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
- // Two URL-resolved registry shapes:
115
- // `@org/name@version` catalog ref. Resolves via the registry CDN.
116
- // `~siteId/name@version` → site-bound ref. Resolves to per-site storage
117
- // on uniweb-edge (sites/{siteId}/_src/...);
118
- // never reaches the catalog R2 namespace.
119
- // Both are link-mode by definition the foundation lives on the hosting
120
- // edge and is loaded at runtime. Surfacing this as `type: 'url'` makes
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
- const siteBoundMatch = /^~([A-Za-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(name)
126
- if (orgScopedMatch || siteBoundMatch) {
119
+ if (orgScopedMatch) {
127
120
  const base = process.env.UNIWEB_REGISTRY_URL || 'https://site-router.uniweb-edge.workers.dev'
128
- if (orgScopedMatch) {
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/~${siteId}/${fn}@${ver}/foundation.js`,
145
- cssUrl: `${base}/foundations/~${siteId}/${fn}@${ver}/assets/foundation.css`
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`, `~siteId/name`) are valid as
150
- // *handles* — they resolve through the local checks below (file: dep,
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) || name.startsWith('~')) {
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>' or '~<siteId>/${name}@<version>'\n` +
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
  )
@@ -135,14 +135,15 @@ function lowerSectionsForm(sectionsMap, resolve, optResolve) {
135
135
  // Lower one section to its declaration body (the caller keys it by name; a nested
136
136
  // section gets a `type: section` marker prepended in lowerField). `kind: multi` →
137
137
  // `multiple: true`; `binder` is derived (no marker — it falls out of "all fields
138
- // are type: section"); `nestable` → `self_nesting`; authored cross-cutting
139
- // `constraints` pass through as a bare array. Leaves and nested sections share one
140
- // ordered `fields:` namespace.
138
+ // are type: section"); `nestable` → `self_nesting`; `append_only` (insert-only
139
+ // records) passes through; authored cross-cutting `constraints` pass through as a
140
+ // bare array. Leaves and nested sections share one ordered `fields:` namespace.
141
141
  function lowerSection(def, resolve, optResolve) {
142
142
  const out = {}
143
143
  if ((def.kind || 'single') === 'multi') out.multiple = true
144
144
  if (def.brief === true) out.brief = true
145
145
  if (def.nestable) out.self_nesting = true
146
+ if (def.append_only) out.append_only = true
146
147
 
147
148
  const fields = {}
148
149
  for (const [key, rawField] of Object.entries(def.fields || {})) {
@@ -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, stripInlineTags } from '../i18n/hash.js'
23
- import { extractUnitsFromDoc } from '../i18n/extract.js'
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; each target locale becomes a structural
103
- * `{ source-text: target }` map built from the doc's translatable units joined with
104
- * `locales/{locale}.json`. The map is keyed by the cleaned (inline-tag-stripped)
105
- * source text, so the projector's `computeHash(key)` matches the unit hash
106
- * closing the round trip. Reuses the i18n extractor (extractUnitsFromDoc).
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 map = {}
126
- for (const [hash, unit] of Object.entries(units)) {
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`: a structural
241
- * `{ src: target }` map hash entries; a free-form doc override → noted with its
242
- * freeform path (`freeformRelPath`, from buildFreeformPath) so it can be written to
243
- * `locales/freeform/{locale}/…`. A bare doc (no locale wrap) is returned unchanged.
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 (isProseMirrorDoc(value)) collector?.noteFreeform?.(locale, value, source, freeformRelPath)
251
- else collector?.addStructuralMap?.(locale, value)
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
- function buildInfo(self, org) {
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