@uniweb/build 1.3.5 → 1.3.7

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.7",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -56,12 +56,12 @@
56
56
  "js-yaml": "^4.1.0",
57
57
  "sharp": "^0.35.3",
58
58
  "yaml": "^2.5.0",
59
+ "@uniweb/content-writer": "~1.1.2",
59
60
  "@uniweb/content-reader": "~1.4.0",
60
61
  "@uniweb/projections": "~1.1.1",
61
- "@uniweb/content-writer": "~1.1.2",
62
- "@uniweb/schemas": "~1.3.0",
63
62
  "@uniweb/semantic-parser": "~1.8.1",
64
- "@uniweb/theming": "~1.2.0"
63
+ "@uniweb/theming": "~1.2.0",
64
+ "@uniweb/schemas": "~1.3.1"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@uniweb/runtime": "~1.1.4"
package/src/dev/plugin.js CHANGED
@@ -26,7 +26,7 @@ import { readFile } from 'node:fs/promises'
26
26
  import { existsSync, readdirSync } from 'node:fs'
27
27
  import { build } from 'vite'
28
28
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
29
- import { DEV_REBUILD_MARKER } from '../vite-foundation-plugin.js'
29
+ import { DEV_REBUILD_MARKER } from '../foundation/dev-rebuild.js'
30
30
  import { shouldRegenerateForFile } from '../generate-entry.js'
31
31
 
32
32
  /** Directories that never hold foundation source and must never be walked. */
@@ -27,6 +27,7 @@ import { resolve } from 'node:path'
27
27
  import { foundationPlugin } from '../vite-foundation-plugin.js'
28
28
  import { jsxExtensionGuard } from '../jsx-extension.js'
29
29
  import { nodeEnvDefine } from './node-env.js'
30
+ import { foundationScopeDefine } from './scope-define.js'
30
31
  import { resolveFoundationSrcDir } from '../utils/foundation-source-root.js'
31
32
  // The ONE definition. This list used to be re-typed here, and again in
32
33
  // vite-foundation-plugin.js — three hand-synced copies of "what a foundation
