@uniweb/build 0.63.3 → 0.64.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 +8 -8
- package/src/i18n/extract.js +24 -7
- package/src/i18n/freeform.js +2 -1
- package/src/i18n/index.js +5 -32
- package/src/i18n/locales.js +51 -0
- package/src/i18n/merge.js +22 -17
- package/src/i18n/records.js +389 -161
- package/src/site/content-collector.js +33 -2
- package/src/site/layout-folder.js +15 -0
- package/src/site/query-processor.js +13 -2
- package/src/uwx/asset-map.js +10 -3
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/index.js +3 -0
- package/src/uwx/locale-sync.js +267 -36
- package/src/uwx/project-writer.js +106 -31
- package/src/uwx/record-identity.js +46 -0
- package/src/uwx/records-project.js +109 -22
- package/src/uwx/records.js +19 -16
- package/src/uwx/same-content.js +43 -0
- package/src/uwx/site-project.js +322 -38
- package/src/uwx/site.js +151 -36
|
@@ -26,7 +26,7 @@ import { readFile, readdir, stat } from 'node:fs/promises'
|
|
|
26
26
|
import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
|
|
27
27
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
28
28
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
29
|
-
import { readLayoutFolder, DEFAULT_LAYOUT } from './layout-folder.js'
|
|
29
|
+
import { readLayoutFolder, DEFAULT_LAYOUT, layoutAreaRoute } from './layout-folder.js'
|
|
30
30
|
import { join, parse, relative, resolve, sep } from 'node:path'
|
|
31
31
|
import { existsSync, statSync, realpathSync, readdirSync } from 'node:fs'
|
|
32
32
|
import yaml from 'js-yaml'
|
|
@@ -2419,7 +2419,7 @@ async function collectLayouts(layoutDir, siteRoot) {
|
|
|
2419
2419
|
assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
|
|
2420
2420
|
iconCollection = mergeIconCollections(iconCollection, result.iconCollection)
|
|
2421
2421
|
}
|
|
2422
|
-
const route = area.layout
|
|
2422
|
+
const route = layoutAreaRoute(area.layout, area.area)
|
|
2423
2423
|
layouts[area.layout] ??= {}
|
|
2424
2424
|
layouts[area.layout][area.area] = {
|
|
2425
2425
|
route,
|
|
@@ -2693,6 +2693,11 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
2693
2693
|
for (const { message } of langValidation.warnings) {
|
|
2694
2694
|
console.warn(`[content-collector] ${message}`)
|
|
2695
2695
|
}
|
|
2696
|
+
// Errors stop a publish build (below); in dev they are said, so the author meets them here
|
|
2697
|
+
// rather than first at a push.
|
|
2698
|
+
if (!dropUnpublished) {
|
|
2699
|
+
for (const { message } of langValidation.errors) console.warn(`[content-collector] ${message}`)
|
|
2700
|
+
}
|
|
2696
2701
|
// Publish filter (the sync format's "Per-locale publish
|
|
2697
2702
|
// readiness"): on published build paths, only the publishable intersection
|
|
2698
2703
|
// (publishLanguages ∩ languages; absent field = all declared) ships — in
|
|
@@ -2956,9 +2961,35 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
|
|
|
2956
2961
|
return Object.keys(result).length > 0 ? result : null
|
|
2957
2962
|
}
|
|
2958
2963
|
|
|
2964
|
+
/**
|
|
2965
|
+
* One level's pages in the order a push walks them, and a pull reproduces: explicit `order:`, then
|
|
2966
|
+
* numeric-prefix filename order, then the parent config's `pages:` list. Shared by the push's walk
|
|
2967
|
+
* (`uwx/site.js::orderedSubfolders`) and the pull (`uwx/site-project.js`), so the two cannot
|
|
2968
|
+
* disagree on what a level's order is.
|
|
2969
|
+
*
|
|
2970
|
+
* ⚠️ The build's own page-folder sort (`collectPagesRecursive`) is not this function: it compares
|
|
2971
|
+
* folder names whole, as strings. The two differ only for numbered page folders — see the note at
|
|
2972
|
+
* `orderedSubfolders`.
|
|
2973
|
+
*
|
|
2974
|
+
* @param {Array<{ dirName: string, name: string, order?: number }>} folders
|
|
2975
|
+
* @param {Array} [parentPages] - the parent config's `pages:`
|
|
2976
|
+
* @returns {Array} the folders, ordered
|
|
2977
|
+
*/
|
|
2978
|
+
function orderFolders(folders, parentPages) {
|
|
2979
|
+
const sorted = [...folders].sort(
|
|
2980
|
+
(a, b) => (a.order ?? Infinity) - (b.order ?? Infinity) || compareFilenames(a.dirName, b.dirName)
|
|
2981
|
+
)
|
|
2982
|
+
const parsed = Array.isArray(parentPages) ? parseWildcardArray(parentPages) : null
|
|
2983
|
+
return parsed && parsed.mode !== 'all' ? applyWildcardOrder(sorted, parsed) : sorted
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2959
2986
|
export {
|
|
2960
2987
|
assertRouteFolder,
|
|
2961
2988
|
buildRouteTranslations,
|
|
2989
|
+
composeLocalizedRoute,
|
|
2990
|
+
isValidSlugSegment,
|
|
2991
|
+
declaredHomepage,
|
|
2992
|
+
orderFolders,
|
|
2962
2993
|
extractItemName,
|
|
2963
2994
|
parseWildcardArray,
|
|
2964
2995
|
applyWildcardOrder,
|
|
@@ -40,6 +40,21 @@ import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
|
40
40
|
/** The key the default layout's areas are held under, on every lane. */
|
|
41
41
|
export const DEFAULT_LAYOUT = 'default'
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The route a layout area renders at — `/layout/<area>` in the default layout,
|
|
45
|
+
* `/layout/<layout>/<area>` in a named one. A layout area's free-form translations are
|
|
46
|
+
* addressed by it (`i18n/freeform.js`), so the build, the push and the pull must all use
|
|
47
|
+
* this one rule: the push said "layout sections have no free-form home" until 2026-09-26,
|
|
48
|
+
* and a translation the build rendered never left the author's machine.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} [layoutName]
|
|
51
|
+
* @param {string} area
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
export function layoutAreaRoute(layoutName, area) {
|
|
55
|
+
return !layoutName || layoutName === DEFAULT_LAYOUT ? `/layout/${area}` : `/layout/${layoutName}/${area}`
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
/**
|
|
44
59
|
* Area names a layout folder is most likely to have been meant as. A folder under
|
|
45
60
|
* `layout/` named like one of them was, before this convention, an area of the
|
|
@@ -186,23 +186,34 @@ function extractPlainText(node) {
|
|
|
186
186
|
if (node.type === 'text') {
|
|
187
187
|
return node.text || ''
|
|
188
188
|
}
|
|
189
|
+
if (node.type === 'hardBreak' || node.type === 'hard_break') return ' '
|
|
189
190
|
|
|
190
191
|
if (Array.isArray(node.content)) {
|
|
191
|
-
|
|
192
|
+
// A textblock's inline children join as written; blocks are separated by a space. ⛔ Until
|
|
193
|
+
// 2026-09-26 blocks joined with nothing too, so an excerpt ran one paragraph into the next
|
|
194
|
+
// ("…in 1816.The river…").
|
|
195
|
+
const inline = node.content.every((child) => child?.type === 'text' || INLINE_NODES.has(child?.type))
|
|
196
|
+
return node.content.map(extractPlainText).join(inline ? '' : ' ')
|
|
192
197
|
}
|
|
193
198
|
|
|
194
199
|
return ''
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
// The inline nodes a textblock holds beside text.
|
|
203
|
+
const INLINE_NODES = new Set(['hardBreak', 'hard_break', 'image', 'inset_ref', 'inset_placeholder', 'icon'])
|
|
204
|
+
|
|
197
205
|
/**
|
|
198
206
|
* Extract excerpt from content
|
|
199
207
|
*
|
|
208
|
+
* Exported for the localized build (`i18n/records.js`), which derives each locale's
|
|
209
|
+
* excerpt from that locale's body by this same rule.
|
|
210
|
+
*
|
|
200
211
|
* @param {Object} frontmatter - Parsed frontmatter
|
|
201
212
|
* @param {Object} content - ProseMirror content
|
|
202
213
|
* @param {Object} excerptConfig - Excerpt configuration
|
|
203
214
|
* @returns {string} Excerpt text
|
|
204
215
|
*/
|
|
205
|
-
function extractExcerpt(frontmatter, content, excerptConfig) {
|
|
216
|
+
export function extractExcerpt(frontmatter, content, excerptConfig) {
|
|
206
217
|
const { maxLength = 160, field = null } = excerptConfig || {}
|
|
207
218
|
|
|
208
219
|
// Check for explicit excerpt in frontmatter
|
package/src/uwx/asset-map.js
CHANGED
|
@@ -70,9 +70,14 @@ export function servedFingerprint(url) {
|
|
|
70
70
|
* that works, not a local path to a file that is not there. Filling those in is
|
|
71
71
|
* the download's job, not this one.
|
|
72
72
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
73
|
+
* ⭐ **And the identity goes with the URL it stood beside.** A restored reference is
|
|
74
|
+
* the author's path again, and that path's id is kept in `sync.json`, which the next
|
|
75
|
+
* push stamps from (`rewriteEntityAssets`). ⛔ Until 2026-09-26 `assetId` was left in
|
|
76
|
+
* place, on the ground that the markdown serializer drops it — true of a body, and of
|
|
77
|
+
* nothing else: a pull into the copy that pushed wrote `assetId`/`assetExt` into the
|
|
78
|
+
* frontmatter of every section whose background it restored, and a body holding a
|
|
79
|
+
* pushed image never compared as unchanged, since the author's markdown parses with
|
|
80
|
+
* no id. An id the map does not know keeps its identity, with its URL.
|
|
76
81
|
*
|
|
77
82
|
* @param {object} document - the site-content document (mutated in place)
|
|
78
83
|
* @param {Record<string, { id: string, ext: string }>} map - readAssetMap()
|
|
@@ -98,6 +103,8 @@ export function restoreAssetRefs(document, map) {
|
|
|
98
103
|
if (!ref) { stats.unknown++; continue }
|
|
99
104
|
const urlAttr = slot.urls.find((k) => typeof node[k] === 'string') || slot.urls[0]
|
|
100
105
|
node[urlAttr] = ref
|
|
106
|
+
delete node[slot.id]
|
|
107
|
+
delete node[slot.ext]
|
|
101
108
|
stats.restored++
|
|
102
109
|
}
|
|
103
110
|
for (const v of Object.values(node)) visit(v)
|
package/src/uwx/backfill.js
CHANGED
|
@@ -254,7 +254,7 @@ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArr
|
|
|
254
254
|
* it has no name for is written as the uuid.
|
|
255
255
|
* @returns {string} the source-file text
|
|
256
256
|
*/
|
|
257
|
-
export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName }) {
|
|
257
|
+
export function renderEntityDocument({ document, declaration, format, sourceLocale = 'en', collector, freeformRelPath, refName, context = null }) {
|
|
258
258
|
const layout = recordFileLayout(declaration)
|
|
259
259
|
if (layout.sections.length === 0) {
|
|
260
260
|
throw new Error('uwx/render: the data schema declares no sections')
|
|
@@ -267,7 +267,7 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
|
|
|
267
267
|
}
|
|
268
268
|
// Markdown: the content body field, wherever its single section is, becomes the body.
|
|
269
269
|
const { target } = contentBodyTarget(declaration)
|
|
270
|
-
const dec = { sourceLocale, collector, freeformRelPath, refName, bodyAt: format === 'md' ? target : null, body: '' }
|
|
270
|
+
const dec = { sourceLocale, collector, freeformRelPath, refName, context, bodyAt: format === 'md' ? target : null, body: '' }
|
|
271
271
|
|
|
272
272
|
const record = {}
|
|
273
273
|
if (document?.$uuid) record.$uuid = document.$uuid
|
|
@@ -338,16 +338,16 @@ function decodeLeaf(raw, field, dec, isBody) {
|
|
|
338
338
|
if (field?.type === 'entity_ref') return decodeReference(raw, field, dec)
|
|
339
339
|
if (isProseMirrorField(field)) {
|
|
340
340
|
const sourceDoc = field.localized
|
|
341
|
-
? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined)
|
|
341
|
+
? unwrapLocalizedContent(raw, dec.sourceLocale, dec.collector, isBody ? dec.freeformRelPath : undefined, null, dec.context)
|
|
342
342
|
: raw
|
|
343
343
|
return sourceDoc ? proseMirrorToMarkdown(sourceDoc) : ''
|
|
344
344
|
}
|
|
345
345
|
if (!field?.localized) return raw
|
|
346
346
|
if (Array.isArray(raw)) {
|
|
347
|
-
if (!isBody) for (const item of raw) dec.collector?.add(item)
|
|
347
|
+
if (!isBody) for (const item of raw) dec.collector?.add(item, dec.context)
|
|
348
348
|
return unwrapLocalizedList(raw, dec.sourceLocale)
|
|
349
349
|
}
|
|
350
|
-
if (!isBody) dec.collector?.add(raw)
|
|
350
|
+
if (!isBody) dec.collector?.add(raw, dec.context)
|
|
351
351
|
return unwrapLocalized(raw, dec.sourceLocale)
|
|
352
352
|
}
|
|
353
353
|
|
package/src/uwx/index.js
CHANGED
|
@@ -71,7 +71,10 @@ export {
|
|
|
71
71
|
declarationsToQueriesYml,
|
|
72
72
|
folderToFolderYml,
|
|
73
73
|
findRecordFileByUuid,
|
|
74
|
+
indexFolder,
|
|
74
75
|
} from './records-project.js'
|
|
76
|
+
// Which stored record each of a copy's records is, when its map lost the answer.
|
|
77
|
+
export { matchStoredRecords } from './record-identity.js'
|
|
75
78
|
// The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
|
|
76
79
|
// up front and hands it to the synchronous projections above.
|
|
77
80
|
export { siteSelfScope } from './self-scope.js'
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -16,22 +16,52 @@
|
|
|
16
16
|
// surface, used by the projector (pull → files) and, later, the producer
|
|
17
17
|
// (files → push).
|
|
18
18
|
|
|
19
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
|
20
20
|
import { join, dirname } from 'node:path'
|
|
21
|
+
import yaml from 'js-yaml'
|
|
22
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
21
23
|
import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-writer'
|
|
22
24
|
import { computeHash } from '../i18n/hash.js'
|
|
23
|
-
import { blockElements, elementText } from '../i18n/extract.js'
|
|
25
|
+
import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
|
|
26
|
+
import { visitDataStrings } from '../i18n/data-strings.js'
|
|
24
27
|
import { resolveDocForLocale } from '../i18n/merge.js'
|
|
25
28
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
29
|
+
import { sameMarkdownDocument, canonicalJson } from './same-content.js'
|
|
26
30
|
|
|
27
31
|
const FREEFORM_MANIFEST = '.manifest.json'
|
|
28
32
|
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
+
// The directory a site keeps its translations in — `site.yml`'s `i18n.localesDir`, else `locales`,
|
|
34
|
+
// the rule the build and `uniweb i18n` read it by — for the push and the pull alike. `subdir` scopes a
|
|
35
|
+
// lane: '' → site-content (locales/), 'records' → record translations (locales/records/), matching the
|
|
36
|
+
// i18n manifest layout.
|
|
37
|
+
// ⛔ It was `locales/` whatever the site said until 2026-09-26: a site keeping its translations in
|
|
38
|
+
// `translations/` pushed its source language alone, and a pull wrote into a directory it never read.
|
|
33
39
|
export function localesDir(siteRoot, subdir = '') {
|
|
34
|
-
|
|
40
|
+
const base = join(siteRoot, localesDirName(siteRoot))
|
|
41
|
+
return subdir ? join(base, subdir) : base
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `i18n.localesDir`, read once per version of the file — every translation a pull writes asks for it.
|
|
45
|
+
const localesDirCache = new Map() // siteRoot → { mtimeMs, name }
|
|
46
|
+
function localesDirName(siteRoot) {
|
|
47
|
+
const file = join(siteRoot, 'site.yml')
|
|
48
|
+
let mtimeMs = null
|
|
49
|
+
try {
|
|
50
|
+
mtimeMs = statSync(file).mtimeMs
|
|
51
|
+
} catch {
|
|
52
|
+
return 'locales'
|
|
53
|
+
}
|
|
54
|
+
const cached = localesDirCache.get(siteRoot)
|
|
55
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.name
|
|
56
|
+
let name = 'locales'
|
|
57
|
+
try {
|
|
58
|
+
const dir = yaml.load(readFileSync(file, 'utf8'), YAML_OPTIONS)?.i18n?.localesDir
|
|
59
|
+
if (typeof dir === 'string' && dir.trim()) name = dir.trim()
|
|
60
|
+
} catch {
|
|
61
|
+
// unreadable — the default
|
|
62
|
+
}
|
|
63
|
+
localesDirCache.set(siteRoot, { mtimeMs, name })
|
|
64
|
+
return name
|
|
35
65
|
}
|
|
36
66
|
|
|
37
67
|
export function localeFilePath(siteRoot, locale, subdir = '') {
|
|
@@ -138,7 +168,7 @@ export function loadLocaleTranslations(siteRoot, locales, subdir = '') {
|
|
|
138
168
|
* @param {string[]} [targetLocales]
|
|
139
169
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
140
170
|
*/
|
|
141
|
-
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations) {
|
|
171
|
+
export function localizeContentDoc(doc, sourceLocale, targetLocales, translations, context = undefined) {
|
|
142
172
|
// Non-docs (null, an already-localized map) pass through untouched.
|
|
143
173
|
if (!isProseMirrorDoc(doc)) return doc
|
|
144
174
|
|
|
@@ -147,7 +177,8 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
|
|
|
147
177
|
for (const locale of targetLocales) {
|
|
148
178
|
const table = translations[locale]
|
|
149
179
|
if (!table) continue
|
|
150
|
-
|
|
180
|
+
// With the section's context, so a context-specific override resolves as the build resolves it.
|
|
181
|
+
const resolved = resolveDocForLocale(doc, table, context)
|
|
151
182
|
if (resolved) result[locale] = resolved
|
|
152
183
|
}
|
|
153
184
|
}
|
|
@@ -164,20 +195,46 @@ export function localizeContentDoc(doc, sourceLocale, targetLocales, translation
|
|
|
164
195
|
* @param {string} sourceLocale
|
|
165
196
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
166
197
|
*/
|
|
167
|
-
export function localizeScalar(value, sourceLocale, translations) {
|
|
198
|
+
export function localizeScalar(value, sourceLocale, translations, context = null) {
|
|
168
199
|
if (value == null) return undefined
|
|
169
200
|
if (typeof value === 'object') return value // already a { lang: value } map
|
|
170
201
|
const out = { [sourceLocale]: value }
|
|
171
202
|
if (translations && typeof value === 'string') {
|
|
172
203
|
const hash = computeHash(value)
|
|
173
204
|
for (const [locale, table] of Object.entries(translations)) {
|
|
174
|
-
const tgt = table?.[hash]
|
|
205
|
+
const tgt = translationFor(table?.[hash], context)
|
|
175
206
|
if (typeof tgt === 'string') out[locale] = tgt
|
|
176
207
|
}
|
|
177
208
|
}
|
|
178
209
|
return out
|
|
179
210
|
}
|
|
180
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A translation entry's text in one place: a string as it is; `{ default, overrides }` — a
|
|
214
|
+
* string that reads differently in some places — by the place's key (`contextKey`), else its
|
|
215
|
+
* default. ⛔ Until 2026-09-26 a scalar's entry in that form was dropped whole, its default too:
|
|
216
|
+
* the build rendered it, and a push carried the source alone.
|
|
217
|
+
*
|
|
218
|
+
* @param {*} entry - a translation file's entry
|
|
219
|
+
* @param {{ key?: string, page?: string, section?: string }|null} context - where the value is
|
|
220
|
+
* @returns {string|undefined}
|
|
221
|
+
*/
|
|
222
|
+
export function translationFor(entry, context) {
|
|
223
|
+
if (typeof entry === 'string') return entry
|
|
224
|
+
if (!entry || typeof entry !== 'object') return undefined
|
|
225
|
+
const override = context ? entry.overrides?.[contextKey(context)] : undefined
|
|
226
|
+
return typeof override === 'string' ? override : typeof entry.default === 'string' ? entry.default : undefined
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The key an override names a place by: a record's identity (`<pool>/<handle>`, as `key`), or
|
|
231
|
+
* a page section's `<route>:<id>` (`i18n/extract.js::translationContext`) — as `i18n/merge.js`
|
|
232
|
+
* reads it.
|
|
233
|
+
*/
|
|
234
|
+
export function contextKey(context) {
|
|
235
|
+
return context?.key ?? `${context?.page}:${context?.section}`
|
|
236
|
+
}
|
|
237
|
+
|
|
181
238
|
/**
|
|
182
239
|
* Wrap a `multiple: true` + `localized: true` field (e.g. page `keywords` — "array
|
|
183
240
|
* of localized strings") into per-locale form by localizing each element. Container
|
|
@@ -189,10 +246,10 @@ export function localizeScalar(value, sourceLocale, translations) {
|
|
|
189
246
|
* @param {string} sourceLocale
|
|
190
247
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
191
248
|
*/
|
|
192
|
-
export function localizeScalarList(value, sourceLocale, translations) {
|
|
249
|
+
export function localizeScalarList(value, sourceLocale, translations, context = null) {
|
|
193
250
|
if (value == null) return undefined
|
|
194
|
-
if (!Array.isArray(value)) return localizeScalar(value, sourceLocale, translations)
|
|
195
|
-
return value.map((item) => localizeScalar(item, sourceLocale, translations))
|
|
251
|
+
if (!Array.isArray(value)) return localizeScalar(value, sourceLocale, translations, context)
|
|
252
|
+
return value.map((item) => localizeScalar(item, sourceLocale, translations, context))
|
|
196
253
|
}
|
|
197
254
|
|
|
198
255
|
// True for a ProseMirror document node (`{ type: 'doc', content: [...] }`).
|
|
@@ -217,12 +274,14 @@ export function isLocalizedContent(value) {
|
|
|
217
274
|
* (source text hashed exactly as `i18n extract` keys it). Non-string or missing
|
|
218
275
|
* values are skipped (only hashable scalars belong in the hash manifest).
|
|
219
276
|
*/
|
|
220
|
-
export function createTranslationCollector(sourceLocale) {
|
|
277
|
+
export function createTranslationCollector(sourceLocale, { siteRoot = null } = {}) {
|
|
221
278
|
const byLocale = {} // { locale: { hash: target } }
|
|
222
279
|
const freeformPending = [] // [{ locale, content }] — target-locale free-form bodies
|
|
223
280
|
|
|
224
|
-
// Record a localized SCALAR field's target locales (hash of the source scalar).
|
|
225
|
-
|
|
281
|
+
// Record a localized SCALAR field's target locales (hash of the source scalar). ⭐ With `context`
|
|
282
|
+
// — the record it was read in (`{ key: '<pool>/<handle>' }`) — a translation that differs from one
|
|
283
|
+
// record to another comes back as the author's `{ default, overrides }`, as a page section's does.
|
|
284
|
+
function add(localizedValue, context = null) {
|
|
226
285
|
if (!isLocalizedMap(localizedValue)) return
|
|
227
286
|
const source = localizedValue[sourceLocale]
|
|
228
287
|
if (typeof source !== 'string' || source.length === 0) return
|
|
@@ -230,17 +289,38 @@ export function createTranslationCollector(sourceLocale) {
|
|
|
230
289
|
for (const [locale, value] of Object.entries(localizedValue)) {
|
|
231
290
|
if (locale === sourceLocale) continue
|
|
232
291
|
if (typeof value !== 'string') continue
|
|
233
|
-
|
|
292
|
+
const table = (byLocale[locale] ||= {})
|
|
293
|
+
if (!context) {
|
|
294
|
+
if (table[hash] instanceof SeenInContexts) table[hash].plain = value
|
|
295
|
+
else table[hash] = value
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const seen = table[hash] instanceof SeenInContexts ? table[hash] : new SeenInContexts(table[hash])
|
|
299
|
+
seen.values[contextKey(context)] = value
|
|
300
|
+
table[hash] = seen
|
|
234
301
|
}
|
|
235
302
|
}
|
|
236
303
|
|
|
237
304
|
// Record a structural translation MAP for one target locale: `{ src-text: target }`
|
|
238
305
|
// keyed by the source text exactly as `i18n extract` keys it (hash of the source).
|
|
239
|
-
|
|
306
|
+
// ⭐ With `context` — where the map was read (`translationContext`) — a translation that differs
|
|
307
|
+
// from one section to another is kept per section, and comes back as the author's
|
|
308
|
+
// context-specific overrides (`writeLocaleTranslations`). ⛔ Until 2026-09-26 the last section
|
|
309
|
+
// read won, and a pull wrote that one string over the author's `{ default, overrides }`.
|
|
310
|
+
function addStructuralMap(locale, map, context = null) {
|
|
240
311
|
if (locale === sourceLocale || !map || typeof map !== 'object' || Array.isArray(map)) return
|
|
312
|
+
const table = (byLocale[locale] ||= {})
|
|
241
313
|
for (const [src, target] of Object.entries(map)) {
|
|
242
314
|
if (typeof src !== 'string' || typeof target !== 'string') continue
|
|
243
|
-
|
|
315
|
+
const hash = computeHash(src)
|
|
316
|
+
if (!context) {
|
|
317
|
+
if (table[hash] instanceof SeenInContexts) table[hash].plain = target
|
|
318
|
+
else table[hash] = target
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
const seen = table[hash] instanceof SeenInContexts ? table[hash] : new SeenInContexts(table[hash])
|
|
322
|
+
seen.values[contextKey(context)] = target
|
|
323
|
+
table[hash] = seen
|
|
244
324
|
}
|
|
245
325
|
}
|
|
246
326
|
|
|
@@ -253,7 +333,21 @@ export function createTranslationCollector(sourceLocale) {
|
|
|
253
333
|
freeformPending.push({ locale, target, source, relpath: relpath || null })
|
|
254
334
|
}
|
|
255
335
|
|
|
256
|
-
|
|
336
|
+
// ⭐ The FREE-FORM file the site keeps for this translation, if any — the first of `relpaths` (the
|
|
337
|
+
// order the renderer looks them up in, `freeformPathsFor`) that exists for `locale`. A pulled
|
|
338
|
+
// translation goes back into that file, whatever its shape. ⛔ Until 2026-09-25 a free-form body
|
|
339
|
+
// whose paragraphs lined up with its source's was read as a structural translation, and a pull into
|
|
340
|
+
// the author's copy wrote its text over their `locales/{locale}.json` entries for those paragraphs —
|
|
341
|
+
// measured on the `international` template's `about/story`, in Spanish and French.
|
|
342
|
+
function keptFreeform(locale, relpaths) {
|
|
343
|
+
if (!siteRoot) return null
|
|
344
|
+
for (const rel of [].concat(relpaths || [])) {
|
|
345
|
+
if (rel && existsSync(join(localesDir(siteRoot), 'freeform', locale, rel))) return rel
|
|
346
|
+
}
|
|
347
|
+
return null
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return { add, addStructuralMap, noteFreeform, keptFreeform, byLocale, freeformPending }
|
|
257
351
|
}
|
|
258
352
|
|
|
259
353
|
/**
|
|
@@ -289,6 +383,45 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
|
289
383
|
// a mark we can't serialize → don't risk a lossy map; store as free-form
|
|
290
384
|
return null
|
|
291
385
|
}
|
|
386
|
+
return addDataBlockStrings(map, sourceDoc, targetDoc)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
|
|
390
|
+
// (`visitDataStrings`, the extractor's judgement), keyed by their source text as the build keys them.
|
|
391
|
+
// A target that is not the source with only those strings changed — another shape, another `href` —
|
|
392
|
+
// cannot be said per string, and the whole translation is free-form. ⛔ Until 2026-09-26 a data block
|
|
393
|
+
// was not read here at all, and its translations were not sent (`merge.js::translateDataBlocks`).
|
|
394
|
+
function addDataBlockStrings(map, sourceDoc, targetDoc) {
|
|
395
|
+
const srcBlocks = dataBlockNodes(sourceDoc)
|
|
396
|
+
const tgtBlocks = dataBlockNodes(targetDoc)
|
|
397
|
+
if (srcBlocks.length !== tgtBlocks.length) return null
|
|
398
|
+
for (let i = 0; i < srcBlocks.length; i++) {
|
|
399
|
+
const s = srcBlocks[i].attrs || {}
|
|
400
|
+
const t = tgtBlocks[i].attrs || {}
|
|
401
|
+
if (s.tag !== t.tag) return null
|
|
402
|
+
if (!s.data || typeof s.data !== 'object') {
|
|
403
|
+
if (canonicalJson(s.data) !== canonicalJson(t.data)) return null
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
const strings = (data) => {
|
|
407
|
+
const out = []
|
|
408
|
+
visitDataStrings(JSON.parse(JSON.stringify(data ?? null)), (value) => void out.push(value))
|
|
409
|
+
return out
|
|
410
|
+
}
|
|
411
|
+
const from = strings(s.data)
|
|
412
|
+
const to = strings(t.data)
|
|
413
|
+
if (from.length !== to.length) return null
|
|
414
|
+
const pairs = new Map()
|
|
415
|
+
for (let j = 0; j < from.length; j++) {
|
|
416
|
+
if (from[j] === to[j]) continue
|
|
417
|
+
if (pairs.has(from[j]) && pairs.get(from[j]) !== to[j]) return null // one string, two translations
|
|
418
|
+
pairs.set(from[j], to[j])
|
|
419
|
+
}
|
|
420
|
+
const rebuilt = JSON.parse(JSON.stringify(s.data))
|
|
421
|
+
visitDataStrings(rebuilt, (value) => pairs.get(value) ?? value)
|
|
422
|
+
if (canonicalJson(rebuilt) !== canonicalJson(t.data)) return null
|
|
423
|
+
for (const [from, to] of pairs) map[from.trim()] = to
|
|
424
|
+
}
|
|
292
425
|
return map
|
|
293
426
|
}
|
|
294
427
|
|
|
@@ -303,23 +436,62 @@ function deriveStructuralMap(sourceDoc, targetDoc) {
|
|
|
303
436
|
* reserved `@` (and `$`-prefixed) key is opaque metadata — NEVER a locale. A bare
|
|
304
437
|
* doc (no locale wrap) is returned unchanged.
|
|
305
438
|
*/
|
|
306
|
-
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath) {
|
|
439
|
+
export function unwrapLocalizedContent(content, sourceLocale, collector, freeformRelPath, freeformCandidates = null, context = null) {
|
|
307
440
|
if (!isLocalizedContent(content)) return content
|
|
308
441
|
const source = content[sourceLocale]
|
|
309
442
|
for (const [locale, value] of Object.entries(content)) {
|
|
310
443
|
if (locale === sourceLocale) continue
|
|
311
444
|
if (locale === '@' || locale.startsWith('$')) continue // reserved metadata, not a locale
|
|
312
445
|
if (isProseMirrorDoc(value)) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
446
|
+
// A translation the site keeps as a free-form file stays one, even when it lines up.
|
|
447
|
+
const kept = collector?.keptFreeform?.(locale, freeformCandidates || freeformRelPath) || null
|
|
448
|
+
const map = kept ? null : deriveStructuralMap(source, value)
|
|
449
|
+
if (map) collector?.addStructuralMap?.(locale, map, context)
|
|
450
|
+
else collector?.noteFreeform?.(locale, value, source, kept || freeformRelPath)
|
|
316
451
|
} else {
|
|
317
|
-
collector?.addStructuralMap?.(locale, value)
|
|
452
|
+
collector?.addStructuralMap?.(locale, value, context)
|
|
318
453
|
}
|
|
319
454
|
}
|
|
320
455
|
return source
|
|
321
456
|
}
|
|
322
457
|
|
|
458
|
+
// A translation read in several sections: what each section's context says, and what a place with
|
|
459
|
+
// no context (a page title) says. Reconciled with the author's entry when the file is written.
|
|
460
|
+
class SeenInContexts {
|
|
461
|
+
constructor(plain) {
|
|
462
|
+
this.plain = typeof plain === 'string' ? plain : undefined
|
|
463
|
+
this.values = {} // a place's key (`contextKey`) → translation
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ⭐ A translation seen in several places comes back as the author keeps it: one string where every
|
|
468
|
+
// place agrees, else `{ default, overrides }` — their default and their overrides kept wherever they
|
|
469
|
+
// still hold. The default is what the places without an override of their own say (the most of
|
|
470
|
+
// them; the author's default when tied, else the first read). An override for a place this pull did
|
|
471
|
+
// not see is kept, and one that now says the default is dropped.
|
|
472
|
+
function reconcileTranslation(existing, seen) {
|
|
473
|
+
const own = existing && typeof existing === 'object' && existing.overrides && typeof existing.overrides === 'object' ? existing.overrides : {}
|
|
474
|
+
const authoredDefault = typeof existing === 'string' ? existing : existing && typeof existing === 'object' ? existing.default : undefined
|
|
475
|
+
const contexts = Object.entries(seen.values)
|
|
476
|
+
const candidates = [...(seen.plain !== undefined ? [seen.plain] : []), ...contexts.filter(([k]) => !(k in own)).map(([, v]) => v)]
|
|
477
|
+
let def = authoredDefault
|
|
478
|
+
if (candidates.length) {
|
|
479
|
+
const counts = new Map()
|
|
480
|
+
for (const v of candidates) counts.set(v, (counts.get(v) || 0) + 1)
|
|
481
|
+
const most = Math.max(...counts.values())
|
|
482
|
+
const top = [...counts].filter(([, n]) => n === most).map(([v]) => v)
|
|
483
|
+
def = top.includes(authoredDefault) ? authoredDefault : top[0]
|
|
484
|
+
} else if (def === undefined) {
|
|
485
|
+
def = contexts[0]?.[1]
|
|
486
|
+
}
|
|
487
|
+
const overrides = { ...own }
|
|
488
|
+
for (const [k, v] of contexts) {
|
|
489
|
+
if (v === def) delete overrides[k]
|
|
490
|
+
else overrides[k] = v
|
|
491
|
+
}
|
|
492
|
+
return Object.keys(overrides).length ? { default: def, overrides } : def
|
|
493
|
+
}
|
|
494
|
+
|
|
323
495
|
/**
|
|
324
496
|
* Write captured target-locale FREE-FORM bodies to `locales/freeform/{locale}/<relpath>`
|
|
325
497
|
* (markdown, body-only) and record each one's SOURCE hash in that locale's
|
|
@@ -368,7 +540,9 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
|
|
|
368
540
|
} catch {
|
|
369
541
|
// absent
|
|
370
542
|
}
|
|
371
|
-
|
|
543
|
+
// ⭐ A file whose markdown already says this is left as the author wrote it — the writer's
|
|
544
|
+
// markdown is not theirs (blank lines, line breaks), as for a section body (`writeSectionFile`).
|
|
545
|
+
if (current !== text && !(current !== null && sameMarkdownDocument(current.trim(), target))) {
|
|
372
546
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
373
547
|
writeFileSync(filePath, text)
|
|
374
548
|
report.written.push(filePath)
|
|
@@ -391,34 +565,91 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
|
|
|
391
565
|
return report
|
|
392
566
|
}
|
|
393
567
|
|
|
568
|
+
/**
|
|
569
|
+
* The keys of a JSON object's top level, in the order its text writes them.
|
|
570
|
+
*
|
|
571
|
+
* ⛔ NOT `Object.keys(JSON.parse(text))`: a JavaScript object puts every integer-like key
|
|
572
|
+
* first, and a translation's key is a hash — `19544096` is one, as about one in fifty are.
|
|
573
|
+
* Until 2026-09-26 a pull that changed one translation also moved each such entry to the top
|
|
574
|
+
* of the author's file.
|
|
575
|
+
*
|
|
576
|
+
* @param {string} text - a JSON object
|
|
577
|
+
* @returns {string[]}
|
|
578
|
+
*/
|
|
579
|
+
function topLevelKeyOrder(text) {
|
|
580
|
+
const keys = []
|
|
581
|
+
let depth = 0
|
|
582
|
+
for (let i = 0; i < text.length; i++) {
|
|
583
|
+
const c = text[i]
|
|
584
|
+
if (c === '"') {
|
|
585
|
+
let end = i + 1
|
|
586
|
+
while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1
|
|
587
|
+
if (depth === 1 && /^\s*:/.test(text.slice(end + 1, end + 64))) keys.push(JSON.parse(text.slice(i, end + 1)))
|
|
588
|
+
i = end
|
|
589
|
+
} else if (c === '{' || c === '[') depth++
|
|
590
|
+
else if (c === '}' || c === ']') depth--
|
|
591
|
+
}
|
|
592
|
+
return keys
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** `JSON.stringify(object, null, 2)` of these entries, in their order — see `topLevelKeyOrder`. */
|
|
596
|
+
function jsonObjectText(entries) {
|
|
597
|
+
if (entries.length === 0) return '{}\n'
|
|
598
|
+
const lines = entries.map(([key, value]) => ` ${JSON.stringify(key)}: ${JSON.stringify(value, null, 2).replace(/\n/g, '\n ')}`)
|
|
599
|
+
return `{\n${lines.join(',\n')}\n}\n`
|
|
600
|
+
}
|
|
601
|
+
|
|
394
602
|
/**
|
|
395
603
|
* Merge `{ locale: { hash: target } }` into each `locales/{locale}.json`,
|
|
396
604
|
* preserving existing entries (translations for strings not in this projection are
|
|
397
|
-
* kept — `uniweb i18n audit --clean` removes genuine orphans).
|
|
398
|
-
* the
|
|
605
|
+
* kept — `uniweb i18n audit --clean` removes genuine orphans). An entry keeps its place
|
|
606
|
+
* in the file, and new ones follow it, sorted, so a pull that changes nothing writes
|
|
607
|
+
* nothing. Creates `locales/` if absent.
|
|
399
608
|
*
|
|
400
609
|
* @returns {{ [locale: string]: 'updated' | 'unchanged' }}
|
|
401
610
|
*/
|
|
402
611
|
export function writeLocaleTranslations(siteRoot, byLocale, subdir = '') {
|
|
403
612
|
const report = {}
|
|
404
|
-
for (
|
|
613
|
+
for (let [locale, entries] of Object.entries(byLocale || {})) {
|
|
405
614
|
if (!entries || Object.keys(entries).length === 0) continue
|
|
406
615
|
const filePath = localeFilePath(siteRoot, locale, subdir)
|
|
407
616
|
|
|
408
617
|
let existing = {}
|
|
618
|
+
let order = []
|
|
409
619
|
if (existsSync(filePath)) {
|
|
410
620
|
try {
|
|
411
|
-
const
|
|
412
|
-
|
|
621
|
+
const text = readFileSync(filePath, 'utf8')
|
|
622
|
+
const parsed = JSON.parse(text)
|
|
623
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
624
|
+
existing = parsed
|
|
625
|
+
order = topLevelKeyOrder(text).filter((key) => key in parsed)
|
|
626
|
+
}
|
|
413
627
|
} catch {
|
|
414
628
|
// unreadable / invalid → start fresh
|
|
415
629
|
}
|
|
416
630
|
}
|
|
417
631
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
632
|
+
// ⭐ A FILE THE PULL ADDS NOTHING TO IS LEFT AS IT IS, and one it does keeps its order: an entry
|
|
633
|
+
// changes in its place, and new ones follow, sorted. ⛔ Until 2026-09-26 every pull re-sorted the
|
|
634
|
+
// whole file by hash — measured on the `international` template, whose `es.json` runs in page
|
|
635
|
+
// order: a pull that changed no translation rewrote all 77 lines.
|
|
636
|
+
// A translation read in several sections becomes the string or the `{ default, overrides }` the
|
|
637
|
+
// author keeps (`reconcileTranslation`).
|
|
638
|
+
entries = Object.fromEntries(
|
|
639
|
+
Object.entries(entries).map(([key, value]) => [key, value instanceof SeenInContexts ? reconcileTranslation(existing[key], value) : value])
|
|
640
|
+
)
|
|
641
|
+
const fresh = Object.keys(entries).filter((key) => !(key in existing)).sort()
|
|
642
|
+
if (fresh.length === 0 && Object.entries(entries).every(([key, value]) => canonicalJson(existing[key]) === canonicalJson(value))) {
|
|
643
|
+
report[locale] = 'unchanged'
|
|
644
|
+
continue
|
|
645
|
+
}
|
|
646
|
+
// An entry the pull did not change keeps its value as written — key order included.
|
|
647
|
+
const merged = [...new Set([...order, ...Object.keys(existing)])].map((key) => [
|
|
648
|
+
key,
|
|
649
|
+
key in entries && canonicalJson(entries[key]) !== canonicalJson(existing[key]) ? entries[key] : existing[key],
|
|
650
|
+
])
|
|
651
|
+
for (const key of fresh) merged.push([key, entries[key]])
|
|
652
|
+
const next = jsonObjectText(merged)
|
|
422
653
|
|
|
423
654
|
let current = null
|
|
424
655
|
try {
|