@uniweb/build 0.52.7 → 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.7",
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/semantic-parser": "^1.4.1",
62
61
  "@uniweb/schemas": "^0.3.3",
63
62
  "@uniweb/theming": "^0.1.15",
64
63
  "@uniweb/content-writer": "^0.3.4",
65
- "@uniweb/projections": "^0.7.3"
64
+ "@uniweb/projections": "^0.7.3",
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,7 +74,7 @@
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/runtime": "^0.26.4",
77
+ "@uniweb/runtime": "^0.26.5",
78
78
  "@uniweb/core": "^0.29.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
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)) {
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)
@@ -1346,6 +1385,24 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1346
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')