@@ -114,6 +115,8 @@ export async function defineFoundationConfig(options = {}) {
114
115
  // JSX compiles only in .jsx files: a .js file holding it fails with that said.
115
116
  jsxExtensionGuard(),
116
117
  nodeEnvDefine(),
118
+ // `__FOUNDATION_SCOPE__` — the scope the foundation's own `@/x` names (`scope-define.js`).
119
+ foundationScopeDefine({ foundationDir: foundationRoot }),
117
120
  tailwind && tailwindcss(),
118
121
  react(),
119
122
  svgr(),
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Marks a foundation build as a DEV-server rebuild — a leaf, so the foundation
3
+ * build plugin (`vite-foundation-plugin.js`), the dev server that injects the
4
+ * marker (`dev/plugin.js`) and the scope define (`scope-define.js`) share it.
5
+ *
6
+ * The dev server runs a real Vite `build()` of the foundation on every watched
7
+ * change (`dev/plugin.js`), so `command`, `mode` and `isProduction` cannot tell
8
+ * a dev rebuild from a shipping build — all of them say "build". An injected
9
+ * marker plugin can, and it survives the foundation's own `vite.config.js`
10
+ * being merged in, which a plugin *option* would not (the dev server does not
11
+ * construct the foundation plugin — the foundation's config file does).
12
+ *
13
+ * Used to skip the `entry-ssr.js` sub-build in dev: it is a second full Vite
14
+ * pass per save, and nothing in the dev loop reads it (dev never ships, and the
15
+ * SSR lanes that run locally — SSG prerender and unipress — load `entry.js`).
16
+ * And to build the foundation as a working copy, as `uniweb dev` serves it in
17
+ * bundled mode: its `@/x` stays as written (`scope-define.js`).
18
+ */
19
+ export const DEV_REBUILD_MARKER = 'uniweb:dev-foundation-rebuild'
20
+
21
+ /**
22
+ * Whether a Vite config carries the marker — read in a `config` hook, where
23
+ * `plugins` is still the array as written (nested, with falsy entries), or
24
+ * once resolved.
25
+ *
26
+ * @param {{ plugins?: unknown[] }} config
27
+ * @returns {boolean}
28
+ */
29
+ export function hasDevRebuildMarker(config) {
30
+ return (config?.plugins || []).flat(Infinity).some((p) => p?.name === DEV_REBUILD_MARKER)
31
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `__FOUNDATION_SCOPE__` — the scope a foundation's own `@/x` names, stamped
3
+ * into its bundle.
4
+ *
5
+ * A foundation names its own types `@/x` wherever it names one, and a call to
6
+ * the site's backend is one of those places (`useRecords({ schema: '@/track' })`,
7
+ * `@uniweb/api`). A backend holds the type under the name `register` stored it
8
+ * under, `@<scope>/x`, so the call must say the scope — the one in the
9
+ * foundation's name (`foundation-name.js`), which only its build knows. The
10
+ * bundle reads it from this constant and names the type with `qualifyRef`
11
+ * (`@uniweb/schemas/refs`), the rule `register` and a push use.
12
+ *
13
+ * Three values, and each says something:
14
+ *
15
+ * '@acme' a build of `@acme/marketing` that ships — `uniweb build`, and
16
+ * `register`, which builds through it: `@/x` is sent as `@acme/x`.
17
+ * null the foundation as a working copy — served by `uniweb dev`, or
18
+ * rebuilt by it on each save — or a name with no scope yet: `@/x`
19
+ * is sent as written, which is how a local mock holds the
20
+ * foundation's own types.
21
+ * undefined not a Uniweb build: `@uniweb/api` refuses `@/x` rather than guess.
22
+ *
23
+ * ⭐ Real-backend testing is a job for a built foundation [Diego, 2026-10-17].
24
+ * A dev rebuild never ships: it empties `dist/` and writes no `entry-ssr.js`,
25
+ * so `register` builds again before it uploads (`foundationNeedsBuild`).
26
+ *
27
+ * An author's own `define` for the key wins: it is left untouched.
28
+ */
29
+
30
+ import { readFileSync } from 'node:fs'
31
+ import { join } from 'node:path'
32
+ import { loadFoundationConfig } from '../schema.js'
33
+ import { foundationNameOf, splitFoundationName } from '../foundation-name.js'
34
+ import { resolveFoundationSrcDir } from '../utils/foundation-source-root.js'
35
+ import { hasDevRebuildMarker } from './dev-rebuild.js'
36
+
37
+ export const FOUNDATION_SCOPE_KEY = '__FOUNDATION_SCOPE__'
38
+
39
+ /**
40
+ * The define, for a build of the foundation at `foundationDir` — its own, its
41
+ * SSR twin's, or a site's that bundles it.
42
+ *
43
+ * @param {{ foundationDir?: string }} [options] - default: the Vite root
44
+ * @returns {import('vite').Plugin}
45
+ */
46
+ export function foundationScopeDefine({ foundationDir } = {}) {
47
+ return {
48
+ name: 'uniweb:foundation-scope',
49
+ async config(config, { command }) {
50
+ if (config.define && FOUNDATION_SCOPE_KEY in config.define) return
51
+ const workingCopy = command === 'serve' || hasDevRebuildMarker(config)
52
+ const scope = workingCopy ? null : await foundationScope(foundationDir ?? config.root ?? process.cwd())
53
+ return { define: { [FOUNDATION_SCOPE_KEY]: JSON.stringify(scope) } }
54
+ },
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The scope in a foundation's name — `@acme` for `@acme/marketing`, null for a
60
+ * bare name — read as the build reads the name (`buildSchema`): `main.js`'s
61
+ * `name`, else `package.json`'s (`foundationNameOf`). A `main.js` that does not
62
+ * load stops the build here, as it stops the schema.
63
+ *
64
+ * ⛔ Not `readFoundationName`, which refuses a leftover `uniweb.scope` or
65
+ * `uniweb.id` for `register`: a build does not register, and warns about them
66
+ * instead (`loadPackageJson`) — the scope in the name is what registers.
67
+ *
68
+ * @param {string} foundationDir
69
+ * @returns {Promise<string|null>}
70
+ */
71
+ export async function foundationScope(foundationDir) {
72
+ const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
73
+ const config = await loadFoundationConfig(srcDir)
74
+ const { name } = foundationNameOf({ config, pkg: readPackage(foundationDir) })
75
+ return splitFoundationName(name).scope
76
+ }
77
+
78
+ function readPackage(foundationDir) {
79
+ try {
80
+ return JSON.parse(readFileSync(join(foundationDir, 'package.json'), 'utf8')) || {}
81
+ } catch {
82
+ return {}
83
+ }
84
+ }
@@ -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,9 +74,17 @@ 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'))
67
- } else models.set(where, null)
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)
82
+ } else {
83
+ // A declaration the normalizer could not take at all — a retired `'@x/y/*'` ref, a value that
84
+ // is no shape — is a shape declared and not read, as much as a field map that fails.
85
+ if (decl?.problems?.length) warn(where, decl.problems.join('; '))
86
+ models.set(where, null)
87
+ }
68
88
  }
69
89
 
