@uniweb/build 0.52.6 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.52.6",
3
+ "version": "0.53.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,14 +58,14 @@
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
60
  "@uniweb/content-reader": "^1.2.5",
61
+ "@uniweb/schemas": "^0.3.3",
62
+ "@uniweb/theming": "^0.1.15",
61
63
  "@uniweb/content-writer": "^0.3.4",
62
64
  "@uniweb/projections": "^0.7.3",
63
- "@uniweb/semantic-parser": "^1.4.1",
64
- "@uniweb/schemas": "^0.3.1",
65
- "@uniweb/theming": "^0.1.15"
65
+ "@uniweb/semantic-parser": "^1.4.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.26.4"
68
+ "@uniweb/runtime": "^0.26.5"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/core": "^0.29.4",
78
- "@uniweb/runtime": "^0.26.4"
77
+ "@uniweb/runtime": "^0.26.5",
78
+ "@uniweb/core": "^0.29.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/i18n/audit.js CHANGED
@@ -11,6 +11,17 @@ import { readFile, writeFile } from 'fs/promises'
11
11
  import { existsSync } from 'node:fs'
12
12
  import { join } from 'node:path'
13
13
 
14
+ // Inline markdown → ProseMirror, so the markup check asks the merge's own question
15
+ // ("would this value produce marks?") instead of pattern-matching markdown syntax.
16
+ // Same lazy-import-with-fallback pattern as merge.js / extract.js.
17
+ let markdownToProseMirror
18
+ try {
19
+ const contentReader = await import('@uniweb/content-reader')
20
+ markdownToProseMirror = contentReader.markdownToProseMirror
21
+ } catch {
22
+ markdownToProseMirror = null
23
+ }
24
+
14
25
  /**
15
26
  * Audit a locale file against the manifest
16
27
  * @param {string} localesPath - Path to locales directory
@@ -46,18 +57,27 @@ export async function auditLocale(localesPath, locale) {
46
57
  const valid = []
47
58
  const missing = []
48
59
  const stale = []
49
- const needsTags = []
60
+ const losesMarkup = []
50
61
 
51
62
  // Check manifest entries
52
63
  for (const hash of manifestHashes) {
53
64
  if (translationHashes.has(hash)) {
54
- const source = manifest.units[hash].source
65
+ const unit = manifest.units[hash]
66
+ const source = unit.source
55
67
  const translation = getTranslationText(translations[hash])
56
68
  valid.push({ hash, source, translation })
57
69
 
58
- // Flag entries where source has inline tags but translation doesn't
59
- if (/<\d+>/.test(source) && !/<\d+>/.test(translation) && translation.length > 0) {
60
- needsTags.push({ hash, source, translation })
70
+ // ⭐ The source element carries inline markdown; the translation does not.
71
+ // `merge` builds the translated element from the VALUE alone — it never
72
+ // re-applies marks from the source — so this entry will render as flat prose
73
+ // and its links will be gone. It still counts as translated, which is why
74
+ // coverage cannot see it: the whole class scores 100%.
75
+ // ⛔ REPLACES a `<N>`-tag check that could no longer fire. Those tags were the
76
+ // pre-2026-06-24 keying scheme (`d12d594`); extraction has not emitted one
77
+ // since, so the check was dead while looking like a live guard — and it was
78
+ // the only thing in the pipeline that even gestured at mark fidelity.
79
+ if (unit.markup && translation.length > 0 && !carriesInlineMarkup(translation)) {
80
+ losesMarkup.push({ hash, source, markup: unit.markup, translation })
61
81
  }
62
82
  } else {
63
83
  missing.push({
@@ -86,7 +106,29 @@ export async function auditLocale(localesPath, locale) {
86
106
  valid,
87
107
  missing,
88
108
  stale,
89
- needsTags
109
+ losesMarkup
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Does this translation value produce any inline marks when the merge parses it?
115
+ *
116
+ * Asks the parser rather than a regex: the merge resolves a value through
117
+ * `markdownToProseMirror`, so the only honest test of "will this keep its link"
118
+ * is to run the same conversion. With no converter available the check declines
119
+ * to fire — a missing dependency must not invent findings.
120
+ */
121
+ function carriesInlineMarkup(value) {
122
+ if (!markdownToProseMirror) return true
123
+ try {
124
+ const doc = markdownToProseMirror(value)
125
+ const walk = (nodes) =>
126
+ (nodes || []).some(
127
+ (n) => (n?.marks && n.marks.length > 0) || (n?.type && n.type !== 'text' && n.type !== 'paragraph') || walk(n?.content)
128
+ )
129
+ return walk(doc?.content)
130
+ } catch {
131
+ return true
90
132
  }
91
133
  }
92
134
 
