@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "1.3.5",
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"
@@ -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 || {})) declared.set(at(owner, key), decl)
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: _problems, ...schema } = decl
66
- models.set(where, modelOf(schema, '@/inline'))
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
- seo[key] = lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource)
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
- * A value that writes a link is taken as written, re-targeted or not. So is a plain value for an
385
- * element that mixes words and links, since which words were linked cannot be known;
386
- * `uniweb i18n status` reports that loss (`translationKeepsMarkup`).
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
- return asLinkLabels(source, fragment, value) || fragment
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
@@ -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
  }
@@ -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 = 'en' }) {
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
- const { resolveDeclaration, sourceLocale = 'en' } = opts
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
@@ -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 || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
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
@@ -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 { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
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 = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
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 = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
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 = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
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)
@@ -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 = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
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 = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope, dataModel = null }) {
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
- setIf(settings, 'og_title', openGraphValue(siteYml.seo, 'og_title', settingsFields, sourceLocale, translations, SITE_META_CONTEXT))
1310
- setIf(settings, 'og_description', openGraphValue(siteYml.seo, 'og_description', settingsFields, sourceLocale, translations, SITE_META_CONTEXT))
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
+ }
@@ -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
- const sourceLocale = opts.sourceLocale
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 || {})