70
90
  return (type, tag) => {
@@ -75,3 +95,25 @@ export async function dataBlockModels(siteRoot, siteYml = null) {
75
95
  }
76
96
 
77
97
  const at = (owner, key) => `${owner}\u0000${key}`
98
+
99
+ // Each unreadable shape once per site per process: extraction, the build, a push and a pull all
100
+ // read the same declarations.
101
+ const warned = new Set()
102
+
103
+ function warnUnreadable(siteRoot, where, reason, declaration) {
104
+ const [owner, key] = where.split('\u0000')
105
+ const once = `${siteRoot}\u0000${where}\u0000${reason}`
106
+ if (warned.has(once)) return
107
+ warned.add(once)
108
+ const who = owner ? `the ${owner} section type` : 'the foundation (main.js)'
109
+ // ⚠️ `values` only: `{ type: 'object', fields: { … } }` is the whole-schema form, read as its fields.
110
+ const fieldsCalledType = declaration && typeof declaration === 'object' && typeof declaration.type === 'string' && 'values' in declaration && !('fields' in declaration)
111
+ const hint = fieldsCalledType
112
+ ? ` A data key is declared as its block's fields: { type: '${declaration.type}', values: … } declares two fields, named "type" and "values" — name the block's fields instead ({ email: 'string', time: 'string' }).`
113
+ : /open map/.test(reason)
114
+ ? " An open map is read when its values are records: values: { type: 'object', fields: { … } }."
115
+ : ''
116
+ console.warn(
117
+ `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}`
118
+ )
119
+ }
@@ -480,6 +480,11 @@ function addUnit(units, source, field, context, markup = null) {
480
480
  // Two elements can share a key and differ in markup ("Learn more" plain in one
481
481
  // place, linked in another). The first occurrence wins the field and the order,
482
482
  // but a later one may be the only carrier of markup, so fill it rather than drop it.
483
+ // ⭐ And say that the places disagree (`markupVaries`): one value serves them all, so
484
+ // `uniweb i18n init` starts such a unit with its plain words, which a plain place takes as
485
+ // they are and a link takes as its label (`merge.js::translatedInline`). ⛔ Until 2026-10-16
486
+ // nothing said so, and init seeded the markup everywhere — a header's heading became a link.
487
+ if ((markup || null) !== (units[hash].markup || null)) units[hash].markupVaries = true
483
488
  if (markup && !units[hash].markup) units[hash].markup = markup
484
489
  const existingContexts = units[hash].contexts
485
490
  const contextKey = `${context.page}:${context.section}`
package/src/i18n/merge.js CHANGED
@@ -183,6 +183,9 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
183
183
  return translated
184
184
  }
185
185
 
186
+ // The blocks that hold words, which `toPlainText` reads as one run each.
187
+ const TEXT_BLOCKS = new Set(['paragraph', 'heading', 'codeBlock'])
188
+
186
189
  /**
187
190
  * ⭐ A TRANSLATION VALUE IS INLINE MARKDOWN, AND A PLAIN-TEXT SLOT RENDERS ITS TEXT.
188
191
  *
@@ -208,18 +211,27 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
208
211
  * ⛔ Do not add the field or the role to the hash. Genuine wording divergence by
209
212
  * context is what `overrides` is for (`lookupTranslation`); representation
210
213
  * divergence is this function's job.
214
+ *
215
+ * ⭐ One rule for both lanes: a push sends a plain-text slot's translations through it too
216
+ * (`uwx/locale-sync.js::plainTextTargets`). ⛔ Until 2026-10-16 only the build did, so a host
217
+ * was handed `[Sign in](/sign-in)` as a page's French title.
211
218
  */
212
- function toPlainText(value) {
219
+ export function toPlainText(value) {
213
220
  if (typeof value !== 'string' || !value.includes('[') && !value.includes('*') && !value.includes('`') && !value.includes('_')) {
214
221
  return value
215
222
  }
216
223
  try {
217
224
  const doc = markdownToProseMirror(value)
218
- const collect = (nodes) =>
225
+ // A block's words run together; blocks are apart. ⛔ Until 2026-10-16 every level joined with
226
+ // nothing, so a value of two paragraphs read as one word at the seam ("…17hFermé…").
227
+ const inline = (nodes) =>
228
+ (nodes || []).map((n) => (n.type === 'text' ? n.text || '' : n.type === 'hardBreak' ? ' ' : inline(n.content))).join('')
229
+ const blocks = (nodes) =>
219
230
  (nodes || [])
220
- .map((n) => (n.type === 'text' ? n.text || '' : n.type === 'hardBreak' ? ' ' : collect(n.content)))
221
- .join('')
222
- const text = collect(doc?.content).trim()
231
+ .map((n) => (TEXT_BLOCKS.has(n.type) ? inline(n.content) : n.type === 'text' ? n.text || '' : blocks(n.content)))
232
+ .filter((t) => t.trim())
233
+ .join(' ')
234
+ const text = blocks(doc?.content).trim()
223
235
  return text || value
224
236
  } catch {
225
237
  return value
@@ -239,7 +251,8 @@ function translateSiteMeta(config, translations, fallbackToSource) {
239
251
  if (!seo || typeof seo !== 'object') return
240
252
  for (const key of ['ogTitle', 'ogDescription']) {
241
253
  if (typeof seo[key] === 'string' && seo[key]) {
242
- seo[key] = lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource)
254
+ // A plain-text slot, as a page's are (`translatePageMeta`). ⛔ Not flattened until 2026-10-16.
255
+ seo[key] = toPlainText(lookupTranslation(seo[key], SITE_META_CONTEXT, translations, fallbackToSource))
243
256
  }
244
257
  }
245
258
  }
@@ -381,9 +394,18 @@ function applyElementTranslation(node, context, translations, fallbackToSource)
381
394
  * its link: the button became a plain paragraph, or vanished from a section that renders only
382
395
  * links. Locale files keyed before whole-element keying hold exactly such labels.
383
396
  *
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`).
397
+ * A value that writes a link is taken as written, re-targeted or not — and so where the source
398
+ * has none: an author may add a link in one language in the app, and a push carries back what a
399
+ * pull wrote. So is a plain value for an element that mixes words and links, since which words
400
+ * were linked cannot be known; `uniweb i18n status` reports that loss (`translationKeepsMarkup`).
401
+ *
402
+ * ⭐ One text is one unit wherever it appears (`hash.js`), so a heading that says `Faculty of
403
+ * Science` and a link that says it share a value. Its places disagreeing on markup is the seed's to
404
+ * handle, not this function's: extraction marks such a unit (`markupVaries`, `extract.js`), and
405
+ * `uniweb i18n init` starts it with its plain words — which the heading takes as they are and the
406
+ * link takes as its label. ⛔ @uniweb/build 1.3.6 (2026-10-16) instead dropped a value's link from
407
+ * any element whose source had none: it fixed the seeded heading, and lost a link an author had
408
+ * added on the next push. Reverted the same day.
387
409
  *
388
410
  * @param {Array} source - the element's source inline content
389
411
  * @param {string} value - the translation, inline markdown
@@ -495,8 +517,10 @@ export function linkLabels(source, target) {
495
517
  return labels.length === links.length ? labels : null
496
518
  }
497
519
 
520
+ // A link's mark — `link`, or `button`, which is a link drawn as a button (`[Go](/x){button}`).
521
+ // ⛔ Until 2026-10-16 only `link`: a `{button}` translated by its label alone lost the button.
498
522
  function linkMarkOf(node) {
499
- return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link') || null : null
523
+ return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link' || mark.type === 'button') || null : null
500
524
  }
501
525
 
502
526
  function sameMark(a, b) {
@@ -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
  }
@@ -31,6 +31,7 @@ import {
31
31
  } from '../generate-entry.js'
32
32
  import { importMapPlugin } from '../import-map-plugin.js'
33
33
  import { jsxExtensionGuard } from '../jsx-extension.js'
34
+ import { foundationScopeDefine } from '../foundation/scope-define.js'
34
35
  import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
35
36
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
36
37
  import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
@@ -333,6 +334,11 @@ export async function defineSiteConfig(options = {}) {
333
334
  foundationPath: foundationInfo.path // For loading foundation theme vars
334
335
  }),
335
336
 
337
+ // A bundled foundation is compiled here, so its `__FOUNDATION_SCOPE__` is set here: its
338
+ // scope in a build, null under `uniweb dev` (`foundation/scope-define.js`). A foundation
339
+ // loaded by URL carries its own, set by its build.
340
+ !isRuntimeMode && foundationInfo.path && foundationScopeDefine({ foundationDir: foundationInfo.path }),
341
+
336
342
  // Foundation dev server (only in runtime mode with local foundation)
337
343
  isRuntimeMode &&
338
344
  foundationInfo.type === 'local' &&
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { parseOptionsRef, FIELD_RULES } from '@uniweb/schemas/format'
25
+ import { qualifyRef } from '@uniweb/schemas/refs'
25
26
 
26
27
  // Text kinds carry per-locale values when localized.
27
28
  const TEXT_KINDS = new Set(['string', 'text'])
@@ -690,9 +691,5 @@ function sectionAttrsFromField(field) {
690
691
  function defaultResolver(ownName) {
691
692
  const slash = ownName.indexOf('/')
692
693
  const org = ownName[0] === '@' && slash > 1 ? ownName.slice(1, slash) : ''
693
- return (ref) => {
694
- if (typeof ref !== 'string') return ref
695
- if (ref.startsWith('@/')) return `@${org}/${ref.slice(2)}`
696
- return ref
697
- }
694
+ return (ref) => qualifyRef(ref, org)
698
695
  }
@@ -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'
@@ -239,6 +239,30 @@ export function contextKey(context) {
239
239
  return context?.key ?? `${context?.page}:${context?.section}`
240
240
  }
241
241
 
242
+ /**
243
+ * A localized PLAIN-TEXT value — a page's title, label, description, keywords and Open Graph
244
+ * texts, and the site's — with each translation as text, as the build renders it
245
+ * (`i18n/merge.js::toPlainText`). The source stays as the author wrote it, as the build leaves it.
246
+ * ⛔ Until 2026-10-16 a push sent a translation's markdown as it was, so a unit shared by a page's
247
+ * title and a link to the page reached a host as `[Sign in](/sign-in)`.
248
+ *
249
+ * @param {object|Array|undefined} value - a `{ locale: text }` map, or a list of them
250
+ * @param {string} sourceLocale
251
+ */
252
+ export function plainTextTargets(value, sourceLocale) {
253
+ if (Array.isArray(value)) return value.map((item) => plainTextTargets(item, sourceLocale))
254
+ if (!value || typeof value !== 'object') return value
255
+ let out = value
256
+ for (const [locale, text] of Object.entries(value)) {
257
+ if (locale === sourceLocale || typeof text !== 'string') continue
258
+ const plain = toPlainText(text)
259
+ if (plain === text) continue
260
+ if (out === value) out = { ...value }
261
+ out[locale] = plain
262
+ }
263
+ return out
264
+ }
265
+
242
266
  /**
243
267
  * Wrap a `multiple: true` + `localized: true` field (e.g. page `keywords` — "array
244
268
  * of localized strings") into per-locale form by localizing each element. Container
@@ -46,7 +46,8 @@ import { dataKeyTypes } from './data-key-types.js'
46
46
  import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
47
47
  import { folderYmlPath } from '../site/records-config.js'
48
48
  import { contentBodyTarget } from './record-layout.js'
49
- import { unresolveSelfScope, resolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
49
+ import { qualifyRef } from '@uniweb/schemas/refs'
50
+ import { unresolveSelfScope, ownSchemaNames, refuseOrgOption } from './self-scope.js'
50
51
  import { parseCatalogRef } from '../site/foundation-ref.js'
51
52
  import { renderEntityDocument } from './backfill.js'
52
53
  import { labelForFile } from '../site/entry-label.js'
@@ -54,6 +55,7 @@ import { LINK_MODEL, writeLinkRecords } from './link-records.js'
54
55
  import { FILE_MODEL, filesToPull } from './file-records.js'
55
56
  import { parseBibtex } from '@citestyle/bibtex'
56
57
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
58
+ import { siteSourceLocale } from './source-locale.js'
57
59
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
58
60
 
59
61
  // Single-record source extensions we scan + place (BibTeX is multi-record → out).
@@ -215,13 +217,13 @@ function recordDirsReadingAs(recordsRoot, model, scope, { own = null, keyTypes =
215
217
  const at = [first]
216
218
  const keyType = !own?.has(first) ? keyTypes?.get(first) : null
217
219
  if (
218
- resolveSelfScope(schemaForPoolDirs(at), scope) === model ||
219
- (keyType && resolveSelfScope(keyType, scope) === model)
220
+ qualifyRef(schemaForPoolDirs(at), scope) === model ||
221
+ (keyType && qualifyRef(keyType, scope) === model)
220
222
  ) {
221
223
  dirs.push(join(recordsRoot, first))
222
224
  }
223
225
  for (const second of visible(join(recordsRoot, first))) {
224
- if (resolveSelfScope(schemaForPoolDirs([first, second]), scope) === model) {
226
+ if (qualifyRef(schemaForPoolDirs([first, second]), scope) === model) {
225
227
  dirs.push(join(recordsRoot, first, second))
226
228
  }
227
229
  }
@@ -451,7 +453,7 @@ function typedQueryFolders(siteRoot, recordsRoot, scope, own, keyTypes) {
451
453
  for (const [name, schema] of authoredQuerySchemas(siteRoot, siteYml)) {
452
454
  if (schema !== null || own?.has(name)) continue
453
455
  const type = keyTypes.get(name)
454
- const model = type ? resolveSelfScope(type, scope) : null
456
+ const model = type ? qualifyRef(type, scope) : null
455
457
  if (model && !out.has(model)) out.set(model, join(recordsRoot, name))
456
458
  }
457
459
  return out
@@ -527,7 +529,7 @@ export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest })
527
529
  * @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
528
530
  * `file` is where the organization lives, as the author would write it
529
531
  */
530
- export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
532
+ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = siteSourceLocale(siteRoot) }) {
531
533
  const warnings = []
532
534
  const { abs, rel: file } = folderYmlPath(siteRoot)
533
535
  if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
@@ -625,7 +627,9 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
625
627
  * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
626
628
  */
627
629
  export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
628
- const { resolveDeclaration, sourceLocale = 'en' } = opts
630
+ // The site's language, as its `site.yml` says it — written by the pull of the site's content, which
631
+ // runs first (`./source-locale.js`).
632
+ const { resolveDeclaration, sourceLocale = siteSourceLocale(siteRoot) } = opts
629
633
  refuseOrgOption(opts, 'recordsToProject')
630
634
  // The scope the push qualified `@/x` with — the site's foundation's — so a
631
635
  // `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
@@ -59,11 +59,13 @@ import {
59
59
  import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isOpenMapSection, openMapEntries } from './data-schema.js'
60
60
  import { recordFileLayout, contentBodyTarget } from './record-layout.js'
61
61
  import { emitEntitySyncPackage } from './entity-document.js'
62
- import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
62
+ import { qualifyRef } from '@uniweb/schemas/refs'
63
+ import { siteSelfScope, refuseOrgOption } from './self-scope.js'
63
64
  import { sha256Hex, toJsonBuffer } from './manifest.js'
64
65
  import { isUuid } from './uuid.js'
65
66
  import { markdownToProseMirror } from '@uniweb/content-reader'
66
67
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
68
+ import { siteSourceLocale } from './source-locale.js'
67
69
  import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
68
70
  import { loadFreeformRecord } from '../i18n/freeform.js'
69
71
  import { isDraftRecord } from '../site/record-draft.js'
@@ -822,10 +824,7 @@ function resolveDeclaration(schema, modelName) {
822
824
  const dataSchemas = schema?.dataSchemas || {}
823
825
  const m = /^@([^/]+)\/(.+)$/.exec(modelName)
824
826
  const org = m ? m[1] : null
825
- const resolveName = (ref) =>
826
- typeof ref === 'string' && ref.startsWith('@/') && org
827
- ? `@${org}/${ref.slice(2)}`
828
- : ref
827
+ const resolveName = (ref) => qualifyRef(ref, org)
829
828
  for (const [ref, normalized] of Object.entries(dataSchemas)) {
830
829
  if (resolveName(ref) === modelName) {
831
830
  return toDataSchemaDeclaration(normalized, { name: modelName, resolveName })
@@ -972,7 +971,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
972
971
  opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
973
972
  const warnedUnscoped = new Set()
974
973
  const modelFor = (schema, where) => {
975
- const modelName = resolveSelfScope(schema, scope)
974
+ const modelName = qualifyRef(schema, scope)
976
975
  // Unresolvable `@/` — the foundation has no scope yet. Ship it rather than
977
976
  // throwing (a `status` probe on a never-registered foundation must still count),
978
977
  // but say so: the backend's refusal names a missing Model and cannot name this cause.
@@ -1031,7 +1030,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1031
1030
  }
1032
1031
 
1033
1032
  const sourceLocale =
1034
- opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
1033
+ opts.sourceLocale || siteSourceLocale(siteRoot)
1035
1034
 
1036
1035
  // Target locales for wrapping localized record fields per-locale: those with a
1037
1036
  // structural-translation file (locales/records/{locale}.json) UNIONED with
@@ -1277,8 +1276,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
1277
1276
  ? !unresolved.has(schema)
1278
1277
  : Boolean((await declarationFor(modelFor(schema, `query "${name}"`))) || (await typedFor(schema))))
1279
1278
  if (!resolved) {
1280
- if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
1281
- schemaless.push({ name, model: resolveSelfScope(schema, scope) })
1279
+ if (decl.schemaExplicit) throw unresolvedExplicit(qualifyRef(schema, scope), name)
1280
+ schemaless.push({ name, model: qualifyRef(schema, scope) })
1282
1281
  continue
1283
1282
  }
1284
1283
  if (!hasRecords) {
@@ -28,6 +28,7 @@ import { join } from 'node:path'
28
28
 
29
29
  import { normalizeFoundationSchema } from '@uniweb/schemas/foundation'
30
30
  import { parseOptionsRef } from '@uniweb/schemas/format'
31
+ import { qualifyRef } from '@uniweb/schemas/refs'
31
32
  import { toDataSchemaDeclaration, optionsSectionPath } from './data-schema.js'
32
33
  import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
33
34
 
@@ -146,8 +147,7 @@ function buildDataSchemaEntities(dataSchemas, scope) {
146
147
  // `scope` ('@acme' or 'acme') resolves a defined schema's own `@/x` name to a
147
148
  // concrete `@acme/x`. With no scope, `@/x` passes through (local preview).
148
149
  const org = scope ? String(scope).replace(/^@/, '').replace(/\/.*$/, '') : null
149
- const scoped = (ref) =>
150
- org && typeof ref === 'string' && ref.startsWith('@/') ? `@${org}/${ref.slice(2)}` : ref
150
+ const scoped = (ref) => qualifyRef(ref, org)
151
151
 
152
152
  const resolveOptions = makeOptionsResolver(dataSchemas, scoped)
153
153
  const entities = Object.entries(dataSchemas)
@@ -7,7 +7,9 @@
7
7
  // the entity store at restore, and a records service answering a hosted page's
8
8
  // query.
9
9
  //
10
- // ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH, and its inverse:
10
+ // ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH — `qualifyRef`
11
+ // (`@uniweb/schemas/refs`, the rule `register` and a foundation's own calls to its
12
+ // backend use too) with the scope `siteSelfScope` reads — and its inverse here:
11
13
  //
12
14
  // - a record's `$schema` `records.js::buildRecordEntities`
13
15
  // - a query's `schema` `site.js::queriesNested` (the `queries` Section)
@@ -51,24 +53,8 @@ export function bareOrg(scope) {
51
53
  }
52
54
 
53
55
  /**
54
- * `@/x` → `@<scope>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
55
- * does `@/x` when no scope is known — callers that ship an unresolved alias say so
56
- * themselves (`buildRecordEntities` warns per query).
57
- *
58
- * @param {unknown} ref
59
- * @param {unknown} scope - the site's foundation's scope (`siteSelfScope`)
60
- * @returns {unknown}
61
- */
62
- export function resolveSelfScope(ref, scope) {
63
- const handle = bareOrg(scope)
64
- return typeof ref === 'string' && ref.startsWith('@/') && handle
65
- ? `@${handle}/${ref.slice(2)}`
66
- : ref
67
- }
68
-
69
- /**
70
- * The inverse, for a pull: `@<scope>/x` → `@/x` — when `x` is one of the foundation's
71
- * OWN data schemas.
56
+ * The inverse of `qualifyRef`, for a pull: `@<scope>/x` → `@/x` — when `x` is one
57
+ * of the foundation's OWN data schemas.
72
58
  *
73
59
  * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
74
60
  * on both ends. A record authored under `records/article/` comes back as
@@ -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'
@@ -87,7 +87,8 @@ import {
87
87
  refuseUnreadableServices
88
88
  } from './services-request.js'
89
89
  import { resolveQueriesConfig } from './queries-config.js'
90
- import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
90
+ import { qualifyRef } from '@uniweb/schemas/refs'
91
+ import { siteSelfScope, refuseOrgOption } from './self-scope.js'
91
92
  import { siteContentDirs } from './site-dirs.js'
92
93
 
93
94
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
@@ -264,11 +265,13 @@ async function localizeContentTree(pages, layoutSections, sourceLocale, targetLo
264
265
  // build translates them (`i18n/merge.js::translatePageMeta`, `<route>:_meta`): an override for one
265
266
  // page applies there and nowhere else. ⛔ Until 2026-09-26 they were resolved with no page, so an
266
267
  // override never reached a backend — nor, until the same day, an entry's default.
268
+ // ⭐ Each is a plain-text slot, so a translation reaches it as text, as the build renders it
269
+ // (`plainTextTargets`).
267
270
  function localizePageMeta(page, route, sourceLocale, translations) {
268
271
  const context = { page: route, section: '_meta' }
269
272
  const again = (value) =>
270
273
  value && typeof value === 'object' && typeof value[sourceLocale] === 'string'
271
- ? localizeScalar(value[sourceLocale], sourceLocale, translations, context)
274
+ ? plainTextTargets(localizeScalar(value[sourceLocale], sourceLocale, translations, context), sourceLocale)
272
275
  : value
273
276
  for (const key of ['title', 'label', 'description', 'og_title', 'og_description']) {
274
277
  if (page[key] !== undefined) page[key] = again(page[key])
@@ -1053,7 +1056,7 @@ function queriesNested(declarations, uuids = null, scope = null, keyTyped = null
1053
1056
  // and a store that checks a query's `schema` against the site's declared types refused the
1054
1057
  // whole content push for it. An explicit `schema:` that does not resolve never reaches here:
1055
1058
  // the records lane refuses it.
1056
- if (!schemaless?.has(name)) setIf(data, 'schema', resolveSelfScope(schema, scope))
1059
+ if (!schemaless?.has(name)) setIf(data, 'schema', qualifyRef(schema, scope))
1057
1060
  // ⭐ …AND SAYS SO, so a pull can tell the author's form (`team:`, no schema) from an explicit
1058
1061
  // one — sent only where the deployment's `queries` Section declares the key (`GET /dev/config`
1059
1062
  // → `siteContent.queryFields`), since one that does not refuses a push carrying it.
@@ -1306,14 +1309,15 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1306
1309
  // The site's Open Graph title and description per language, where the deployment's `settings`
1307
1310
  // takes them (`uwx/open-graph.js`), in the site's context (`SITE_META_CONTEXT`).
1308
1311
  // (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))
1312
+ // Plain-text slots, each translation as text (`plainTextTargets`).
1313
+ setIf(settings, 'og_title', plainTextTargets(openGraphValue(siteYml.seo, 'og_title', settingsFields, sourceLocale, translations, SITE_META_CONTEXT), sourceLocale))
1314
+ setIf(settings, 'og_description', plainTextTargets(openGraphValue(siteYml.seo, 'og_description', settingsFields, sourceLocale, translations, SITE_META_CONTEXT), sourceLocale))
1311
1315
  setIf(settings, 'seo', seoBesideOpenGraph(siteYml.seo, settingsFields))
1312
1316
  // ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
1313
1317
  // (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
1314
1318
  // convenience, not because it is a different kind of thing. Localized, so it
1315
1319
  // carries the translation collector with it.
1316
- setIf(settings, 'keywords', localizeScalarList(siteYml.keywords, sourceLocale, translations))
1320
+ setIf(settings, 'keywords', plainTextTargets(localizeScalarList(siteYml.keywords, sourceLocale, translations), sourceLocale))
1317
1321
 
1318
1322
  // ⛔ NO SERVICE IS HERE. A service's address and options ride its row in the
1319
1323
  // `services` Section, whole (`requestedServices`) [Diego, 2026-10-06: "If it's on and
@@ -1431,7 +1435,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1431
1435
  // a localized `{lang:value}` map. Genuine content fields (`description` below, page
1432
1436
  // title/slug/label/keywords, the body) stay localized. (uwx-format.md → identity-label names.)
1433
1437
  info.name = siteYml.name
1434
- setIf(info, 'description', localizeScalar(siteYml.description, sourceLocale, translations))
1438
+ setIf(info, 'description', plainTextTargets(localizeScalar(siteYml.description, sourceLocale, translations), sourceLocale))
1435
1439
  // Publish intent (site.yml `publishLanguages:`) rides VERBATIM — dangling
1436
1440
  // codes included. Sync carries the full working set; only *publish* filters
1437
1441
  // (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 || {})
@@ -18,6 +18,8 @@ import { DEFAULT_EXTERNALS } from './import-map-plugin.js'
18
18
  import { deriveSupports } from './foundation/derive-supports.js'
19
19
  import { proseWithoutStyles, PROSE_WITHOUT_STYLES_WARNING } from './foundation/prose-styles.js'
20
20
  import { nodeEnvDefine } from './foundation/node-env.js'
21
+ import { foundationScopeDefine } from './foundation/scope-define.js'
22
+ import { DEV_REBUILD_MARKER, hasDevRebuildMarker } from './foundation/dev-rebuild.js'
21
23
 
22
24
  // Foundations already told that their prose has no stylesheet — once per process, so a dev
23
25
  // server's rebuild on every save does not repeat it.
@@ -348,7 +350,7 @@ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
348
350
  }
349
351
  const react = (await import('@vitejs/plugin-react')).default
350
352
  const svgr = (await import('vite-plugin-svgr')).default
351
- plugins.push(react(), svgr(), nodeEnvDefine())
353
+ plugins.push(react(), svgr(), nodeEnvDefine(), foundationScopeDefine({ foundationDir: foundationRoot }))
352
354
 
353
355
  await viteBuild({
354
356
  root: foundationRoot,
@@ -389,21 +391,8 @@ async function buildEntrySSR(foundationRoot, entrySourcePath, outDir) {
389
391
  }
390
392
  }
391
393
 
392
- /**
393
- * Marks a foundation build as a DEV-server rebuild.
394
- *
395
- * The dev server runs a real Vite `build()` of the foundation on every watched
396
- * change (`dev/plugin.js`), so `command`, `mode` and `isProduction` cannot tell
397
- * a dev rebuild from a shipping build — all of them say "build". An injected
398
- * marker plugin can, and it survives the foundation's own `vite.config.js`
399
- * being merged in, which a plugin *option* would not (the dev server does not
400
- * construct the foundation plugin — the foundation's config file does).
401
- *
402
- * Used to skip the `entry-ssr.js` sub-build in dev: it is a second full Vite
403
- * pass per save, and nothing in the dev loop reads it (dev never ships, and the
404
- * SSR lanes that run locally — SSG prerender and unipress — load `entry.js`).
405
- */
406
- export const DEV_REBUILD_MARKER = 'uniweb:dev-foundation-rebuild'
394
+ // The dev-rebuild marker lives in a leaf (`foundation/dev-rebuild.js`); exported here as before.
395
+ export { DEV_REBUILD_MARKER }
407
396
 
408
397
  /**
409
398
  * Vite plugin for foundation builds
@@ -440,7 +429,7 @@ export function foundationBuildPlugin(options = {}) {
440
429
  resolvedOutDir = config.build.outDir
441
430
  resolvedRoot = config.root
442
431
  isProduction = config.mode === 'production'
443
- isDevRebuild = (config.plugins || []).some((p) => p?.name === DEV_REBUILD_MARKER)
432
+ isDevRebuild = hasDevRebuildMarker(config)
444
433
  },
445
434
 
446
435
  async writeBundle(_options, bundle) {