@@ -161,8 +203,8 @@ export function formatAuditReport(results, options = {}) {
161
203
  lines.push(` Valid: ${result.valid.length} (${coverage}%)`)
162
204
  lines.push(` Missing: ${result.missing.length}`)
163
205
  lines.push(` Stale: ${result.stale.length}`)
164
- if (result.needsTags?.length > 0) {
165
- lines.push(` Needs tags: ${result.needsTags.length}`)
206
+ if (result.losesMarkup?.length > 0) {
207
+ lines.push(` Loses markup: ${result.losesMarkup.length} (links/bold in the source, plain in the translation)`)
166
208
  }
167
209
 
168
210
  if (verbose && result.stale.length > 0) {
@@ -176,14 +218,16 @@ export function formatAuditReport(results, options = {}) {
176
218
  }
177
219
  }
178
220
 
179
- if (verbose && result.needsTags?.length > 0) {
180
- lines.push(`\n Translations missing inline tags:`)
181
- for (const entry of result.needsTags.slice(0, 10)) {
182
- const src = truncate(entry.source, 50)
183
- lines.push(` - ${entry.hash}: "${src}"`)
221
+ if (verbose && result.losesMarkup?.length > 0) {
222
+ lines.push(`\n Translations that drop the source's inline markup:`)
223
+ lines.push(` (a translation value is inline markdown — copy the links and marks across)`)
224
+ for (const entry of result.losesMarkup.slice(0, 10)) {
225
+ lines.push(` - ${entry.hash}`)
226
+ lines.push(` source: "${truncate(entry.markup, 60)}"`)
227
+ lines.push(` yours: "${truncate(entry.translation, 60)}"`)
184
228
  }
185
- if (result.needsTags.length > 10) {
186
- lines.push(` ... and ${result.needsTags.length - 10} more`)
229
+ if (result.losesMarkup.length > 10) {
230
+ lines.push(` ... and ${result.losesMarkup.length - 10} more`)
187
231
  }
188
232
  }
189
233
  }
@@ -9,6 +9,19 @@ import { resolveDefaultLocale } from '@uniweb/core'
9
9
  import { computeHash, stripInlineTags } from './hash.js'
10
10
  import { visitDataStrings } from './data-strings.js'
11
11
 
12
+ // ProseMirror inline fragment → inline markdown, for the TRANSLATOR-FACING half of
13
+ // a unit. Same lazy-import-with-fallback pattern as merge.js, so the synchronous
14
+ // extraction path has the serializer ready at call time. The fallback is the plain
15
+ // text we already had, so a resolution failure degrades to the old behaviour rather
16
+ // than breaking extraction.
17
+ let serializeInlineContent
18
+ try {
19
+ const contentWriter = await import('@uniweb/content-writer')
20
+ serializeInlineContent = contentWriter.serializeInlineContent
21
+ } catch {
22
+ serializeInlineContent = null
23
+ }
24
+
12
25
  /**
13
26
  * Extract all translatable units from site content
14
27
  * @param {Object} siteContent - Parsed site-content.json
@@ -231,7 +244,7 @@ function extractFromProseMirrorDoc(doc, context, units) {
231
244
  const field = getHeadingField(level, headingIndex)
232
245
  headingIndex[`h${level}`]++
233
246
 
234
- addUnit(units, text, field, context)
247
+ addUnit(units, text, field, context, elementMarkup(node))
235
248
  return
236
249
  }
237
250
 
@@ -242,12 +255,12 @@ function extractFromProseMirrorDoc(doc, context, units) {
242
255
  if (!text) return
243
256
 
244
257
  if (listIndex !== null) {
245
- addUnit(units, text, `list.${listIndex}`, context)
258
+ addUnit(units, text, `list.${listIndex}`, context, elementMarkup(node))
246
259
  return
247
260
  }
248
261
 
249
262
  const field = paragraphIndex === 0 ? 'paragraph' : `paragraph.${paragraphIndex}`
250
- addUnit(units, text, field, context)
263
+ addUnit(units, text, field, context, elementMarkup(node))
251
264
  paragraphIndex++
252
265
  })
253
266
  }
@@ -348,6 +361,37 @@ export function elementText(node) {
348
361
  return collectInlineText(node).trim()
349
362
  }
350
363
 
364
+ /**
365
+ * A block element's INLINE MARKDOWN — the translator-facing half of a unit.
366
+ *
367
+ * ⛔ NOT a second key. `elementText` above stays the key and the hash input,
368
+ * deliberately: bolding a word or re-targeting a link must not move a hash and
369
+ * orphan an existing translation.
370
+ *
371
+ * WHY THIS EXISTS. `merge.js` parses a translation VALUE as inline markdown, so
372
+ * the value channel carries links, marks and inline atoms losslessly. The SOURCE
373
+ * channel did not: a unit stored only the flattened text, `uniweb i18n generate`
374
+ * seeded locale files from it, and a translator was therefore shown a paragraph
375
+ * with its links already gone. They cannot restore what they were never shown —
376
+ * so a fully translated, 100%-covered site rendered with zero inline links, and
377
+ * no instrument reported anything (coverage counts keys, not fidelity).
378
+ * Measured 2026-09-18 on a documentation site with 266 internal links.
379
+ *
380
+ * Returns null when the serializer is unavailable or adds nothing over the plain
381
+ * text, so `markup` appears on a unit only when it actually carries more.
382
+ */
383
+ export function elementMarkup(node) {
384
+ if (!serializeInlineContent || !node?.content) return null
385
+ try {
386
+ const markup = serializeInlineContent(node.content)
387
+ if (typeof markup !== 'string') return null
388
+ const trimmed = markup.trim()
389
+ return trimmed && trimmed !== elementText(node) ? trimmed : null
390
+ } catch {
391
+ return null
392
+ }
393
+ }
394
+
351
395
  function collectInlineText(node) {
352
396
  if (!node || !node.content) return ''
353
397
  let out = ''
@@ -385,14 +429,18 @@ export function blockElements(doc) {
385
429
  /**
386
430
  * Add a translation unit to the accumulator
387
431
  */
388
- function addUnit(units, source, field, context) {
432
+ function addUnit(units, source, field, context, markup = null) {
389
433
  if (!source || source.length === 0) return
390
434
 
391
435
  // Hash on plain text (strip inline tags) so keys stay stable
392
436
  const hash = computeHash(stripInlineTags(source))
393
437
 
394
438
  if (units[hash]) {
395
- // Unit exists - add context if not already present
439
+ // Unit exists - add context if not already present.
440
+ // Two elements can share a key and differ in markup ("Learn more" plain in one
441
+ // place, linked in another). The first occurrence wins the field and the order,
442
+ // but a later one may be the only carrier of markup, so fill it rather than drop it.
443
+ if (markup && !units[hash].markup) units[hash].markup = markup
396
444
  const existingContexts = units[hash].contexts
397
445
  const contextKey = `${context.page}:${context.section}`
398
446
  const exists = existingContexts.some(
@@ -402,9 +450,11 @@ function addUnit(units, source, field, context) {
402
450
  existingContexts.push({ ...context })
403
451
  }
404
452
  } else {
405
- // New unit
453
+ // New unit. `markup` is omitted unless the element carries inline markdown —
454
+ // most units are plain prose and gain nothing from a duplicate string.
406
455
  units[hash] = {
407
456
  source,
457
+ ...(markup ? { markup } : {}),
408
458
  field,
409
459
  contexts: [{ ...context }]
410
460
  }
package/src/i18n/hash.js CHANGED
@@ -31,7 +31,21 @@ export function normalizeText(text) {
31
31
 
32
32
  /**
33
33
  * Strip inline mark tags for hashing: "<1>text</1>" → "text"
34
- * Used to keep hash keys stable regardless of mark tagging.
34
+ *
35
+ * VESTIGIAL BUT LIVE. `<N>` tags were the pre-2026-06-24 mark-preservation scheme:
36
+ * a unit's source carried numbered tags and the merge re-applied the source's marks
37
+ * to the matching spans positionally. Whole-element keying replaced it (`d12d594`) —
38
+ * a translation VALUE is now inline markdown and the merge re-parses it, so nothing
39
+ * emits a `<N>` tag any more.
40
+ *
41
+ * ⛔ This stays anyway, because it runs on the HASH path: a manifest written before
42
+ * that change still holds tagged sources, and stripping keeps their keys matching
43
+ * what extraction produces today. Removing it would silently orphan those
44
+ * translations. The tag PARSER that went with it (`parseInlineTags`) had no callers
45
+ * and was deleted 2026-09-18; it was the only thing in the codebase that still looked
46
+ * like a live mark-preservation convention, which cost a peer lane a day of hunting
47
+ * for the placeholder syntax it implied.
48
+ *
35
49
  * @param {string} text
36
50
  * @returns {string}
37
51
  */
@@ -39,36 +53,3 @@ export function stripInlineTags(text) {
39
53
  if (typeof text !== 'string') return ''
40
54
  return text.replace(/<\/?(\d+)>/g, '')
41
55
  }
42
-
43
- /**
44
- * Parse tagged translation string into segments.
45
- * "plain <1>marked</1> more" →
46
- * { segments: [{ text: "plain " }, { text: "marked", markIndex: 0 }, { text: " more" }], hasMarks: true }
47
- *
48
- * Tag numbers are 1-based in the string, markIndex is 0-based in the result.
49
- * @param {string} text
50
- * @returns {{ segments: Array<{ text: string, markIndex?: number }>, hasMarks: boolean }}
51
- */
52
- export function parseInlineTags(text) {
53
- const regex = /<(\d+)>([\s\S]*?)<\/\1>/g
54
- const segments = []
55
- let lastIndex = 0
56
- let hasMarks = false
57
- let match
58
-
59
- while ((match = regex.exec(text)) !== null) {
60
- hasMarks = true
61
- if (match.index > lastIndex) {
62
- segments.push({ text: text.slice(lastIndex, match.index) })
63
- }
64
- segments.push({ text: match[2], markIndex: parseInt(match[1], 10) - 1 })
65
- lastIndex = regex.lastIndex
66
- }
67
-
68
- if (lastIndex < text.length) {
69
- segments.push({ text: text.slice(lastIndex) })
70
- }
71
-
72
- if (!hasMarks) return { segments: [{ text }], hasMarks: false }
73
- return { segments, hasMarks }
74
- }
package/src/i18n/index.js CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  translateRecordData,
24
24
  RECORDS_DIR
25
25
  } from './records.js'
26
+ import { resolveDefaultLocale } from '@uniweb/core'
26
27
  import { generateSearchIndex } from '@uniweb/projections'
27
28
  import { searchDeclaredOn } from '../site/search-declared.js'
28
29
 
@@ -329,6 +330,48 @@ export async function getTranslationStatus(siteRoot, options = {}) {
329
330
  }
330
331
  }
331
332
 
333
+ /**
334
+ * ⭐ THE LOCALE SET A PAYLOAD DECLARES — resolved here, because this is the only
335
+ * place that knows it.
336
+ *
337
+ * ⛔ THE BUILD AND THE RUNTIME USED TO DISAGREE ABOUT WHERE LOCALES COME FROM, and
338
+ * nothing failed when they did. The build derives them from the FILESYSTEM —
339
+ * every `locales/*.json`, which is also what `languages: '*'` means — while
340
+ * `Website.buildLocalesList` reads `config.languages` and nothing else. So a site
341
+ * with locale files and no `languages:` in `site.yml` built a complete
342
+ * `dist/<locale>/` tree whose Website reported `hasMultipleLocales() === false`.
343
+ *
344
+ * Everything gated on that answer then silently switched off, in the rendered
345
+ * output, with no warning anywhere:
346
+ * - no link was locale-prefixed (`applyLocale`, @uniweb/kit/utils/href) — every
347
+ * internal link on a French page pointed into the English tree;
348
+ * - no route translation was applied, although the build had emitted the
349
+ * translated routes as directories (`/fr/a-propos/` existed; nothing linked
350
+ * to it);
351
+ * - any foundation UI gated on `hasMultipleLocales()` — the language switcher —
352
+ * vanished from the very pages that needed it.
353
+ *
354
+ * Measured 2026-09-18 on our own `international` template, which was in exactly
355
+ * this state. ⇒ **The producer stamps what it resolved; the consumer reads it.**
356
+ * Guessing the set twice, from two different sources, is what this replaces.
357
+ *
358
+ * Declared entries are kept as authored — a `{ code, label }` object carries a
359
+ * label the filesystem cannot know — and resolved codes are appended.
360
+ */
361
+ function declaredLanguages(config, defaultLocale, locales) {
362
+ const authored = Array.isArray(config?.languages) ? config.languages : []
363
+ const codeOf = (e) => (typeof e === 'string' ? e : e?.code)
364
+ const seen = new Set(authored.map(codeOf).filter(Boolean))
365
+ const out = authored.filter((e) => codeOf(e) && codeOf(e) !== '*')
366
+ for (const code of [defaultLocale, ...locales]) {
367
+ if (code && !seen.has(code)) {
368
+ seen.add(code)
369
+ out.push(code)
370
+ }
371
+ }
372
+ return out
373
+ }
374
+
332
375
  /**
333
376
  * Build translated site content for all locales
334
377
  * @param {string} siteRoot - Site root directory
@@ -353,10 +396,36 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
353
396
  const siteContentPath = join(outputDir, 'site-content.json')
354
397
  const siteContentRaw = await readFile(siteContentPath, 'utf-8')
355
398
  const siteContent = JSON.parse(siteContentRaw)
399
+ const defaultLocale = resolveDefaultLocale(siteContent.config)
356
400
 
357
401
  const outputs = {}
358
402
 
403
+ // ⭐ The DEFAULT locale's payload needs the same stamp. It is not written in
404
+ // this loop — it is the source this function reads — but a visitor landing on
405
+ // `/` gets a Website built from it, and without the set that page loses its
406
+ // language switcher while every translated page has one.
407
+ const stampedLanguages = declaredLanguages(siteContent.config, defaultLocale, locales)
408
+ if (JSON.stringify(siteContent.config?.languages) !== JSON.stringify(stampedLanguages)) {
409
+ siteContent.config = { ...siteContent.config, languages: stampedLanguages }
410
+ await writeFile(siteContentPath, JSON.stringify(siteContent, null, 2))
411
+ }
412
+
359
413
  for (const locale of locales) {
414
+ // ⛔ THE DEFAULT LOCALE IS THE ROOT TREE, NEVER A SUBDIRECTORY. Emitting
415
+ // `dist/<default>/` mints a byte-identical duplicate of every page at a second
416
+ // URL — duplicate content on any real domain, and the `hreflang` block already
417
+ // points that locale at `/` (`locale.default`, site/plugin.js) so nothing links
418
+ // to the copy.
419
+ //
420
+ // ⚠️ It was reachable only through the DECLARED form, which is the form the
421
+ // contract tells authors to use: `resolveLocales` returns an explicit
422
+ // `languages: [en, fr]` verbatim, default included, while the wildcard and
423
+ // filesystem forms exclude it by accident (there is no `locales/en.json`). So
424
+ // the documented spelling was the broken one. `uniweb i18n generate` has always
425
+ // skipped the default explicitly; this is the same rule, one stage later.
426
+ // Measured 2026-09-18: 47 pages duplicated under /en/.
427
+ if (locale === defaultLocale) continue
428
+
360
429
  const localePath = join(localesPath, `${locale}.json`)
361
430
 
362
431
  // Load translations (or empty object if not exists)
@@ -390,8 +459,14 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
390
459
  })
391
460
  }
392
461
 
393
- // Mark the active locale in the translated content
394
- translated.config = { ...translated.config, activeLocale: locale }
462
+ // Mark the active locale in the translated content, and stamp the resolved
463
+ // locale set so the runtime does not have to re-derive it — see
464
+ // `declaredLanguages`.
465
+ translated.config = {
466
+ ...translated.config,
467
+ activeLocale: locale,
468
+ languages: declaredLanguages(siteContent.config, defaultLocale, locales)
469
+ }
395
470
 
396
471
  // Write to locale subdirectory
397
472
  const localeOutputDir = join(outputDir, locale)
package/src/i18n/merge.js CHANGED
@@ -173,48 +173,93 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
173
173
  return translated
174
174
  }
175
175
 
176
+ /**
177
+ * ⭐ A TRANSLATION VALUE IS INLINE MARKDOWN, AND A PLAIN-TEXT SLOT RENDERS ITS TEXT.
178
+ *
179
+ * Units are deduplicated by normalized plain text, so ONE id carries every role
180
+ * that text plays on the site — and on a documentation site the two roles
181
+ * collide by design, because the house style is to link to a page by its title:
182
+ * "See [Roles and permissions](page:…)" makes that page's own title and the link
183
+ * to it the same string, hence the same unit.
184
+ *
185
+ * ⛔ That collision used to be unresolvable for the translator. A body occurrence
186
+ * needs the markdown (or the link is lost); a title occurrence was assigned the
187
+ * value RAW, so the same markdown landed verbatim in `<title>` — brackets,
188
+ * `page:` protocol and all — in the tab, the nav and every search result.
189
+ * Measured 2026-09-18: 32 of one site's 41 link texts were also page titles, so
190
+ * neither "markdown everywhere" nor "markdown nowhere" was shippable.
191
+ *
192
+ * ⭐ The resolution is on the CONSUMER, not the key. A body element parses the
193
+ * value into a fragment; a title takes its TEXT. One value serves both, and a
194
+ * page's title stays in lockstep with the links pointing at it — which is the
195
+ * property a cross-linked site wants and a role-keyed hash would destroy (the
196
+ * same word translated once per role, free to drift).
197
+ *
198
+ * ⛔ Do not add the field or the role to the hash. Genuine wording divergence by
199
+ * context is what `overrides` is for (`lookupTranslation`); representation
200
+ * divergence is this function's job.
201
+ */
202
+ function toPlainText(value) {
203
+ if (typeof value !== 'string' || !value.includes('[') && !value.includes('*') && !value.includes('`') && !value.includes('_')) {
204
+ return value
205
+ }
206
+ try {
207
+ const doc = markdownToProseMirror(value)
208
+ const collect = (nodes) =>
209
+ (nodes || [])
210
+ .map((n) => (n.type === 'text' ? n.text || '' : n.type === 'hardBreak' ? ' ' : collect(n.content)))
211
+ .join('')
212
+ const text = collect(doc?.content).trim()
213
+ return text || value
214
+ } catch {
215
+ return value
216
+ }
217
+ }
218
+
176
219
  /**
177
220
  * Translate page metadata (title, description, keywords, etc.)
221
+ *
222
+ * Every field here is a PLAIN-TEXT slot — `<title>`, a nav label, a meta
223
+ * description — so each value is flattened. See `toPlainText`.
178
224
  */
179
225
  function translatePageMeta(page, pageRoute, translations, fallbackToSource) {
180
226
  const context = { page: pageRoute, section: '_meta' }
227
+ const translate = (value) =>
228
+ toPlainText(lookupTranslation(value, context, translations, fallbackToSource))
181
229
 
182
230
  // Translate title
183
231
  if (page.title && typeof page.title === 'string') {
184
- page.title = lookupTranslation(page.title, context, translations, fallbackToSource)
232
+ page.title = translate(page.title)
185
233
  }
186
234
 
187
235
  // Translate label (short navigation label)
188
236
  if (page.label && typeof page.label === 'string') {
189
- page.label = lookupTranslation(page.label, context, translations, fallbackToSource)
237
+ page.label = translate(page.label)
190
238
  }
191
239
 
192
240
  // Translate description
193
241
  if (page.description && typeof page.description === 'string') {
194
- page.description = lookupTranslation(page.description, context, translations, fallbackToSource)
242
+ page.description = translate(page.description)
195
243
  }
196
244
 
197
245
  // Translate SEO fields
198
246
  if (page.seo) {
199
247
  if (page.seo.ogTitle && typeof page.seo.ogTitle === 'string') {
200
- page.seo.ogTitle = lookupTranslation(page.seo.ogTitle, context, translations, fallbackToSource)
248
+ page.seo.ogTitle = translate(page.seo.ogTitle)
201
249
  }
202
250
  if (page.seo.ogDescription && typeof page.seo.ogDescription === 'string') {
203
- page.seo.ogDescription = lookupTranslation(page.seo.ogDescription, context, translations, fallbackToSource)
251
+ page.seo.ogDescription = translate(page.seo.ogDescription)
204
252
  }
205
253
  }
206
254
 
207
255
  // Translate keywords
208
256
  if (page.keywords) {
209
257
  if (Array.isArray(page.keywords)) {
210
- page.keywords = page.keywords.map(keyword => {
211
- if (keyword && typeof keyword === 'string') {
212
- return lookupTranslation(keyword, context, translations, fallbackToSource)
213
- }
214
- return keyword
215
- })
258
+ page.keywords = page.keywords.map(keyword =>
259
+ keyword && typeof keyword === 'string' ? translate(keyword) : keyword
260
+ )
216
261
  } else if (typeof page.keywords === 'string') {
217
- page.keywords = lookupTranslation(page.keywords, context, translations, fallbackToSource)
262
+ page.keywords = translate(page.keywords)
218
263
  }
219
264
  }
220
265
  }
package/src/i18n/sync.js CHANGED
@@ -15,6 +15,7 @@ export function syncManifests(previous, current) {
15
15
  const report = {
16
16
  unchanged: [],
17
17
  moved: [],
18
+ remarked: [],
18
19
  changed: [],
19
20
  added: [],
20
21
  removed: []
@@ -42,6 +43,23 @@ export function syncManifests(previous, current) {
42
43
  previousContexts: prevUnit.contexts,
43
44
  currentContexts: currentUnit.contexts
44
45
  })
46
+ } else if (prevUnit.markup !== currentUnit.markup) {
47
+ // ⭐ Same words, different inline markup — an author added a link, bolded a
48
+ // phrase or re-targeted an href without touching the text.
49
+ //
50
+ // ⛔ THE HASH CANNOT SEE THIS, BY DESIGN. It is computed over the flattened
51
+ // plain text precisely so that marking up a sentence does not orphan its
52
+ // existing translation. The cost is that nothing told a translator to go
53
+ // back and add the link, and the existing translation stays "valid" and
54
+ // fully counted while silently losing the markup the source gained.
55
+ // Measured 2026-09-18; before this bucket the report was simply silent.
56
+ report.remarked.push({
57
+ hash,
58
+ source: currentUnit.source,
59
+ previousMarkup: prevUnit.markup || null,
60
+ currentMarkup: currentUnit.markup || null,
61
+ contexts: currentUnit.contexts
62
+ })
45
63
  } else {
46
64
  report.unchanged.push({ hash, source: currentUnit.source })
47
65
  }
@@ -155,6 +173,21 @@ export function formatSyncReport(report) {
155
173
  }
156
174
  }
157
175
 
176
+ if (report.remarked?.length > 0) {
177
+ lines.push(
178
+ ` ✎ ${report.remarked.length} strings kept their words but changed inline markup (links/bold)`
179
+ )
180
+ lines.push(
181
+ ` Existing translations still count as valid — update them or the markup is lost.`
182
+ )
183
+ for (const item of report.remarked.slice(0, 5)) {
184
+ lines.push(` - "${truncate(item.currentMarkup || item.source, 60)}"`)
185
+ }
186
+ if (report.remarked.length > 5) {
187
+ lines.push(` ... and ${report.remarked.length - 5} more`)
188
+ }
189
+ }
190
+
158
191
  if (report.changed.length > 0) {
159
192
  lines.push(` ⚠ ${report.changed.length} strings changed (need re-translation)`)
160
193
  for (const item of report.changed.slice(0, 5)) {
@@ -97,7 +97,10 @@ export function readSiteConfig(siteRoot) {
97
97
  * @param {Array} [options.plugins] - Additional Vite plugins
98
98
  * @param {Object} [options.build] - Vite build options
99
99
  * @param {Object} [options.resolve] - Vite resolve options
100
- * @param {Object} [options.seo] - SEO configuration for siteContentPlugin
100
+ * @param {Object} [options.seo] - SEO OVERRIDES for siteContentPlugin, merged
101
+ * OVER the site's own `site.yml::seo` block. Omit it and `site.yml` decides —
102
+ * a site's `vite.config.js` is scaffolding, so nothing a site GETS may depend
103
+ * on what it passes here.
101
104
  * @param {Object} [options.assets] - Asset processing configuration
102
105
  * @param {Object} [options.search] - Search index configuration
103
106
  * @param {boolean} [options.tailwind] - Include Tailwind CSS v4 Vite plugin (default: true)
@@ -493,6 +493,51 @@ function escapeHtml(str) {
493
493
  .replace(/"/g, '&quot;')
494
494
  }
495
495
 
496
+ /**
497
+ * The effective `seo` block for a collected site.
498
+ *
499
+ * ⛔ **`site.yml::seo` IS THE SOURCE. The plugin option is an OVERRIDE.**
500
+ * This plugin read `options.seo` alone until 2026-09-17, and nothing ever put
501
+ * `site.yml`'s block there: `defineSiteConfig` passes its own `seo` option
502
+ * straight through and defaults it to `{}`, while the scaffolded
503
+ * `vite.config.js` calls `defineSiteConfig()` with no arguments. So on the
504
+ * bundle lane `seo.baseUrl` and `seo.robots` were authored, documented
505
+ * (`cli/partials/agents.md`), read by `uniweb doctor` — and dead. The symptom,
506
+ * reported from a real site: `seo.baseUrl` set, doctor's
507
+ * `agents-index-relative-links` warning cleared, and `uniweb build` / `uniweb
508
+ * export` emitting neither `sitemap.xml` nor `robots.txt` while `llms.txt`
509
+ * links stayed root-relative.
510
+ *
511
+ * ⭐ **The data-only path was never wrong** — `build-site-data.js::writeProjections`
512
+ * reads `siteContent.config.seo.baseUrl`. Reading the same place here is what
513
+ * makes the two lanes unable to disagree, which is the whole reason
514
+ * `projectionOptions` exists.
515
+ *
516
+ * ⚠️ The generators were unit-tested throughout (`tests/robots-content-signals.test.js`
517
+ * calls `generateRobotsTxt` directly), which is why a green suite proved
518
+ * nothing: the tests supplied the input the wiring never delivered. That is why
519
+ * this is an exported function rather than a closure — the RESOLUTION is the
520
+ * part that was missing, so it is the part that gets asserted.
521
+ *
522
+ * A site's `vite.config.js` is CLI scaffolding — strip it and the site is
523
+ * unchanged — so a site's SEO must not depend on what it passes. An explicit
524
+ * override still wins, key by key, the way `base` does.
525
+ *
526
+ * @param {Object} [content] - Collected site content (reads `content.config.seo`)
527
+ * @param {Object} [overrides] - Caller overrides, merged OVER the site's block
528
+ * @returns {{ baseUrl: string, defaultImage: ?string, twitterHandle: ?string, locales: Array, robots: Object }}
529
+ */
530
+ export function resolveEffectiveSeo(content, overrides = {}) {
531
+ const merged = { ...(content?.config?.seo || {}), ...overrides }
532
+ return {
533
+ baseUrl: merged.baseUrl?.replace(/\/$/, '') || '', // Remove trailing slash
534
+ defaultImage: merged.defaultImage || null,
535
+ twitterHandle: merged.twitterHandle || null,
536
+ locales: merged.locales || [],
537
+ robots: merged.robots || {}
538
+ }
539
+ }
540
+
496
541
  /**
497
542
  * Create the site content plugin
498
543
  *
@@ -503,7 +548,10 @@ function escapeHtml(str) {
503
548
  * @param {boolean} [options.inject=true] - Inject content into HTML
504
549
  * @param {string} [options.filename='site-content.json'] - Output filename
505
550
  * @param {boolean} [options.watch=true] - Watch for changes in dev mode
506
- * @param {Object} [options.seo] - SEO configuration
551
+ * @param {Object} [options.seo] - SEO OVERRIDES, merged OVER `site.yml::seo`
552
+ * key by key. The site's own block is the source; this is for a caller that
553
+ * must override it (a preview build pointing at a staging origin, say).
554
+ * ⛔ Do not reach for this to make `site.yml::seo` work — it already does.
507
555
  * @param {string} [options.seo.baseUrl] - Base URL for sitemap and canonical URLs
508
556
  * @param {string} [options.seo.defaultImage] - Default OG image path
509
557
  * @param {string} [options.seo.twitterHandle] - Twitter handle for cards
@@ -548,15 +596,8 @@ export function siteContentPlugin(options = {}) {
548
596
  pdfThumbnails: assetsConfig.pdfThumbnails !== false // Default true
549
597
  }
550
598
 
551
- // Extract SEO options with defaults
552
- const seoEnabled = !!seo.baseUrl
553
- const seoOptions = {
554
- baseUrl: seo.baseUrl?.replace(/\/$/, '') || '', // Remove trailing slash
555
- defaultImage: seo.defaultImage || null,
556
- twitterHandle: seo.twitterHandle || null,
557
- locales: seo.locales || [],
558
- robots: seo.robots || {}
559
- }
599
+ /** This plugin's SEO resolution — `site.yml::seo`, with `options.seo` over it. */
600
+ const resolveSeo = content => resolveEffectiveSeo(content, seo)
560
601
 
561
602
  // Warn once per build, not once per collection — the dev server re-collects
562
603
  // on every content change and would otherwise repeat this on every keystroke.
@@ -756,7 +797,7 @@ export function siteContentPlugin(options = {}) {
756
797
  function projectionOptions(content) {
757
798
  const defaultLocale = resolveDefaultLocale(content.config)
758
799
  return {
759
- baseUrl: seoOptions.baseUrl,
800
+ baseUrl: resolveSeo(content).baseUrl,
760
801
  basePath,
761
802
  locale: content.config?.activeLocale || defaultLocale,
762
803
  defaultLocale
@@ -1185,17 +1226,22 @@ export function siteContentPlugin(options = {}) {
1185
1226
  // If no translations, fall through to serve default content
1186
1227
  }
1187
1228
 
1229
+ // Dev serves the same two SEO artifacts the build emits, off the same
1230
+ // resolution — so `uniweb dev` and `uniweb build` cannot disagree about
1231
+ // whether a site has them.
1232
+ const devSeo = resolveSeo(siteContent)
1233
+
1188
1234
  // Serve sitemap.xml in dev mode
1189
- if (req.url === '/sitemap.xml' && seoEnabled && siteContent?.pages) {
1235
+ if (req.url === '/sitemap.xml' && devSeo.baseUrl && siteContent?.pages) {
1190
1236
  res.setHeader('Content-Type', 'application/xml')
1191
- res.end(generateSitemap(siteContent.pages, seoOptions.baseUrl, filterSeoLocales(seoOptions.locales, siteContent.config), siteContent.config?.i18n?.routeTranslations))
1237
+ res.end(generateSitemap(siteContent.pages, devSeo.baseUrl, filterSeoLocales(devSeo.locales, siteContent.config), siteContent.config?.i18n?.routeTranslations))
1192
1238
  return
1193
1239
  }
1194
1240
 
1195
1241
  // Serve robots.txt in dev mode
1196
- if (req.url === '/robots.txt' && seoEnabled) {
1242
+ if (req.url === '/robots.txt' && devSeo.baseUrl) {
1197
1243
  res.setHeader('Content-Type', 'text/plain')
1198
- res.end(generateRobotsTxt(seoOptions.baseUrl, seoOptions.robots))
1244
+ res.end(generateRobotsTxt(devSeo.baseUrl, devSeo.robots))
1199
1245
  return
1200
1246
  }
1201
1247
 
@@ -1398,8 +1444,9 @@ export function siteContentPlugin(options = {}) {
1398
1444
  }
1399
1445
 
1400
1446
  // Inject SEO meta tags
1401
- if (seoEnabled) {
1402
- const metaTags = generateMetaTags(contentToInject, seoOptions)
1447
+ const injectSeo = resolveSeo(contentToInject)
1448
+ if (injectSeo.baseUrl) {
1449
+ const metaTags = generateMetaTags(contentToInject, injectSeo)
1403
1450
  if (metaTags) {
1404
1451
  headInjection += ` ${metaTags}\n`
1405
1452
  }
@@ -1555,9 +1602,10 @@ export function siteContentPlugin(options = {}) {
1555
1602
  })
1556
1603
 
1557
1604
  // Generate SEO files if enabled
1558
- if (seoEnabled && finalContent?.pages) {
1605
+ const buildSeo = resolveSeo(finalContent)
1606
+ if (buildSeo.baseUrl && finalContent?.pages) {
1559
1607
  // Generate sitemap.xml
1560
- const sitemap = generateSitemap(finalContent.pages, seoOptions.baseUrl, filterSeoLocales(seoOptions.locales, finalContent.config), finalContent.config?.i18n?.routeTranslations)
1608
+ const sitemap = generateSitemap(finalContent.pages, buildSeo.baseUrl, filterSeoLocales(buildSeo.locales, finalContent.config), finalContent.config?.i18n?.routeTranslations)
1561
1609
  this.emitFile({
1562
1610
  type: 'asset',
1563
1611
  fileName: 'sitemap.xml',
@@ -1566,7 +1614,7 @@ export function siteContentPlugin(options = {}) {
1566
1614
  console.log('[site-content] Generated sitemap.xml')
1567
1615
 
1568
1616
  // Generate robots.txt
1569
- const robotsTxt = generateRobotsTxt(seoOptions.baseUrl, seoOptions.robots)
1617
+ const robotsTxt = generateRobotsTxt(buildSeo.baseUrl, buildSeo.robots)
1570
1618
  this.emitFile({
1571
1619
  type: 'asset',
1572
1620
  fileName: 'robots.txt',
@@ -36,9 +36,6 @@
36
36
  ],
37
37
  "template": [
38
38
  "site.yml::template"
39
- ],
40
- "url": [
41
- "site.yml::$url"
42
39
  ]
43
40
  }
44
41
  },
@@ -150,15 +150,15 @@ const INFO_TO_SITE_YML = {
150
150
  // the path an author wrote for an image in the project, which `restoreAssetRefs`
151
151
  // has already put back by recognizing the serve URL the push recorded in
152
152
  // `assets.json` (asset-map.js). [Diego, 2026-09-10]
153
- preview: 'preview',
154
- // ⭐ `url` — where the site is live → `site.yml::$url`. The backend records it at
155
- // every publish, and pull is its only way onto disk; recorded rather than authored,
156
- // so it lands under the `$` like `$uuid`.
157
153
  //
158
- // ⚠️ Both were listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
154
+ // ⚠️ It was listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
159
155
  // 2026-09-10. Leaving an app-written field out of this map is what lets the next
160
156
  // push destroy it, since `info` is replaced whole.
161
- url: '$url',
157
+ preview: 'preview',
158
+ // ⛔ `url` is RETIRED (2026-09-17) and must not come back: where a site went live
159
+ // is a deploy fact, which `publish` records in `deploy.yml`, not site config. A
160
+ // document that still carries `info.url` writes nothing — an unlisted key is
161
+ // ignored — and a `$url` already on disk is left alone and never read.
162
162
  }
163
163
 
164
164
  // ── `settings` Section → site.yml ─────────────────────────────────────────────
package/src/uwx/site.js CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  checkDeclaration,
57
57
  fetchFromQueryShorthand,
58
58
  assertRouteFolder,
59
+ mountEntriesOf,
59
60
  } from '../site/content-collector.js'
60
61
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
61
62
  import { readLayoutFolder } from '../site/layout-folder.js'
@@ -557,6 +558,30 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
557
558
  return sections.map((s, i) => sectionToRecord(s, i))
558
559
  }
559
560
 
561
+ // A folder-mode folder's `.md` files are pages to the build and nothing to this
562
+ // lane. Name every one of them, so the shortfall is visible at push time rather
563
+ // than by reading the backend's store or opening the published site.
564
+ async function warnFolderModePagesSkipped(dirPath, siteRoot) {
565
+ let files
566
+ try {
567
+ files = (await readdir(dirPath)).filter(isMarkdownFile)
568
+ } catch {
569
+ return
570
+ }
571
+ if (files.length === 0) return
572
+ const where = relative(siteRoot, dirPath)
573
+ console.warn(
574
+ `uwx/site: ${files.length} page(s) in \`${where}\` will NOT be pushed — ` +
575
+ `it is a folder-mode folder (\`folder.yml\`), and pushing \`.md\`-as-pages ` +
576
+ `is not implemented on the sync lane. A build renders them; the backend ` +
577
+ `will not have them.\n` +
578
+ files.map((f) => ` - ${where}/${f}`).join('\n') +
579
+ `\n To publish them, make each one a page-mode directory ` +
580
+ `(\`<name>/page.yml\` + its section files). To ship them as they are, ` +
581
+ `use \`uniweb export\` or \`uniweb deploy --host <adapter>\`.`
582
+ )
583
+ }
584
+
560
585
  // Recursively build the `pages` tree: each record carries its fields, its inline
561
586
  // `page_sections` (page mode only), and its child pages under `$children`.
562
587
  async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
@@ -594,6 +619,20 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
594
619
  if (mode === 'page') {
595
620
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
596
621
  if (sections.length > 0) record.page_sections = sections
622
+ } else {
623
+ // ⛔ FOLDER MODE: the build makes each `.md` file here a PAGE; this lane
624
+ // makes it nothing. It is a v0 mapper gap (bidirectional-sync.md §"Other
625
+ // v0 mapper gaps"), and until it closes the ONLY thing standing between an
626
+ // author and a site that publishes a fraction of itself is this warning.
627
+ //
628
+ // ⭐ It has to be here, not in `status` or `push`. Those compare the built
629
+ // document against what was last sent, so with the pages already absent
630
+ // from the document both correctly report "Synced" / "Nothing to push".
631
+ // The loss happens upstream of every diff, which is why it was invisible:
632
+ // measured 2026-09-18, a 49-page documentation site published 2 pages with
633
+ // every CLI instrument reporting success [measured:backend, channel with
634
+ // backend, and reproduced here against the same site].
635
+ await warnFolderModePagesSkipped(f.path, siteRoot)
597
636
  }
598
637
 
599
638
  const children = await walkPagesNested(ctx, f.path, slugPath, f.internalMode, f.config, false)
@@ -1332,20 +1371,38 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1332
1371
  // fingerprint `assets.json` recorded for it).
1333
1372
  // The app leaves an author's value alone.
1334
1373
  //
1335
- // ⚠️ This read "`url` and `preview_image` are BACKEND-STAMPED and framework emits
1336
- // NEITHER" until 2026-09-10. Neither half held: nothing stamped `url`, and leaving
1337
- // an app-written field off an allowlist destroys it on every push, because `info`
1338
- // is replaced whole.
1374
+ // ⚠️ Until 2026-09-10 framework emitted no preview, on the premise that the host
1375
+ // stamps it. Nothing did, and leaving an app-written field off an allowlist
1376
+ // destroys it on every push, because `info` is replaced whole.
1339
1377
  setIf(info, 'preview', scalarString(siteYml.preview))
1340
- // ⭐ `url` — where the site is live, so a site card can link to it without opening
1341
- // the editor. The backend records it at every publish (stated by backend,
1342
- // 2026-09-10) and pull brings it into `site.yml::$url`; nothing in framework writes
1343
- // it. The `$` marks it as recorded rather than authored, like `$uuid` — and keeps it
1344
- // out of the rendered payload, where a bare `url:` would sit beside `seo.baseUrl`,
1345
- // the authored canonical address.
1346
- setIf(info, 'url', siteYml.$url)
1378
+ // ⛔ `url` IS RETIRED (2026-09-17) — do not reintroduce it, in either direction.
1379
+ // It carried where the site went live, read from `site.yml::$url` (which pull
1380
+ // wrote), so a site card could link to it. That is a fact about a DEPLOY, not
1381
+ // about the site: `publish` prints it and records it in `deploy.yml`, and a
1382
+ // derived value must not round-trip (the round-trip law, uwx-format.md). The host
1383
+ // no longer declares the field and refuses a push that carries it. A `$url` line
1384
+ // left in an old site.yml is inert — nothing reads it, so nothing sends it.
1385
+ // (uwx-format.md → info.url.)
1347
1386
 
1348
1387
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1388
+ // ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
1389
+ // The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
1390
+ // `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
1391
+ // lane reads `paths.pages` alone. A sub-mounted folder therefore syncs as an
1392
+ // EMPTY container: its directory on disk holds only the `folder.yml`, so it
1393
+ // emits with no children and nothing anywhere says so. Measured 2026-09-18 on
1394
+ // a site whose entire corpus was one sub-mount: 49 pages built, 2 pushed.
1395
+ // Deferred by design for now (bidirectional-sync.md); warned about here so it
1396
+ // is not also silent.
1397
+ for (const [segment] of mountEntriesOf(siteYml.paths)) {
1398
+ console.warn(
1399
+ `uwx/site: \`paths: { pages/${segment}: … }\` is NOT read on the sync lane — ` +
1400
+ `\`${segment}\` will be pushed as an empty container and none of the mounted ` +
1401
+ `pages will reach the backend. A build mounts it normally. Inline the ` +
1402
+ `directory under \`pages/${segment}/\` to push it, or ship the site with ` +
1403
+ `\`uniweb export\` / \`uniweb deploy --host <adapter>\`.`
1404
+ )
1405
+ }
1349
1406
  const pagesPath = siteYml.paths?.pages
1350
1407
  ? join(siteRoot, siteYml.paths.pages)
1351
1408
  : join(siteRoot, 'pages')
@@ -60,7 +60,7 @@ export function upsertYamlScalar(filePath, key, value) {
60
60
  * rather than half-removed.
61
61
  *
62
62
  * @param {string} filePath
63
- * @param {string} key - a top-level scalar key (e.g. `$url`)
63
+ * @param {string} key - a top-level scalar key (e.g. `preview`)
64
64
  * @returns {boolean} true if the file changed
65
65
  */
66
66
  export function removeYamlScalar(filePath, key) {