@uniweb/build 1.3.5 → 1.3.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/i18n/data-models.js +39 -3
- package/src/i18n/merge.js +65 -8
- package/src/i18n/records.js +3 -2
- package/src/uwx/locale-sync.js +25 -1
- package/src/uwx/records-project.js +5 -2
- package/src/uwx/records.js +2 -1
- package/src/uwx/site-diff.js +4 -4
- package/src/uwx/site-project.js +5 -3
- package/src/uwx/site.js +9 -6
- package/src/uwx/source-locale.js +57 -0
- package/src/uwx/sync-package.js +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.6",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"sharp": "^0.35.3",
|
|
58
58
|
"yaml": "^2.5.0",
|
|
59
59
|
"@uniweb/content-reader": "~1.4.0",
|
|
60
|
-
"@uniweb/projections": "~1.1.1",
|
|
61
60
|
"@uniweb/content-writer": "~1.1.2",
|
|
61
|
+
"@uniweb/projections": "~1.1.1",
|
|
62
|
+
"@uniweb/theming": "~1.2.0",
|
|
62
63
|
"@uniweb/schemas": "~1.3.0",
|
|
63
|
-
"@uniweb/semantic-parser": "~1.8.1"
|
|
64
|
-
"@uniweb/theming": "~1.2.0"
|
|
64
|
+
"@uniweb/semantic-parser": "~1.8.1"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
67
|
"@uniweb/runtime": "~1.1.4"
|
package/src/i18n/data-models.js
CHANGED
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
* normalizer (`@uniweb/schemas/foundation::normalizeData`); a ref resolves as a query's records
|
|
12
12
|
* resolve theirs (`resolveRecordSchemas`). A key with no shape, or a ref that does not resolve, has
|
|
13
13
|
* no model, and its blocks keep the heuristic.
|
|
14
|
+
*
|
|
15
|
+
* ⛔ A key that DOES declare a shape, which the translation step cannot read, is said aloud
|
|
16
|
+
* (`warnUnreadable`): its blocks fall back to the heuristic, which never offers a field named
|
|
17
|
+
* `email`, `time`, `href` and the like. ⛔ Until 2026-10-16 that fall was silent — measured on a
|
|
18
|
+
* block of contact labels declared `{ type: 'object', values: { type: 'string' } }`, whose `email`
|
|
19
|
+
* and `time` labels were never offered for translation.
|
|
14
20
|
*/
|
|
15
21
|
import { normalizeData } from '@uniweb/schemas/foundation'
|
|
16
22
|
import { foundationSections, resolveRecordSchemas } from '../site/queries-config.js'
|
|
@@ -27,6 +33,9 @@ export const NO_DATA_MODELS = () => null
|
|
|
27
33
|
* @returns {Promise<(type: string, tag: string) => object|null>}
|
|
28
34
|
*/
|
|
29
35
|
export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
36
|
+
// Each key's declaration as written, to say what was meant when its shape cannot be read.
|
|
37
|
+
const raw = new Map()
|
|
38
|
+
const warn = (where, reason) => warnUnreadable(siteRoot, where, reason, raw.get(where))
|
|
30
39
|
let sections
|
|
31
40
|
try {
|
|
32
41
|
sections = await foundationSections(siteRoot, siteYml)
|
|
@@ -39,7 +48,10 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
|
39
48
|
const declared = new Map()
|
|
40
49
|
const declare = (owner, data) => {
|
|
41
50
|
const normalized = normalizeData(data, { strict: false })
|
|
42
|
-
for (const [key, decl] of Object.entries(normalized || {}))
|
|
51
|
+
for (const [key, decl] of Object.entries(normalized || {})) {
|
|
52
|
+
declared.set(at(owner, key), decl)
|
|
53
|
+
raw.set(at(owner, key), data?.[key])
|
|
54
|
+
}
|
|
43
55
|
}
|
|
44
56
|
for (const [name, entry] of Object.entries(sections)) {
|
|
45
57
|
if (!entry || typeof entry !== 'object' || name === 'dataSchemas' || name === '_layouts') continue
|
|
@@ -62,8 +74,11 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
|
62
74
|
for (const [where, decl] of declared) {
|
|
63
75
|
if (decl.kind === 'schema') models.set(where, modelOf(resolved[decl.schema] || null, decl.schema))
|
|
64
76
|
else if (decl.kind === 'fields') {
|
|
65
|
-
const { kind: _kind, problems
|
|
66
|
-
|
|
77
|
+
const { kind: _kind, problems, ...schema } = decl
|
|
78
|
+
let lowering = null
|
|
79
|
+
const model = modelOf(schema, '@/inline', (err) => (lowering = err))
|
|
80
|
+
if (problems?.length || !model) warn(where, problems?.length ? problems.join('; ') : lowering?.message || 'it lowers to no model')
|
|
81
|
+
models.set(where, model)
|
|
67
82
|
} else models.set(where, null)
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -75,3 +90,24 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
|
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
const at = (owner, key) => `${owner}\u0000${key}`
|
|
93
|
+
|
|
94
|
+
// Each unreadable shape once per site per process: extraction, the build, a push and a pull all
|
|
95
|
+
// read the same declarations.
|
|
96
|
+
const warned = new Set()
|
|
97
|
+
|
|
98
|
+
function warnUnreadable(siteRoot, where, reason, declaration) {
|
|
99
|
+
const [owner, key] = where.split('\u0000')
|
|
100
|
+
const once = `${siteRoot}\u0000${where}\u0000${reason}`
|
|
101
|
+
if (warned.has(once)) return
|
|
102
|
+
warned.add(once)
|
|
103
|
+
const who = owner ? `the ${owner} section type` : 'the foundation (main.js)'
|
|
104
|
+
const fieldsCalledType = declaration && typeof declaration === 'object' && typeof declaration.type === 'string' && ('values' in declaration || 'fields' in declaration)
|
|
105
|
+
const hint = fieldsCalledType
|
|
106
|
+
? ` A data key is declared as its block's fields: { type: '${declaration.type}', ${'values' in declaration ? 'values' : 'fields'}: … } declares two fields, named "type" and "${'values' in declaration ? 'values' : 'fields'}" — name the block's fields instead ({ email: 'string', time: 'string' }).`
|
|
107
|
+
: /open map/.test(reason)
|
|
108
|
+
? " An open map is read when its values are records: values: { type: 'object', fields: { … } }."
|
|
109
|
+
: ''
|
|
110
|
+
console.warn(
|
|
111
|
+
`Warning: ${who} declares data key "${key}" with a shape the translation step cannot read (${reason}), so its blocks' strings are guessed — a field named email, time, href or the like is never offered for translation.${hint}`
|
|
112
|
+
)
|
|
113
|
+
}
|
package/src/i18n/merge.js
CHANGED
|
@@ -207,9 +207,14 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
|
|
|
207
207
|
*
|
|
208
208
|
* ⛔ Do not add the field or the role to the hash. Genuine wording divergence by
|
|
209
209
|
* context is what `overrides` is for (`lookupTranslation`); representation
|
|
210
|
-
* divergence is this function's job
|
|
210
|
+
* divergence is this function's job — and `translatedInline`'s, for an element whose
|
|
211
|
+
* source has no link.
|
|
212
|
+
*
|
|
213
|
+
* ⭐ One rule for both lanes: a push sends a plain-text slot's translations through it too
|
|
214
|
+
* (`uwx/locale-sync.js::plainTextTargets`). ⛔ Until 2026-10-16 only the build did, so a host
|
|
215
|
+
* was handed `[Sign in](/sign-in)` as a page's French title.
|
|
211
216
|
*/
|
|
212
|
-
function toPlainText(value) {
|
|
217
|
+
export function toPlainText(value) {
|
|
213
218
|
if (typeof value !== 'string' || !value.includes('[') && !value.includes('*') && !value.includes('`') && !value.includes('_')) {
|
|
214
219
|
return value
|
|
215
220
|
}
|
|
@@ -239,7 +244,8 @@ function translateSiteMeta(config, translations, fallbackToSource) {
|
|
|
239
244
|
if (!seo || typeof seo !== 'object') return
|
|
240
245
|
for (const key of ['ogTitle', 'ogDescription']) {
|
|
241
246
|
if (typeof seo[key] === 'string' && seo[key]) {
|
|
242
|
-
|
|
247
|
+
// A plain-text slot, as a page's are (`translatePageMeta`). ⛔ Not flattened until 2026-10-16.
|
|
248
|
+
seo[key] = toPlainText(lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource))
|
|
243
249
|
}
|
|
244
250
|
}
|
|
245
251
|
}
|
|
@@ -381,9 +387,19 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
|
|
|
381
387
|
* its link: the button became a plain paragraph, or vanished from a section that renders only
|
|
382
388
|
* links. Locale files keyed before whole-element keying hold exactly such labels.
|
|
383
389
|
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
390
|
+
* ⭐ And a link is the SOURCE's to have: a value that writes one, given to an element whose source
|
|
391
|
+
* has none, gives it its words alone. One text is one unit wherever it appears (`hash.js`), so its
|
|
392
|
+
* value serves a heading that says it and a link that says it alike — `Faculty of Science` in a
|
|
393
|
+
* header, `[Faculty of Science](/)` in a list — and the link belongs to the place that has one.
|
|
394
|
+
* Representation is the consumer's, as a title's is (`toPlainText`). ⛔ Until 2026-10-16 the value
|
|
395
|
+
* was taken as written: `uniweb i18n init` seeds a unit with the markup of whichever occurrence
|
|
396
|
+
* carries some, so an untouched seeded locale turned the heading into a link — and the parser,
|
|
397
|
+
* which reads a heading made only of a link as a link, left its section without a title.
|
|
398
|
+
*
|
|
399
|
+
* A value that writes a link, given to an element whose source has one, is taken as written,
|
|
400
|
+
* re-targeted or not. So is a plain value for an element that mixes words and links, since which
|
|
401
|
+
* words were linked cannot be known; `uniweb i18n status` reports that loss
|
|
402
|
+
* (`translationKeepsMarkup`). Emphasis is the translator's: a value's bold or italic stays.
|
|
387
403
|
*
|
|
388
404
|
* @param {Array} source - the element's source inline content
|
|
389
405
|
* @param {string} value - the translation, inline markdown
|
|
@@ -392,7 +408,36 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
|
|
|
392
408
|
export function translatedInline(source, value) {
|
|
393
409
|
const fragment = inlineMarkdownToFragment(value)
|
|
394
410
|
if (!fragment) return null
|
|
395
|
-
|
|
411
|
+
const labels = asLinkLabels(source, fragment, value)
|
|
412
|
+
if (labels) return labels
|
|
413
|
+
return hasLink(source) ? fragment : withoutLinks(fragment)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Whether inline content holds a link — on its text, or inside an inline wrapper.
|
|
417
|
+
function hasLink(nodes) {
|
|
418
|
+
return (nodes || []).some((node) => Boolean(linkMarkOf(node)) || hasLink(node?.content))
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Inline content without its links: the words, and every other mark, kept — runs that end up
|
|
422
|
+
// alike joined, as the reader would have written them.
|
|
423
|
+
function withoutLinks(nodes) {
|
|
424
|
+
const out = []
|
|
425
|
+
for (const node of nodes) {
|
|
426
|
+
let next = node
|
|
427
|
+
if (node?.content) next = { ...node, content: withoutLinks(node.content) }
|
|
428
|
+
else if (linkMarkOf(node)) {
|
|
429
|
+
const { marks, ...rest } = node
|
|
430
|
+
const kept = marks.filter((mark) => mark.type !== 'link')
|
|
431
|
+
next = kept.length ? { ...rest, marks: kept } : rest
|
|
432
|
+
}
|
|
433
|
+
const last = out[out.length - 1]
|
|
434
|
+
if (last?.type === 'text' && next?.type === 'text' && JSON.stringify(last.marks || []) === JSON.stringify(next.marks || [])) {
|
|
435
|
+
out[out.length - 1] = { ...last, text: last.text + next.text }
|
|
436
|
+
} else {
|
|
437
|
+
out.push(next)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return out
|
|
396
441
|
}
|
|
397
442
|
|
|
398
443
|
/**
|
|
@@ -581,7 +626,7 @@ function translateDataBlocks(doc, context, translations, fallbackToSource, model
|
|
|
581
626
|
const data = node.attrs?.data
|
|
582
627
|
if (data && typeof data === 'object') {
|
|
583
628
|
visitDataBlockStrings(data, (value) => {
|
|
584
|
-
const translated = lookupTranslation(value, context, translations, fallbackToSource)
|
|
629
|
+
const translated = asDataString(value, lookupTranslation(value, context, translations, fallbackToSource))
|
|
585
630
|
if (translated !== value) changed = true
|
|
586
631
|
return translated
|
|
587
632
|
}, modelFor(node.attrs?.tag))
|
|
@@ -595,6 +640,18 @@ function translateDataBlocks(doc, context, translations, fallbackToSource, model
|
|
|
595
640
|
return changed
|
|
596
641
|
}
|
|
597
642
|
|
|
643
|
+
/**
|
|
644
|
+
* ⭐ A data block's string written as TEXT takes its translation as text — the rule an element
|
|
645
|
+
* with no link follows (`translatedInline`), for a string a component renders as it is: the markup a
|
|
646
|
+
* translation carries for a link elsewhere is not this string's. ⛔ Until 2026-10-16 it was taken
|
|
647
|
+
* as written, and a label shared with a link rendered as `[Sign in](/sign-in)`. A string written
|
|
648
|
+
* with markdown keeps its translation as written.
|
|
649
|
+
*/
|
|
650
|
+
function asDataString(source, translated) {
|
|
651
|
+
if (typeof translated !== 'string' || translated === source) return translated
|
|
652
|
+
return toPlainText(source) === source ? toPlainText(translated) : translated
|
|
653
|
+
}
|
|
654
|
+
|
|
598
655
|
/**
|
|
599
656
|
* Resolve ONE ProseMirror content doc for a single target locale: a deep clone of
|
|
600
657
|
* the source doc with each whole-element translated (inline content replaced from
|
package/src/i18n/records.js
CHANGED
|
@@ -187,11 +187,12 @@ function isFieldTranslatable(fieldDef) {
|
|
|
187
187
|
* @param {string} ref - the ref it was resolved from
|
|
188
188
|
* @returns {Object|null} the lowered declaration, or null when there is none
|
|
189
189
|
*/
|
|
190
|
-
export function modelOf(schema, ref) {
|
|
190
|
+
export function modelOf(schema, ref, onError = null) {
|
|
191
191
|
if (!schema) return null
|
|
192
192
|
try {
|
|
193
193
|
return toDataSchemaDeclaration(schema, { name: ref || '@/record', resolveName: (r) => r })
|
|
194
|
-
} catch {
|
|
194
|
+
} catch (err) {
|
|
195
|
+
onError?.(err) // a caller that can say why — `data-models.js`, for a declared block shape
|
|
195
196
|
return null // a schema a push refuses is refused there, and the build keeps its heuristic
|
|
196
197
|
}
|
|
197
198
|
}
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -24,7 +24,7 @@ import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-w
|
|
|
24
24
|
import { computeHash } from '../i18n/hash.js'
|
|
25
25
|
import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
|
|
26
26
|
import { visitDataBlockStrings } from '../i18n/data-strings.js'
|
|
27
|
-
import { resolveDocForLocale, translatedInline, linkLabels } from '../i18n/merge.js'
|
|
27
|
+
import { resolveDocForLocale, translatedInline, linkLabels, toPlainText } from '../i18n/merge.js'
|
|
28
28
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
29
29
|
import { sameMarkdownDocument, canonicalJson } from './same-content.js'
|
|
30
30
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
@@ -250,6 +250,30 @@ export function contextKey(context) {
|
|
|
250
250
|
* @param {string} sourceLocale
|
|
251
251
|
* @param {object} [translations] - `{ locale: { hash: tgt } }` from loadLocaleTranslations
|
|
252
252
|
*/
|
|
253
|
+
/**
|
|
254
|
+
* A localized PLAIN-TEXT value — a page's title, label, description, keywords and Open Graph
|
|
255
|
+
* texts, and the site's — with each translation as text, as the build renders it
|
|
256
|
+
* (`i18n/merge.js::toPlainText`). The source stays as the author wrote it, as the build leaves it.
|
|
257
|
+
* ⛔ Until 2026-10-16 a push sent a translation's markdown as it was, so a unit shared by a page's
|
|
258
|
+
* title and a link to the page reached a host as `[Sign in](/sign-in)`.
|
|
259
|
+
*
|
|
260
|
+
* @param {object|Array|undefined} value - a `{ locale: text }` map, or a list of them
|
|
261
|
+
* @param {string} sourceLocale
|
|
262
|
+
*/
|
|
263
|
+
export function plainTextTargets(value, sourceLocale) {
|
|
264
|
+
if (Array.isArray(value)) return value.map((item) => plainTextTargets(item, sourceLocale))
|
|
265
|
+
if (!value || typeof value !== 'object') return value
|
|
266
|
+
let out = value
|
|
267
|
+
for (const [locale, text] of Object.entries(value)) {
|
|
268
|
+
if (locale === sourceLocale || typeof text !== 'string') continue
|
|
269
|
+
const plain = toPlainText(text)
|
|
270
|
+
if (plain === text) continue
|
|
271
|
+
if (out === value) out = { ...value }
|
|
272
|
+
out[locale] = plain
|
|
273
|
+
}
|
|
274
|
+
return out
|
|
275
|
+
}
|
|
276
|
+
|
|
253
277
|
export function localizeScalarList(value, sourceLocale, translations, context = null) {
|
|
254
278
|
if (value == null) return undefined
|
|
255
279
|
if (!Array.isArray(value)) return localizeScalar(value, sourceLocale, translations, context)
|
|
@@ -54,6 +54,7 @@ import { LINK_MODEL, writeLinkRecords } from './link-records.js'
|
|
|
54
54
|
import { FILE_MODEL, filesToPull } from './file-records.js'
|
|
55
55
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
56
56
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
57
|
+
import { siteSourceLocale } from './source-locale.js'
|
|
57
58
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
58
59
|
|
|
59
60
|
// Single-record source extensions we scan + place (BibTeX is multi-record → out).
|
|
@@ -527,7 +528,7 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
|
|
|
527
528
|
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
|
|
528
529
|
* `file` is where the organization lives, as the author would write it
|
|
529
530
|
*/
|
|
530
|
-
export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale =
|
|
531
|
+
export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = siteSourceLocale(siteRoot) }) {
|
|
531
532
|
const warnings = []
|
|
532
533
|
const { abs, rel: file } = folderYmlPath(siteRoot)
|
|
533
534
|
if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
|
|
@@ -625,7 +626,9 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
625
626
|
* @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
|
|
626
627
|
*/
|
|
627
628
|
export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
|
|
628
|
-
|
|
629
|
+
// The site's language, as its `site.yml` says it — written by the pull of the site's content, which
|
|
630
|
+
// runs first (`./source-locale.js`).
|
|
631
|
+
const { resolveDeclaration, sourceLocale = siteSourceLocale(siteRoot) } = opts
|
|
629
632
|
refuseOrgOption(opts, 'recordsToProject')
|
|
630
633
|
// The scope the push qualified `@/x` with — the site's foundation's — so a
|
|
631
634
|
// `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
|
package/src/uwx/records.js
CHANGED
|
@@ -64,6 +64,7 @@ import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
|
64
64
|
import { isUuid } from './uuid.js'
|
|
65
65
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
66
66
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
67
|
+
import { siteSourceLocale } from './source-locale.js'
|
|
67
68
|
import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
68
69
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
69
70
|
import { isDraftRecord } from '../site/record-draft.js'
|
|
@@ -1031,7 +1032,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
1031
1032
|
}
|
|
1032
1033
|
|
|
1033
1034
|
const sourceLocale =
|
|
1034
|
-
opts.sourceLocale ||
|
|
1035
|
+
opts.sourceLocale || siteSourceLocale(siteRoot)
|
|
1035
1036
|
|
|
1036
1037
|
// Target locales for wrapping localized record fields per-locale: those with a
|
|
1037
1038
|
// structural-translation file (locales/records/{locale}.json) UNIONED with
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
import { entityContentHash } from './records.js'
|
|
50
50
|
import { sectionFileBase, pageDirName, layoutSectionPaths } from './site-project.js'
|
|
51
|
-
import {
|
|
51
|
+
import { documentSourceLocale } from './source-locale.js'
|
|
52
52
|
|
|
53
53
|
// A unit's own content, with the nested collections that are their own units
|
|
54
54
|
// removed — so editing a section never also marks its page (or its parent
|
|
@@ -70,7 +70,7 @@ const ownContent = (record, ...childKeys) => {
|
|
|
70
70
|
*
|
|
71
71
|
* @returns {Map<string, object>} path → the unit's own record
|
|
72
72
|
*/
|
|
73
|
-
export function collectSiteUnits(doc, sourceLocale =
|
|
73
|
+
export function collectSiteUnits(doc, sourceLocale = documentSourceLocale(doc)) {
|
|
74
74
|
const units = new Map()
|
|
75
75
|
walkSiteUnits(doc, (path, record, kind) => {
|
|
76
76
|
units.set(
|
|
@@ -93,7 +93,7 @@ export function collectSiteUnits(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.
|
|
|
93
93
|
*
|
|
94
94
|
* @param {(path: string, record: object, kind: 'info'|'page'|'section') => void} cb
|
|
95
95
|
*/
|
|
96
|
-
export function walkSiteUnits(doc, cb, sourceLocale =
|
|
96
|
+
export function walkSiteUnits(doc, cb, sourceLocale = documentSourceLocale(doc)) {
|
|
97
97
|
const walkSections = (sections, dir) => {
|
|
98
98
|
for (const record of sections || []) {
|
|
99
99
|
// The projector's own file rule — including an app-created section named from its
|
|
@@ -353,7 +353,7 @@ const KEYED_SECTIONS = {
|
|
|
353
353
|
*
|
|
354
354
|
* @returns {Map<string, { uuid: string|null, record: object }>}
|
|
355
355
|
*/
|
|
356
|
-
export function siteItemsByKey(doc, sourceLocale =
|
|
356
|
+
export function siteItemsByKey(doc, sourceLocale = documentSourceLocale(doc)) {
|
|
357
357
|
const out = new Map()
|
|
358
358
|
const uuidOf = (record) => (typeof record?.$uuid === 'string' && record.$uuid ? record.$uuid : null)
|
|
359
359
|
const units = collectSiteUnits(doc, sourceLocale)
|
package/src/uwx/site-project.js
CHANGED
|
@@ -52,6 +52,7 @@ import { unwritableNodes } from '@uniweb/content-writer'
|
|
|
52
52
|
import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
|
|
53
53
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
54
54
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
55
|
+
import { documentSourceLocale } from './source-locale.js'
|
|
55
56
|
import { siteContentDirs } from './site-dirs.js'
|
|
56
57
|
import { layoutAreaRoute, readLayoutFolderSync, DEFAULT_LAYOUT } from '../site/layout-folder.js'
|
|
57
58
|
import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
@@ -215,7 +216,7 @@ function readAuthoredYaml(filePath) {
|
|
|
215
216
|
* @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
|
|
216
217
|
* write status ('updated' | 'unchanged')
|
|
217
218
|
*/
|
|
218
|
-
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale =
|
|
219
|
+
export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = documentSourceLocale(document), collector, keepAuthoredFoundation = false }) {
|
|
219
220
|
const info = document?.info || {}
|
|
220
221
|
const settingsSection = document?.settings || {}
|
|
221
222
|
|
|
@@ -1272,7 +1273,8 @@ function refuseContentFilesCannotHold(document, sourceLocale) {
|
|
|
1272
1273
|
* @param {object} params
|
|
1273
1274
|
* @param {object} params.document - the `@uniweb/site-content` `$`-document
|
|
1274
1275
|
* @param {string} params.siteRoot
|
|
1275
|
-
* @param {string} [params.sourceLocale]
|
|
1276
|
+
* @param {string} [params.sourceLocale] - the language the document is written in; by default its
|
|
1277
|
+
* own `settings`, by the rule its push wrapped it with (`documentSourceLocale`)
|
|
1276
1278
|
* @param {boolean} [params.prune=false] - delete orphaned pages/sections/layout
|
|
1277
1279
|
* files that have no corresponding incoming item (git-pull-like). Off by
|
|
1278
1280
|
* default; `uniweb pull` opts in. Guarded against wiping a level on an
|
|
@@ -1282,7 +1284,7 @@ function refuseContentFilesCannotHold(document, sourceLocale) {
|
|
|
1282
1284
|
* `declarationsToQueriesYml`, whose default it overrides.
|
|
1283
1285
|
* @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
|
|
1284
1286
|
*/
|
|
1285
|
-
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale =
|
|
1287
|
+
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = documentSourceLocale(document), prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
|
|
1286
1288
|
// Before anything is written: a pull that would leave content out of the files stops here.
|
|
1287
1289
|
refuseContentFilesCannotHold(document, sourceLocale)
|
|
1288
1290
|
|
package/src/uwx/site.js
CHANGED
|
@@ -73,7 +73,7 @@ import { readLayoutFolder, layoutAreaRoute } from '../site/layout-folder.js'
|
|
|
73
73
|
import { normalizeHideIn } from '../site/nav-visibility.js'
|
|
74
74
|
import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
|
|
75
75
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
76
|
-
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
76
|
+
import { loadLocaleTranslations, localizeScalar, localizeScalarList, localizeContentDoc, localesDir, isLocalizedContent, plainTextTargets } from './locale-sync.js'
|
|
77
77
|
import { unwrapLocalized } from './backfill.js'
|
|
78
78
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
79
79
|
import { resolveLocaleList } from '../i18n/locales.js'
|
|
@@ -264,11 +264,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
|
|
|
264
264
|
// build translates them (`i18n/merge.js::translatePageMeta`, `<route>:_meta`): an override for one
|
|
265
265
|
// page applies there and nowhere else. ⛔ Until 2026-09-26 they were resolved with no page, so an
|
|
266
266
|
// override never reached a backend — nor, until the same day, an entry's default.
|
|
267
|
+
// ⭐ Each is a plain-text slot, so a translation reaches it as text, as the build renders it
|
|
268
|
+
// (`plainTextTargets`).
|
|
267
269
|
function localizePageMeta(page, route, sourceLocale, translations) {
|
|
268
270
|
const context = { page: route, section: '_meta' }
|
|
269
271
|
const again = (value) =>
|
|
270
272
|
value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
|
|
271
|
-
? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
|
|
273
|
+
? plainTextTargets(localizeScalar(value[sourceLocale], sourceLocale, translations, context), sourceLocale)
|
|
272
274
|
: value
|
|
273
275
|
for (const key of ['title', 'label', 'description', 'og_title', 'og_description']) {
|
|
274
276
|
if (page[key] !== undefined) page[key] = again(page[key])
|
|
@@ -1306,14 +1308,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1306
1308
|
// The site's Open Graph title and description per language, where the deployment's `settings`
|
|
1307
1309
|
// takes them (`uwx/open-graph.js`), in the site's context (`SITE_META_CONTEXT`).
|
|
1308
1310
|
// (Each read inline from `siteYml.seo`, so the emit surface names its source.)
|
|
1309
|
-
|
|
1310
|
-
setIf(settings, '
|
|
1311
|
+
// Plain-text slots, each translation as text (`plainTextTargets`).
|
|
1312
|
+
setIf(settings, 'og_title', plainTextTargets(openGraphValue(siteYml.seo, 'og_title', settingsFields, sourceLocale, translations, SITE_META_CONTEXT), sourceLocale))
|
|
1313
|
+
setIf(settings, 'og_description', plainTextTargets(openGraphValue(siteYml.seo, 'og_description', settingsFields, sourceLocale, translations, SITE_META_CONTEXT), sourceLocale))
|
|
1311
1314
|
setIf(settings, 'seo', seoBesideOpenGraph(siteYml.seo, settingsFields))
|
|
1312
1315
|
// ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
|
|
1313
1316
|
// (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
|
|
1314
1317
|
// convenience, not because it is a different kind of thing. Localized, so it
|
|
1315
1318
|
// carries the translation collector with it.
|
|
1316
|
-
setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
|
|
1319
|
+
setIf(settings, 'keywords', plainTextTargets(localizeScalarList(siteYml.keywords, sourceLocale, translations), sourceLocale))
|
|
1317
1320
|
|
|
1318
1321
|
// ⛔ NO SERVICE IS HERE. A service's address and options ride its row in the
|
|
1319
1322
|
// `services` Section, whole (`requestedServices`) [Diego, 2026-10-06: "If it's on and
|
|
@@ -1431,7 +1434,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1431
1434
|
// a localized `{lang:value}` map. Genuine content fields (`description` below, page
|
|
1432
1435
|
// title/slug/label/keywords, the body) stay localized. (uwx-format.md → identity-label names.)
|
|
1433
1436
|
info.name = siteYml.name
|
|
1434
|
-
setIf(info, 'description', localizeScalar(siteYml.description, sourceLocale, translations))
|
|
1437
|
+
setIf(info, 'description', plainTextTargets(localizeScalar(siteYml.description, sourceLocale, translations), sourceLocale))
|
|
1435
1438
|
// Publish intent (site.yml `publishLanguages:`) rides VERBATIM — dangling
|
|
1436
1439
|
// codes included. Sync carries the full working set; only *publish* filters
|
|
1437
1440
|
// (backend projection / static-build filter). The verbatim carry is what
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⭐ THE LANGUAGE A SITE IS WRITTEN IN — the locale every localized value of it is keyed by on the
|
|
3
|
+
* sync wire, its source. One rule for both directions, `resolveDefaultLocale`
|
|
4
|
+
* (`@uniweb/core/locale-config`): `defaultLanguage`, else the first of `languages`, else `en`.
|
|
5
|
+
*
|
|
6
|
+
* - From the files, `site.yml` (`siteSourceLocale`): what a push wraps pages, records and folder
|
|
7
|
+
* labels under.
|
|
8
|
+
* - From a document, its `settings` (`documentSourceLocale`): what a pull unwraps them by, and what
|
|
9
|
+
* keys a document's units. A push sends `default_language` when the site names one, and its
|
|
10
|
+
* `languages` source first (`uwx/site.js`), so the rule over a document gives back the language
|
|
11
|
+
* the push wrapped it in.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ Until 2026-10-16 only the page push asked the site. The record push and every pull assumed
|
|
14
|
+
* `en`, which every site measured until then happened to be written in: a French site with an
|
|
15
|
+
* English translation came back from a pull in English, its own text filed as a translation of it;
|
|
16
|
+
* a French-only clone came back with empty sections; and its records were pushed labelled English,
|
|
17
|
+
* their English translations left out.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import yaml from 'js-yaml'
|
|
22
|
+
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
23
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The language a site's files are written in — `site.yml`'s, by the shared rule.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} siteRoot - the directory holding `site.yml`
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function siteSourceLocale(siteRoot) {
|
|
32
|
+
const path = join(siteRoot, 'site.yml')
|
|
33
|
+
let siteYml = null
|
|
34
|
+
if (existsSync(path)) {
|
|
35
|
+
try {
|
|
36
|
+
siteYml = yaml.load(readFileSync(path, 'utf8'), YAML_OPTIONS)
|
|
37
|
+
} catch {
|
|
38
|
+
siteYml = null // an unreadable site.yml is reported by whoever reads it for more
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return resolveDefaultLocale(siteYml && typeof siteYml === 'object' ? siteYml : {})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The language a `@uniweb/site-content` document's localized values are keyed by — its
|
|
46
|
+
* `settings`, by the shared rule.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} document - the site-content `$`-document
|
|
49
|
+
* @returns {string}
|
|
50
|
+
*/
|
|
51
|
+
export function documentSourceLocale(document) {
|
|
52
|
+
const settings = document?.settings
|
|
53
|
+
return resolveDefaultLocale({
|
|
54
|
+
defaultLanguage: settings?.default_language,
|
|
55
|
+
languages: settings?.languages,
|
|
56
|
+
})
|
|
57
|
+
}
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -25,6 +25,7 @@ import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
|
25
25
|
import { fileRecordUploads } from './file-records.js'
|
|
26
26
|
import { buildFolderEntity } from './folder.js'
|
|
27
27
|
import { siteProjectToDocument } from './site.js'
|
|
28
|
+
import { siteSourceLocale } from './source-locale.js'
|
|
28
29
|
import { siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
29
30
|
import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
|
|
30
31
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
@@ -299,7 +300,10 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
299
300
|
*/
|
|
300
301
|
export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
301
302
|
const includeSite = opts.includeSite !== false
|
|
302
|
-
|
|
303
|
+
// ⭐ ONE LANGUAGE FOR THE WHOLE EMIT — the site's (`./source-locale.js`): its pages, its records,
|
|
304
|
+
// its folder's labels and the paths its units are keyed by. ⛔ Until 2026-10-16 only the pages asked
|
|
305
|
+
// the site; the rest assumed `en`.
|
|
306
|
+
const sourceLocale = opts.sourceLocale || siteSourceLocale(siteRoot)
|
|
303
307
|
const priorHashes = opts.priorHashes || {}
|
|
304
308
|
const sendAll = !!opts.sendAll
|
|
305
309
|
const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
|