@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 +9 -8
- package/src/i18n/collections.js +23 -52
- package/src/i18n/data-strings.js +114 -0
- package/src/i18n/extract.js +150 -52
- package/src/i18n/merge.js +34 -0
- package/src/index.js +1 -0
- package/src/prerender.js +14 -30
- package/src/site/build-site-data.js +23 -4
- package/src/site/collection-processor.js +85 -7
- package/src/site/content-collector.js +4 -3
- package/src/site/data-ball.js +2 -1
- package/src/site/data-fetcher.js +2 -2
- package/src/site/head-markers.js +16 -6
- package/src/site/plugin.js +112 -15
- package/src/uwx/locale-sync.js +37 -16
- package/src/uwx/site.js +10 -7
- package/src/validate-data.js +167 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
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.
|
|
68
|
-
"@uniweb/
|
|
69
|
-
"@uniweb/
|
|
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.
|
|
79
|
+
"@uniweb/core": "0.8.0"
|
|
79
80
|
},
|
|
80
81
|
"peerDependenciesMeta": {
|
|
81
82
|
"vite": {
|
package/src/i18n/collections.js
CHANGED
|
@@ -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
|
-
|
|
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',
|
|
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',
|
|
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,
|
|
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
|
+
}
|
package/src/i18n/extract.js
CHANGED
|
@@ -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
|
-
|
|
225
|
+
visitTranslatableBlocks(doc.content, (node, listIndex) => {
|
|
169
226
|
if (node.type === 'heading') {
|
|
170
227
|
const text = elementText(node)
|
|
171
|
-
if (!text)
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
|
|
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
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
|
|
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
|
-
//
|
|
385
|
-
//
|
|
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
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|