@uniweb/build 0.15.13 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.15.13",
3
+ "version": "0.16.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,14 +59,15 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/projections": "0.1.5",
63
- "@uniweb/content-writer": "0.2.9",
64
- "@uniweb/theming": "0.1.14"
62
+ "@uniweb/theming": "0.1.15",
63
+ "@uniweb/projections": "0.2.0",
64
+ "@uniweb/content-writer": "0.3.0"
65
65
  },
66
66
  "optionalDependencies": {
67
- "@uniweb/runtime": "0.8.41",
68
- "@uniweb/content-reader": "1.1.18",
69
- "@uniweb/schemas": "0.2.4"
67
+ "@uniweb/runtime": "0.9.0",
68
+ "@uniweb/schemas": "0.2.4",
69
+ "@uniweb/content-reader": "1.2.0",
70
+ "@uniweb/semantic-parser": "1.2.0"
70
71
  },
71
72
  "peerDependencies": {
72
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -75,7 +76,7 @@
75
76
  "@tailwindcss/vite": "^4.0.0",
76
77
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
77
78
  "vite-plugin-svgr": "^4.0.0",
78
- "@uniweb/core": "0.7.33"
79
+ "@uniweb/core": "0.8.0"
79
80
  },
80
81
  "peerDependenciesMeta": {
81
82
  "vite": {
@@ -14,51 +14,23 @@ import { readFile, writeFile, readdir, mkdir } from 'fs/promises'
14
14
  import { existsSync } from 'fs'
15
15
  import { join } from 'path'
16
16
  import { pathToFileURL } from 'url'
17
+ import { DATA_DIR } from '@uniweb/core'
17
18
  import { computeHash } from './hash.js'
18
19
  import { loadFreeformCollectionItem } from './freeform.js'
20
+ // The heuristic judgement about which strings inside structured data are prose.
21
+ // It lives in its own module because the page lane needs exactly the same
22
+ // answer for a tagged data block's payload — a `label` is prose and an `href`
23
+ // is not, wherever the value came from. Moved rather than copied: two tuned
24
+ // denylists would drift, and drift here is silent.
25
+ import {
26
+ NON_TRANSLATABLE_TYPES,
27
+ HEURISTIC_SKIP_FIELDS,
28
+ MAX_HEURISTIC_DEPTH,
29
+ isStructuralString,
30
+ } from './data-strings.js'
19
31
 
20
32
  export const COLLECTIONS_DIR = 'collections'
21
33
 
22
- // ---------------------------------------------------------------------------
23
- // Constants
24
- // ---------------------------------------------------------------------------
25
-
26
- /** Types that are never translatable regardless of schema */
27
- const NON_TRANSLATABLE_TYPES = new Set([
28
- 'number', 'boolean', 'date', 'datetime', 'url', 'email', 'image'
29
- ])
30
-
31
- /** Field names skipped by the heuristic extractor (structural, not human-readable) */
32
- const HEURISTIC_SKIP_FIELDS = new Set([
33
- 'slug', 'id', 'type', 'status', 'href', 'url', 'src', 'icon',
34
- 'target', 'email', 'phone', 'orcid', 'doi', 'arxiv', 'isbn',
35
- 'pmid', 'bibtex', 'pdf', 'code', 'data', 'slides', 'video',
36
- 'repository', 'caseStudy', 'website', 'avatar', 'image',
37
- 'thumbnail', 'currency', 'order', 'hidden', 'current',
38
- 'featured', 'published', 'allDay', 'remote', 'hybrid',
39
- 'noindex', 'corresponding', 'required', 'virtual',
40
- 'lastModified', 'date', 'updated', 'posted', 'submitted',
41
- 'accepted', 'startDate', 'endDate', 'deadline',
42
- 'readTime', 'citations', 'capacity', 'volume', 'issue', 'pages',
43
- 'time', 'timezone',
44
- ])
45
-
46
- /** String patterns that indicate non-translatable values */
47
- const HEURISTIC_SKIP_PATTERNS = [
48
- /^https?:\/\//, // URLs
49
- /^mailto:/, // mailto links
50
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/, // email addresses
51
- /^\d{4}-\d{2}-\d{2}/, // ISO dates
52
- /^#[0-9a-fA-F]{3,8}$/, // hex colors
53
- /^[\w./\\-]+\.\w{2,4}$/, // file paths (e.g., ./logo.svg, /img/hero.jpg)
54
- /^[A-Z]{3}$/, // currency codes (USD, EUR)
55
- /^\d+(\.\d+)?$/, // plain numbers as strings
56
- /^\d{1,2}:\d{2}(:\d{2})?$/, // times (09:00, 14:30:00)
57
- ]
58
-
59
- /** Max recursion depth for heuristic extraction */
60
- const MAX_HEURISTIC_DEPTH = 5
61
-
62
34
  // ---------------------------------------------------------------------------
63
35
  // Schema resolution
64
36
  // ---------------------------------------------------------------------------
@@ -85,8 +57,14 @@ async function resolveSchema(collectionName, siteRoot) {
85
57
 
86
58
  let schema = null
87
59
 
88
- // 1. Companion schema file
89
- const companionPath = join(siteRoot, 'public', 'data', `${collectionName}.schema.js`)
60
+ // 1. Companion schema file, beside the collection it describes.
61
+ //
62
+ // This used to be looked up in `public/<DATA_DIR>/` — next to the compiled
63
+ // output rather than the source. That directory is the build's, and asking
64
+ // an author to write into it was the one remaining place the framework
65
+ // contradicted its own rule that `collections/` is the only way to provide
66
+ // structured data. The schema describes the source, so it lives with it.
67
+ const companionPath = join(siteRoot, 'collections', `${collectionName}.schema.js`)
90
68
  if (existsSync(companionPath)) {
91
69
  try {
92
70
  const mod = await import(pathToFileURL(companionPath).href)
@@ -290,13 +268,6 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
290
268
  }
291
269
  }
292
270
 
293
- /**
294
- * Check if a string value looks structural (not human-readable).
295
- */
296
- function isStructuralString(value) {
297
- return HEURISTIC_SKIP_PATTERNS.some(pattern => pattern.test(value))
298
- }
299
-
300
271
  // ---------------------------------------------------------------------------
301
272
  // Schema-guided translation
302
273
  // ---------------------------------------------------------------------------
@@ -412,7 +383,7 @@ function translateItemHeuristic(data, context, translations, depth) {
412
383
  * @returns {Promise<Object>} Manifest with translation units
413
384
  */
414
385
  export async function extractCollectionContent(siteRoot, options = {}) {
415
- const dataDir = join(siteRoot, 'public', 'data')
386
+ const dataDir = join(siteRoot, 'public', DATA_DIR)
416
387
 
417
388
  if (!existsSync(dataDir)) {
418
389
  return { version: '1.0', units: {} }
@@ -577,7 +548,7 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
577
548
  freeformEnabled = true
578
549
  } = options
579
550
 
580
- const dataDir = join(siteRoot, 'public', 'data')
551
+ const dataDir = join(siteRoot, 'public', DATA_DIR)
581
552
 
582
553
  if (!existsSync(dataDir)) {
583
554
  return {}
@@ -615,7 +586,7 @@ export async function buildLocalizedCollections(siteRoot, options = {}) {
615
586
  const hasFreeform = freeformEnabled && existsSync(freeformDir)
616
587
 
617
588
  // Create locale data directory
618
- const localeDataDir = join(outputDir, locale, 'data')
589
+ const localeDataDir = join(outputDir, locale, DATA_DIR)
619
590
  await mkdir(localeDataDir, { recursive: true })
620
591
 
621
592
  outputs[locale] = {}
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Which strings inside STRUCTURED DATA are human-readable prose.
3
+ *
4
+ * A tagged data block (```yaml:nav, ```json:pricing) carries an arbitrary shape,
5
+ * so there is no element to key a translation unit to — only a tree of values,
6
+ * most of which are machinery. A nav's `label` is prose; its `href` and `icon`
7
+ * are not. Guessing wrong in one direction leaves a site half-translated; in the
8
+ * other it rewrites a URL into another language and breaks the link.
9
+ *
10
+ * The judgement was already made and tuned for collections, which have exactly
11
+ * this problem. This module is that judgement, moved somewhere both lanes can
12
+ * reach rather than copied — the copy is how the two would drift, and the whole
13
+ * point is that a `label` means the same thing in a collection record and in a
14
+ * data block.
15
+ *
16
+ * An ALLOWLIST of shapes is impossible here (the data is author-defined), so
17
+ * this is necessarily a denylist, and it is deliberately conservative: a missed
18
+ * skip shows up as a translatable string an author can leave alone, while a
19
+ * missed field shows up as untranslated content nobody notices.
20
+ */
21
+
22
+ /** Types that are never translatable regardless of schema. */
23
+ export const NON_TRANSLATABLE_TYPES = new Set([
24
+ 'number', 'boolean', 'date', 'datetime', 'url', 'email', 'image'
25
+ ])
26
+
27
+ /** Field names skipped by the heuristic extractor (structural, not human-readable). */
28
+ export const HEURISTIC_SKIP_FIELDS = new Set([
29
+ 'slug', 'id', 'type', 'status', 'href', 'url', 'src', 'icon',
30
+ 'target', 'email', 'phone', 'orcid', 'doi', 'arxiv', 'isbn',
31
+ 'pmid', 'bibtex', 'pdf', 'code', 'data', 'slides', 'video',
32
+ 'repository', 'caseStudy', 'website', 'avatar', 'image',
33
+ 'thumbnail', 'currency', 'order', 'hidden', 'current',
34
+ 'featured', 'published', 'allDay', 'remote', 'hybrid',
35
+ 'noindex', 'corresponding', 'required', 'virtual',
36
+ 'lastModified', 'date', 'updated', 'posted', 'submitted',
37
+ 'accepted', 'startDate', 'endDate', 'deadline',
38
+ 'readTime', 'citations', 'capacity', 'volume', 'issue', 'pages',
39
+ 'time', 'timezone',
40
+ ])
41
+
42
+ /** String patterns that indicate non-translatable values. */
43
+ export const HEURISTIC_SKIP_PATTERNS = [
44
+ /^https?:\/\//, // URLs
45
+ /^mailto:/, // mailto links
46
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/, // email addresses
47
+ /^\d{4}-\d{2}-\d{2}/, // ISO dates
48
+ /^#[0-9a-fA-F]{3,8}$/, // hex colors
49
+ /^[\w./\\-]+\.\w{2,4}$/, // file paths (e.g., ./logo.svg, /img/hero.jpg)
50
+ /^[A-Z]{3}$/, // currency codes (USD, EUR)
51
+ /^\d+(\.\d+)?$/, // plain numbers as strings
52
+ /^\d{1,2}:\d{2}(:\d{2})?$/, // times (09:00, 14:30:00)
53
+ ]
54
+
55
+ /** Max recursion depth for heuristic extraction. */
56
+ export const MAX_HEURISTIC_DEPTH = 5
57
+
58
+ /** Whether a string value looks structural rather than human-readable. */
59
+ export function isStructuralString(value) {
60
+ return HEURISTIC_SKIP_PATTERNS.some((pattern) => pattern.test(value))
61
+ }
62
+
63
+ /**
64
+ * Walk structured data and hand every translatable-looking STRING to `visit`.
65
+ *
66
+ * One walker for both directions, the same discipline the document walk uses:
67
+ * extraction returns nothing and collects, application returns a replacement and
68
+ * the walker writes it back. Two copies of a rule this fiddly is how the
69
+ * extracted set and the applied set end up different, which fails as content
70
+ * that appears in the manifest, gets translated by a human, and still renders in
71
+ * the source language.
72
+ *
73
+ * Mutates `data` in place when `visit` returns a different string, so callers
74
+ * that must not mutate should pass a clone.
75
+ *
76
+ * @param {*} data - any parsed YAML/JSON value
77
+ * @param {(value: string, path: string) => string|void} visit
78
+ * @param {string} [path] - dotted path, for the unit's `field` metadata
79
+ * @param {number} [depth]
80
+ */
81
+ export function visitDataStrings(data, visit, path = '', depth = 0) {
82
+ if (!data || typeof data !== 'object' || depth > MAX_HEURISTIC_DEPTH) return
83
+
84
+ const isArray = Array.isArray(data)
85
+ const keys = isArray ? data.map((_, i) => i) : Object.keys(data)
86
+
87
+ for (const key of keys) {
88
+ const value = data[key]
89
+ if (value === undefined || value === null) continue
90
+
91
+ const fieldPath = isArray
92
+ ? `${path}[${key}]`
93
+ : path
94
+ ? `${path}.${key}`
95
+ : String(key)
96
+
97
+ if (typeof value === 'string') {
98
+ // A skip-list entry applies to a NAMED field. Inside an array the key is
99
+ // an index and carries no meaning, so only the value patterns apply —
100
+ // otherwise a list of prose strings would be skipped by position.
101
+ if (!isArray && HEURISTIC_SKIP_FIELDS.has(key)) continue
102
+ if (isStructuralString(value)) continue
103
+ if (!value.trim()) continue
104
+
105
+ const replacement = visit(value, fieldPath)
106
+ if (typeof replacement === 'string' && replacement !== value) {
107
+ data[key] = replacement
108
+ }
109
+ } else if (typeof value === 'object') {
110
+ visitDataStrings(value, visit, fieldPath, depth + 1)
111
+ }
112
+ // numbers and booleans are never prose
113
+ }
114
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { resolveDefaultLocale } from '@uniweb/core'
9
9
  import { computeHash, stripInlineTags } from './hash.js'
10
+ import { visitDataStrings } from './data-strings.js'
10
11
 
11
12
  /**
12
13
  * Extract all translatable units from site content
@@ -130,6 +131,7 @@ function extractFromSection(section, pageRoute, units) {
130
131
 
131
132
  if (section.content?.type === 'doc') {
132
133
  extractFromProseMirrorDoc(section.content, context, units)
134
+ extractFromDataBlocks(section.content, context, units)
133
135
  }
134
136
 
135
137
  // Recursively process subsections
@@ -138,6 +140,61 @@ function extractFromSection(section, pageRoute, units) {
138
140
  }
139
141
  }
140
142
 
143
+ /**
144
+ * Extract the human-readable strings inside a section's TAGGED DATA BLOCKS.
145
+ *
146
+ * A ```yaml:nav block's link labels, a ```yaml:pricing block's plan names — the
147
+ * strings an author most expects to see translated, and which stayed in the
148
+ * source language on every multilingual site until 2026-07-30 because nothing
149
+ * in this file mentioned `dataBlock`.
150
+ *
151
+ * Which strings count is not this file's judgement to make: it is the same
152
+ * question collections answer for a record's fields, so the same detection runs
153
+ * here (`data-strings.js`).
154
+ *
155
+ * DELIBERATELY SEPARATE from `extractFromProseMirrorDoc`, and not folded into
156
+ * it. That function is also `extractUnitsFromDoc`, which publishes the
157
+ * structural-keying contract pinned by tests/i18n/structural-keying-vectors.json
158
+ * — units keyed by a BLOCK ELEMENT's text. A data string is not a block
159
+ * element's text and has no place in that map. The consequence is real rather
160
+ * than tidy: `deriveStructuralMap` recovers a pulled translation by walking
161
+ * block elements, so a data string translated on the sync wire would be
162
+ * invisible to it — neither captured in the map nor counted as divergence — and
163
+ * would be silently lost on the next pull. So this lane is the BUILD lane only
164
+ * (the manifest, and `dist/{locale}/`), which is where the reported bug lives.
165
+ * Carrying data-block translations across the sync wire needs a representation
166
+ * that contract does not have yet.
167
+ */
168
+ function extractFromDataBlocks(doc, context, units) {
169
+ for (const node of dataBlockNodes(doc)) {
170
+ const { tag, data } = node.attrs || {}
171
+ if (!tag || !data || typeof data !== 'object') continue
172
+
173
+ visitDataStrings(data, (value, fieldPath) => {
174
+ addUnit(units, value, `data.${tag}.${fieldPath}`, context)
175
+ })
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Every tagged data block in a doc, including any nested inside a container.
181
+ */
182
+ function dataBlockNodes(doc) {
183
+ const out = []
184
+ const walk = (nodes) => {
185
+ for (const node of nodes || []) {
186
+ if (!node) continue
187
+ if (node.type === 'dataBlock') out.push(node)
188
+ else if (CONTAINER_BLOCKS.has(node.type)) walk(node.content)
189
+ else if (node.type === 'bulletList' || node.type === 'orderedList') {
190
+ for (const listItem of node.content || []) walk(listItem.content)
191
+ }
192
+ }
193
+ }
194
+ walk(doc?.content)
195
+ return out
196
+ }
197
+
141
198
  /**
142
199
  * Extract translatable strings from ProseMirror document
143
200
  * @param {Object} doc - ProseMirror document
@@ -165,28 +222,103 @@ function extractFromProseMirrorDoc(doc, context, units) {
165
222
  let headingIndex = { h1: 0, h2: 0, h3: 0, h4: 0 }
166
223
  let paragraphIndex = 0
167
224
 
168
- for (const node of doc.content) {
225
+ visitTranslatableBlocks(doc.content, (node, listIndex) => {
169
226
  if (node.type === 'heading') {
170
227
  const text = elementText(node)
171
- if (!text) continue
228
+ if (!text) return
172
229
 
173
230
  const level = node.attrs?.level || 1
174
231
  const field = getHeadingField(level, headingIndex)
175
232
  headingIndex[`h${level}`]++
176
233
 
177
234
  addUnit(units, text, field, context)
178
- } else if (node.type === 'paragraph') {
179
- // Whole-element keying: ONE unit per paragraph, with link text kept INLINE
180
- // (not split into a separate link.label unit). The conformance gate is
181
- // tests/i18n/structural-keying-vectors.json (vectors A–H).
182
- const text = elementText(node)
183
- if (text) {
184
- const field = paragraphIndex === 0 ? 'paragraph' : `paragraph.${paragraphIndex}`
185
- addUnit(units, text, field, context)
186
- paragraphIndex++
187
- }
235
+ return
236
+ }
237
+
238
+ // Whole-element keying: ONE unit per paragraph, with link text kept INLINE
239
+ // (not split into a separate link.label unit). The conformance gate is
240
+ // tests/i18n/structural-keying-vectors.json.
241
+ const text = elementText(node)
242
+ if (!text) return
243
+
244
+ if (listIndex !== null) {
245
+ addUnit(units, text, `list.${listIndex}`, context)
246
+ return
247
+ }
248
+
249
+ const field = paragraphIndex === 0 ? 'paragraph' : `paragraph.${paragraphIndex}`
250
+ addUnit(units, text, field, context)
251
+ paragraphIndex++
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Block node types whose children carry translatable prose, so the walk has to
257
+ * descend into them.
258
+ *
259
+ * WHY THIS EXISTS. The walk used to handle four node types and recurse into
260
+ * nothing, so a string inside ANY container was invisible to translation: a
261
+ * callout's body, a table cell, a blockquote. On a multilingual site those
262
+ * silently stayed in the source language, and nothing reported it. That
263
+ * predates concept blocks — adding one more prose container without fixing the
264
+ * walk would have inherited the hole in the place it hurts most, since a
265
+ * concept block is prose, which is the thing translation exists for.
266
+ *
267
+ * An explicit set rather than "recurse into anything with content", because the
268
+ * denylist version is the dangerous one: `codeBlock` also has content, and
269
+ * extracting source code as translatable prose would be worse than missing it.
270
+ *
271
+ * KEEP IN SYNC with the editor's own container list. It maintains a second
272
+ * implementation of this walk over the same documents and already recursed
273
+ * containers when this one did not, so the two disagreed — the shared vectors
274
+ * in tests/i18n/structural-keying-vectors.json are the only thing pinning them
275
+ * together, and they are kept in step by hand.
276
+ */
277
+ export const CONTAINER_BLOCKS = new Set([
278
+ 'concept_block', // ```md:<tag> — a concept's body is authored prose
279
+ 'inset_block', // ```@Component{params} — a callout's body is authored prose
280
+ 'blockquote',
281
+ 'table',
282
+ 'tableRow',
283
+ 'tableCell',
284
+ ])
285
+
286
+ /**
287
+ * Walk a content array and hand every translatable BLOCK element to `visit`, in
288
+ * document order, descending into containers.
289
+ *
290
+ * ONE walker with two consumers, deliberately. Extraction (into the manifest)
291
+ * and resolution (applying a translation) used to be two separate walks over
292
+ * the same four node types. Two copies of one rule is the shape that fails
293
+ * halfway: teach only the extractor about a container and its strings reach the
294
+ * manifest but are never applied; teach only the resolver and there is nothing
295
+ * to apply. Neither half fails loudly. They cannot drift now because there is
296
+ * only one of them.
297
+ *
298
+ * @param {Array} nodes - a content array
299
+ * @param {(node: Object, listIndex: number|null) => void} visit
300
+ */
301
+ function visitTranslatableBlocks(nodes, visit) {
302
+ for (const node of nodes || []) {
303
+ if (!node) continue
304
+
305
+ if (node.type === 'heading' || node.type === 'paragraph') {
306
+ visit(node, null)
188
307
  } else if (node.type === 'bulletList' || node.type === 'orderedList') {
189
- extractFromList(node, context, units)
308
+ // A list item's index is part of its unit's field name, so lists keep
309
+ // their own branch rather than folding into the container recursion.
310
+ ;(node.content || []).forEach((listItem, index) => {
311
+ if (listItem.type !== 'listItem') return
312
+ for (const child of listItem.content || []) {
313
+ if (child.type === 'paragraph') {
314
+ visit(child, index)
315
+ } else if (CONTAINER_BLOCKS.has(child.type)) {
316
+ visitTranslatableBlocks([child], visit)
317
+ }
318
+ }
319
+ })
320
+ } else if (CONTAINER_BLOCKS.has(node.type)) {
321
+ visitTranslatableBlocks(node.content, visit)
190
322
  }
191
323
  }
192
324
  }
@@ -203,27 +335,6 @@ function getHeadingField(level, index) {
203
335
  return `heading.h${level}.${index[`h${level}`]}`
204
336
  }
205
337
 
206
- /**
207
- * Extract from list items — one whole-element unit per list item (link text
208
- * stays inline, same rule as paragraphs; vectors G and H).
209
- */
210
- function extractFromList(listNode, context, units) {
211
- if (!listNode.content) return
212
-
213
- listNode.content.forEach((listItem, index) => {
214
- if (listItem.type === 'listItem' && listItem.content) {
215
- for (const child of listItem.content) {
216
- if (child.type === 'paragraph') {
217
- const text = elementText(child)
218
- if (text) {
219
- addUnit(units, text, `list.${index}`, context)
220
- }
221
- }
222
- }
223
- }
224
- })
225
- }
226
-
227
338
  /**
228
339
  * A block element's cleaned source text — the WHOLE-ELEMENT translation key.
229
340
  * ALL inline marks (bold, italic, link, span, …) flatten into the text: link
@@ -259,27 +370,14 @@ function collectInlineText(node) {
259
370
 
260
371
  /**
261
372
  * The translatable block elements of a content doc, in document order, with the
262
- * SAME coverage as extraction above (headings, paragraphs, and each list item's
263
- * paragraphs). Shared by the merge resolver (push) and the pull-side
264
- * structural-map derivation so all paths walk identically and keys never drift.
265
- * Returns the element nodes themselves — callers read `.type`/`.content` and key
266
- * them via elementText.
373
+ * SAME coverage as extraction above because it is the same walk. Shared by the
374
+ * merge resolver (push) and the pull-side structural-map derivation so all paths
375
+ * walk identically and keys never drift. Returns the element nodes themselves —
376
+ * callers read `.type`/`.content` and key them via elementText.
267
377
  */
268
378
  export function blockElements(doc) {
269
379
  const out = []
270
- for (const node of doc?.content || []) {
271
- if (node.type === 'heading' || node.type === 'paragraph') {
272
- out.push(node)
273
- } else if (node.type === 'bulletList' || node.type === 'orderedList') {
274
- for (const listItem of node.content || []) {
275
- if (listItem.type === 'listItem' && listItem.content) {
276
- for (const child of listItem.content) {
277
- if (child.type === 'paragraph') out.push(child)
278
- }
279
- }
280
- }
281
- }
282
- }
380
+ visitTranslatableBlocks(doc?.content, (node) => out.push(node))
283
381
  return out
284
382
  }
285
383
 
package/src/i18n/merge.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { computeHash } from './hash.js'
16
16
  import { loadFreeformTranslation } from './freeform.js'
17
17
  import { elementText, blockElements } from './extract.js'
18
+ import { visitDataStrings } from './data-strings.js'
18
19
 
19
20
  // Inline-markdown → ProseMirror inline fragment, for resolving a whole-element
20
21
  // translation VALUE (which carries marks/links/icons as inline markdown). Same
@@ -227,6 +228,7 @@ function translateSectionSync(section, pageRoute, translations, fallbackToSource
227
228
 
228
229
  if (section.content?.type === 'doc') {
229
230
  translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
231
+ translateDataBlocks(section.content, context, translations, fallbackToSource)
230
232
  }
231
233
 
232
234
  // Recursively translate subsections
@@ -259,6 +261,7 @@ async function translateSectionAsync(section, page, translations, options) {
259
261
  // Fall back to hash-based translation
260
262
  if (section.content?.type === 'doc') {
261
263
  translateProseMirrorDoc(section.content, context, translations, fallbackToSource)
264
+ translateDataBlocks(section.content, context, translations, fallbackToSource)
262
265
  }
263
266
  }
264
267
 
@@ -302,6 +305,37 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
302
305
  return false
303
306
  }
304
307
 
308
+ /**
309
+ * Translate the human-readable strings inside a section's TAGGED DATA BLOCKS,
310
+ * in place. The other half of the fix in `extract.js` — a manifest entry nobody
311
+ * applies is worse than no entry, because a translator has already done the work.
312
+ *
313
+ * Called from the two BUILD-lane section walks only, deliberately, and NOT from
314
+ * `resolveDocForLocale`. See `extractFromDataBlocks` for why: the sync wire's
315
+ * structural map is derived by walking block elements, so a translated data
316
+ * payload there would be neither captured nor flagged as divergent, and would be
317
+ * dropped on the next pull. Losing a translation silently is worse than not
318
+ * carrying one yet.
319
+ */
320
+ function translateDataBlocks(doc, context, translations, fallbackToSource) {
321
+ const walk = (nodes) => {
322
+ for (const node of nodes || []) {
323
+ if (!node) continue
324
+ if (node.type === 'dataBlock') {
325
+ const data = node.attrs?.data
326
+ if (data && typeof data === 'object') {
327
+ visitDataStrings(data, (value) =>
328
+ lookupTranslation(value, context, translations, fallbackToSource)
329
+ )
330
+ }
331
+ } else if (Array.isArray(node.content)) {
332
+ walk(node.content)
333
+ }
334
+ }
335
+ }
336
+ walk(doc?.content)
337
+ }
338
+
305
339
  /**
306
340
  * Resolve ONE ProseMirror content doc for a single target locale: a deep clone of
307
341
  * the source doc with each whole-element translated (inline content replaced from
package/src/index.js CHANGED
@@ -18,6 +18,7 @@ export {
18
18
  export {
19
19
  validateItem,
20
20
  validateDataInputs,
21
+ validateConceptBlocks,
21
22
  isStaticallyCheckable,
22
23
  } from './validate-data.js'
23
24
 
package/src/prerender.js CHANGED
@@ -11,7 +11,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
11
11
  import { existsSync, readdirSync, statSync } from 'node:fs'
12
12
  import { join, dirname, resolve } from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
- import { resolveDefaultLocale } from '@uniweb/core'
14
+ import { resolveDefaultLocale, isDataUrl } from '@uniweb/core'
15
15
  import { executeFetch, mergeDataIntoContent } from './site/data-fetcher.js'
16
16
  import { shouldSplitContent } from './site/split-content.js'
17
17
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
@@ -81,7 +81,7 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
81
81
  localeInfo.distDir
82
82
 
83
83
  function localizeFetch(config) {
84
- if (!isNonDefaultLocale || !config.path?.startsWith('/data/')) return config
84
+ if (!isNonDefaultLocale || !isDataUrl(config.path)) return config
85
85
  return { ...config, path: `/${localeInfo.locale}${config.path}` }
86
86
  }
87
87
 
@@ -381,35 +381,19 @@ export function scopeFetchedData(fetchedData, scopeRoutes) {
381
381
  export function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null, scopeRoutes = null } = {}) {
382
382
  let result = html
383
383
 
384
- // Inject the theme's font <link> tags if not already present (the vite
385
- // plugin normally puts them there when it builds index.html)
384
+ // Neither the theme <style> NOR the font <link>s are injected here anymore.
385
+ // Both are derived from the website graph (`website.themeData`), so they
386
+ // belong to the shared seam — @uniweb/runtime/ssr's injectPageContent(),
387
+ // which runs just before this — and every lane gets them from one
388
+ // implementation. The theme CSS sat here until 2026-07-28 and cloud-rendered
389
+ // pages were unstyled the whole time; the font links followed once
390
+ // FONT_LINKS_MARKER moved to @uniweb/theming, the one package both this and
391
+ // the runtime can read it from.
386
392
  //
387
- // STILL HERE, and it is the remaining half of the 2026-07-28 seam fix.
388
- // `theme.links` is graph-derived like the theme CSS was, so by the rule
389
- // below it belongs in injectPageContent() too a lane that doesn't run
390
- // this build gets no webfont <link>s and falls back to system fonts.
391
- // What blocks the move is the marker, not the principle: FONT_LINKS_MARKER
392
- // has a second consumer in site/plugin.js (the vite plugin), which must not
393
- // import the SSR bundle, and @uniweb/build declares no dependency on
394
- // @uniweb/runtime — so there is no one place both sides can read it from
395
- // today. Fixing that means giving the runtime a zero-dep leaf export for
396
- // the constant and re-exporting it here. Degraded-not-broken (wrong
397
- // typeface, not unstyled), which is why it was scoped out rather than
398
- // rushed.
399
- if (siteContent?.theme?.links && !result.includes(FONT_LINKS_MARKER)) {
400
- result = result.replace(
401
- '</head>',
402
- ` ${FONT_LINKS_MARKER}\n${siteContent.theme.links}\n </head>`
403
- )
404
- }
405
-
406
- // Theme CSS is NOT injected here. Like the appearance boot script below, it
407
- // is derived from the website graph (`website.themeData`), so it belongs to
408
- // the shared seam — @uniweb/runtime/ssr's injectPageContent(), which runs
409
- // just before this — and every lane gets it from one implementation. It sat
410
- // here until 2026-07-28 and cloud-rendered pages were unstyled the whole
411
- // time. See the "which side of the seam?" note there before adding a new
412
- // head injection, and note that this file has now got it wrong twice.
393
+ // See the "which side of the seam?" note there before adding a new head
394
+ // injection, and note that this file has now got it wrong twice. The guard
395
+ // against a third is mechanical: tests/head-seam-parity.test.js fails if this
396
+ // function's <head> output gains anything outside its BUILD_ONLY allowlist.
413
397
 
414
398
  // The pre-paint appearance script is NOT injected here. It belongs to the
415
399
  // shared seam — @uniweb/runtime/ssr's injectPageContent(), which runs just