@uniweb/build 0.52.7 → 0.54.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.54.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,12 +60,12 @@
60
60
  "@uniweb/content-reader": "^1.2.5",
61
61
  "@uniweb/semantic-parser": "^1.4.1",
62
62
  "@uniweb/schemas": "^0.3.3",
63
- "@uniweb/theming": "^0.1.15",
64
63
  "@uniweb/content-writer": "^0.3.4",
64
+ "@uniweb/theming": "^0.1.15",
65
65
  "@uniweb/projections": "^0.7.3"
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)) {
@@ -1714,14 +1714,18 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1714
1714
  }
1715
1715
 
1716
1716
  /**
1717
- * Determine the index page name from ordering config
1717
+ * The homepage ONE config declares: the first named `pages:` entry, else `index:`.
1718
+ * `undefined` when it declares none; `null` when its first `pages:` entry names
1719
+ * nothing usable, which has always meant "no homepage" rather than "fall through".
1718
1720
  *
1719
- * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent
1720
- * @param {Array} availableFolders - Array of { name, order } for folders at this level
1721
- * @returns {string|null} The folder name that should be the index, or null
1721
+ * The within-file rule, in one place — a level's config and each of the root's two
1722
+ * configs (`rootOrderConfig`) are read by it alike.
1723
+ *
1724
+ * @param {Object} [config] - `{ pages, index }` as authored
1725
+ * @returns {string|null|undefined}
1722
1726
  */
1723
- function determineIndexPage(orderConfig, availableFolders) {
1724
- const { pages: pagesArray, index: indexName } = orderConfig || {}
1727
+ function declaredHomepage(config) {
1728
+ const { pages: pagesArray, index: indexName } = config || {}
1725
1729
 
1726
1730
  // 1. Explicit pages array - first non-'...' item is index
1727
1731
  if (Array.isArray(pagesArray) && pagesArray.length > 0) {
@@ -1733,9 +1737,69 @@ function determineIndexPage(orderConfig, availableFolders) {
1733
1737
  }
1734
1738
 
1735
1739
  // 2. Explicit index property
1736
- if (indexName) {
1737
- return indexName
1740
+ return indexName || undefined
1741
+ }
1742
+
1743
+ /**
1744
+ * The homepage a level's order config names. The root's was already decided across
1745
+ * its two configs, and arrives as `homepage` (`rootOrderConfig`); any other level
1746
+ * that promotes one — a folder promoted to `/`, the latest version of a scope at the
1747
+ * root — names it in its own config.
1748
+ */
1749
+ function homepageOf(orderConfig) {
1750
+ return orderConfig && 'homepage' in orderConfig ? orderConfig.homepage : declaredHomepage(orderConfig)
1751
+ }
1752
+
1753
+ /**
1754
+ * The site's TOP LEVEL — its order and its homepage — from the two configs that can
1755
+ * declare it: the site config (`site.yml`, or a document profile's own file) and the
1756
+ * pages directory's own config (`pages/folder.yml`, or `pages/page.yml`).
1757
+ *
1758
+ * ⭐ The pages directory's config is read as a MOUNTED directory's is: the content's
1759
+ * own declaration, under the site's. Every other directory's config orders its
1760
+ * children, and a mounted one's does too — but the root's was read for its mode
1761
+ * alone until 2026-09-19, so a content repository that orders itself kept its order
1762
+ * mounted below the root and lost it as the whole pages directory: alphabetical,
1763
+ * and in folder mode with no homepage at all.
1764
+ *
1765
+ * ⛔ THE HOMEPAGE IS DECIDED PER CONFIG, NOT PER KEY. Within one file the first
1766
+ * `pages:` entry beats `index:`, as it always has; across the two, whichever form the
1767
+ * site config uses beats the pages directory's. Merged key by key, a `pages:` list
1768
+ * from the pages directory would override a `site.yml` `index:`. The order is the
1769
+ * site config's `pages:`, else the pages directory's.
1770
+ *
1771
+ * ⚖️ `pages:` and `index:` ONLY. The root has no container page, so the keys that
1772
+ * describe one (`title`, `seo`, …) mean nothing here — a repository carries them for
1773
+ * when it is mounted below the root. And `layout:` stays the site config's alone: a
1774
+ * site's layout travels on the sync lane as that file's own value, verbatim, so a
1775
+ * second source would render on a static build and nowhere else.
1776
+ *
1777
+ * @param {{ pages?: Array, index?: string }} [site] - the site config's own order keys
1778
+ * @param {Object} [root] - the pages directory's config, as `readFolderConfig` read it
1779
+ * @returns {{ pages: Array|undefined, index: string|undefined, homepage: string|null|undefined }}
1780
+ */
1781
+ export function rootOrderConfig(site = {}, root = {}) {
1782
+ const listed = (value) => Array.isArray(value) && value.length > 0
1783
+ const fromSite = declaredHomepage(site)
1784
+ const homepage = fromSite !== undefined ? fromSite : declaredHomepage(root)
1785
+ return {
1786
+ pages: listed(site?.pages) ? site.pages : listed(root?.pages) ? root.pages : undefined,
1787
+ index: homepage || undefined,
1788
+ homepage,
1738
1789
  }
1790
+ }
1791
+
1792
+ /**
1793
+ * Determine the index page name from ordering config
1794
+ *
1795
+ * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent
1796
+ * @param {Array} availableFolders - Array of { name, order } for folders at this level
1797
+ * @returns {string|null} The folder name that should be the index, or null
1798
+ */
1799
+ function determineIndexPage(orderConfig, availableFolders) {
1800
+ // 1–2. The first named `pages:` entry, else `index:` (`declaredHomepage`).
1801
+ const declared = homepageOf(orderConfig)
1802
+ if (declared !== undefined) return declared
1739
1803
 
1740
1804
  // 3. Fallback: lowest order value, or first alphabetically
1741
1805
  // IMPORTANT: Dynamic route folders (e.g., [slug]) can never be index pages
@@ -1953,11 +2017,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1953
2017
  // pages: controls order only and every folder keeps its natural route.
1954
2018
  let indexName = null
1955
2019
  if (parentRoute === '/') {
1956
- if (pagesParsedFM && pagesParsedFM.before.length > 0) {
1957
- indexName = extractItemName(pagesParsedFM.before[0])
1958
- } else {
1959
- indexName = orderConfig?.index || null
1960
- }
2020
+ indexName = homepageOf(orderConfig) ?? null
1961
2021
  }
1962
2022
 
1963
2023
  // Add md-file-pages
@@ -2505,21 +2565,22 @@ export async function collectSiteContent(sitePath, options = {}) {
2505
2565
  }
2506
2566
  }
2507
2567
 
2568
+ // Root content mode default comes from the profile (folder mode for
2569
+ // documents, page mode for sites). A folder.yml/page.yml in the pages root
2570
+ // overrides the profile default per readFolderConfig.
2571
+ const { mode: rootContentMode, config: rootFolderConfig } = await readFolderConfig(pagesPath, profile.defaultMode)
2572
+
2508
2573
  // Extract page ordering config from the top-level config.
2509
2574
  // Document profile reads the `content:` field (with `pages:` as a back-compat
2510
2575
  // alias); site profile reads `pages:`. Either way the internal field name is
2511
2576
  // `pages` so collectPagesRecursive doesn't need to care about profile.
2512
- const siteOrderConfig = {
2577
+ // ⭐ Layered over the pages directory's own `pages:` / `index:` — see `rootOrderConfig`.
2578
+ const siteOrderConfig = rootOrderConfig({
2513
2579
  pages: profile.orderField === 'pages'
2514
2580
  ? siteConfig.pages
2515
2581
  : (siteConfig[profile.orderField] ?? siteConfig.pages),
2516
2582
  index: siteConfig.index
2517
- }
2518
-
2519
- // Root content mode default comes from the profile (folder mode for
2520
- // documents, page mode for sites). A folder.yml/page.yml in the pages root
2521
- // overrides the profile default per readFolderConfig.
2522
- const { mode: rootContentMode } = await readFolderConfig(pagesPath, profile.defaultMode)
2583
+ }, rootFolderConfig)
2523
2584
 
2524
2585
  // Collect layout areas from the layout/ directory, read from the site alone
2525
2586
  const { layouts, assetCollection: layoutAssets, iconCollection: layoutIcons } =
@@ -64,8 +64,9 @@ const ownContent = (record, ...childKeys) => {
64
64
  * the projector would write it to.
65
65
  *
66
66
  * Paths use the conventional `pages/` and `layout/` roots. A site that relocates
67
- * them via `info.paths` still gets stable, unique keys — only the displayed prefix
68
- * would differ from disk, which is a labelling nicety, not a correctness issue.
67
+ * them via `site.yml::paths` (`settings.paths` on the wire) still gets stable, unique
68
+ * keys — only the displayed prefix would differ from disk, which is a labelling
69
+ * nicety, not a correctness issue.
69
70
  *
70
71
  * @returns {Map<string, object>} path → the unit's own record
71
72
  */
@@ -0,0 +1,32 @@
1
+ // Where a site's pages and layout live — ONE definition for the push, which reads
2
+ // the tree from here (`site.js`), and the pull, which writes it back here
3
+ // (`site-project.js`). A leaf module so the pull does not import the push.
4
+
5
+ import { join, resolve } from 'node:path'
6
+
7
+ /**
8
+ * `site.yml::paths.pages` / `paths.layout`, else `pages/` and `layout/` — resolved
9
+ * as the build resolves them (`site/content-collector.js`).
10
+ *
11
+ * ⛔ The pull read this from the pulled document's `info.paths` until 2026-09-19.
12
+ * `paths` moved to the `settings` Section on 2026-09-09 and that reader did not
13
+ * move with it, so it read as absent: every pull wrote pages to `pages/` and layout
14
+ * to `layout/` whatever the site declared, while the build and the next push kept
15
+ * reading the declared directory — untouched by the pull, and recorded by it as
16
+ * current.
17
+ *
18
+ * ⭐ Reading `site.yml` rather than the document is the fix, not a detour: it is the
19
+ * file both readers use, and the pull writes the document's `settings.paths` into it
20
+ * before it places a single page.
21
+ *
22
+ * @param {string} siteRoot
23
+ * @param {object|null} siteYml - the parsed `site.yml`
24
+ * @returns {{ pagesDir: string, layoutDir: string }}
25
+ */
26
+ export function siteContentDirs(siteRoot, siteYml) {
27
+ const paths = siteYml?.paths
28
+ return {
29
+ pagesDir: paths?.pages ? resolve(siteRoot, paths.pages) : join(siteRoot, 'pages'),
30
+ layoutDir: paths?.layout ? resolve(siteRoot, paths.layout) : join(siteRoot, 'layout'),
31
+ }
32
+ }
@@ -46,6 +46,7 @@ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTrans
46
46
  import { buildFreeformPath } from '../i18n/freeform.js'
47
47
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
48
48
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
49
+ import { siteContentDirs } from './site-dirs.js'
49
50
 
50
51
  // The pull-side identity index: a per-clone, GITIGNORED `uuid → relative path`
51
52
  // map under `.uniweb/`, the home for the backend's per-item identity so that
@@ -607,24 +608,35 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null) {
607
608
  return y
608
609
  }
609
610
 
610
- // Delete `<name>.md` files in `pageDir` whose stableId isn't in `keep`.
611
+ // ⛔ A PRUNE MAY ONLY DELETE WHAT A PUSH COULD HAVE SENT. An orphan is a file the
612
+ // incoming document no longer has — but a push reads the tree with the build's own
613
+ // rules (`isMarkdownFile`, `isIgnoredFolder`), so a `_draft.md`, a `README.md`, a
614
+ // `_partials/` folder or a `.git/` directory never reaches the backend and can never
615
+ // come back in a pulled document. Judged by "not in the incoming set" alone, every
616
+ // one of them was an orphan and a default pull DELETED it — the `.git/` of a pages
617
+ // directory that is its own clone included. The same two rules decide both sides
618
+ // now, which is also what the layout prune below already did.
619
+
620
+ // Delete the section files in `pageDir` whose stableId isn't in `keep`.
611
621
  function pruneOrphanSectionFiles(pageDir, keep, report) {
612
622
  if (!existsSync(pageDir)) return
613
623
  for (const entry of readdirSync(pageDir)) {
614
- if (extname(entry).toLowerCase() !== '.md') continue
624
+ if (!isMarkdownFile(entry)) continue
615
625
  if (keep.has(basename(entry, extname(entry)))) continue
616
626
  const p = join(pageDir, entry)
627
+ if (!statSync(p).isFile()) continue
617
628
  unlinkSync(p)
618
629
  report.deleted.push(p)
619
630
  }
620
631
  }
621
632
 
622
- // Delete subdirectories of `pagesDir` whose name isn't an incoming page dir.
633
+ // Delete the page directories in `pagesDir` whose name isn't an incoming page dir.
623
634
  function pruneOrphanPageDirs(pagesDir, keepDirs, report) {
624
635
  if (!existsSync(pagesDir)) return
625
636
  for (const entry of readdirSync(pagesDir)) {
637
+ if (isIgnoredFolder(entry) || keepDirs.has(entry)) continue
626
638
  const p = join(pagesDir, entry)
627
- if (!statSync(p).isDirectory() || keepDirs.has(entry)) continue
639
+ if (!statSync(p).isDirectory()) continue
628
640
  rmSync(p, { recursive: true, force: true })
629
641
  report.deleted.push(p)
630
642
  }
@@ -870,11 +882,11 @@ export function siteContentDocumentToProject({ document, siteRoot, sourceLocale
870
882
  // localized scalars during the page walk.
871
883
  const ctx = { siteRoot, oldIndex: readPullIndex(siteRoot), newIndex: {}, report, collector, sourceLocale }
872
884
 
873
- const paths = document?.info?.paths || {}
874
- const pagesDir = paths.pages ? join(siteRoot, paths.pages) : join(siteRoot, 'pages')
885
+ // Where the tree goes is what `site.yml` says NOW — `siteInfoToConfig` above has
886
+ // written the document's `settings.paths` into it — because that is the file the
887
+ // next push and the build read. See `siteContentDirs`.
888
+ const { pagesDir, layoutDir: layoutBaseDir } = siteContentDirs(siteRoot, readAuthoredYaml(join(siteRoot, 'site.yml')))
875
889
  projectPages(document?.pages, pagesDir, sourceLocale, report, prune, ctx)
876
-
877
- const layoutBaseDir = paths.layout ? join(siteRoot, paths.layout) : join(siteRoot, 'layout')
878
890
  projectLayout(document?.layout_sections, layoutBaseDir, report, prune, ctx)
879
891
 
880
892
  report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
package/src/uwx/site.js CHANGED
@@ -56,6 +56,8 @@ import {
56
56
  checkDeclaration,
57
57
  fetchFromQueryShorthand,
58
58
  assertRouteFolder,
59
+ mountEntriesOf,
60
+ rootOrderConfig,
59
61
  } from '../site/content-collector.js'
60
62
  import { refuseBinding, refuseUnder, refuseOutsideLanguage, warnDuplicateBindings } from '../site/data-fetcher.js'
61
63
  import { readLayoutFolder } from '../site/layout-folder.js'
@@ -68,6 +70,7 @@ import { loadFreeformTranslation } from '../i18n/freeform.js'
68
70
  import { upsertYamlScalar } from './yaml-upsert.js'
69
71
  import { resolveQueriesConfig } from './queries-config.js'
70
72
  import { resolveSelfScope } from './self-scope.js'
73
+ import { siteContentDirs } from './site-dirs.js'
71
74
 
72
75
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
73
76
 
@@ -544,8 +547,7 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
544
547
  }
545
548
 
546
549
  // Top-level sections: every NON-`@` markdown file, in order. `@`-prefixed files
547
- // are children, pulled in via their parent's `nest:` above (an orphaned `@`
548
- // file with no parent is simply omitted — it stays out of the document).
550
+ // are children, pulled in via their parent's `nest:` above.
549
551
  const seen = new Set()
550
552
  const sections = []
551
553
  for (const file of mdFiles) {
@@ -554,9 +556,68 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
554
556
  sections.push(await buildSection(file, seen))
555
557
  }
556
558
 
559
+ // An `@` file no `nest:` claimed is an ORPHAN, and the build keeps it — appended
560
+ // as a top-level section, with a warning, "to avoid silent data loss" (`processPage`,
561
+ // `site/content-collector.js`). Mirrored exactly: at the end, in file order, with no
562
+ // children of its own. ⛔ This lane omitted it until 2026-09-19, and a pull of the
563
+ // result then DELETED the file — on disk and absent from the document, it read as
564
+ // something removed upstream.
565
+ for (const file of mdFiles) {
566
+ if (!isChildSection(file) || seen.has(file)) continue
567
+ seen.add(file)
568
+ const stableDefault = parseNumericPrefix(stripAtPrefix(parse(file).name)).name
569
+ const { section } = await processMarkdownFile(join(pageDir, file), String(seen.size), siteRoot, stableDefault)
570
+ section.subsections = []
571
+ sections.push(section)
572
+ }
573
+
557
574
  return sections.map((s, i) => sectionToRecord(s, i))
558
575
  }
559
576
 
577
+ // A folder-mode folder's `.md` files are pages to the build and nothing to this
578
+ // lane. Name every one of them, so the shortfall is visible at push time rather
579
+ // than by reading the backend's store or opening the published site.
580
+ //
581
+ // `source` is `readFolderConfig`'s: a folder with no config of its own is in folder
582
+ // mode because a `folder.yml` above it put it there, and the message says so —
583
+ // "it is a folder-mode folder (`folder.yml`)" over a folder that has none would send
584
+ // the author looking for a file that does not exist.
585
+ async function warnFolderModePagesSkipped(dirPath, siteRoot, source = 'folder.yml') {
586
+ let files
587
+ try {
588
+ files = (await readdir(dirPath)).filter(isMarkdownFile)
589
+ } catch {
590
+ return
591
+ }
592
+ if (files.length === 0) return
593
+ const where = relative(siteRoot, dirPath)
594
+ const why = source === 'inherited'
595
+ ? 'it is a folder-mode folder (it has no `folder.yml` or `page.yml` of its own, so it inherits folder mode from the `folder.yml` above it)'
596
+ : 'it is a folder-mode folder (`folder.yml`)'
597
+ console.warn(
598
+ `uwx/site: ${files.length} page(s) in \`${where}\` will NOT be pushed — ` +
599
+ `${why}, and pushing \`.md\`-as-pages ` +
600
+ `is not implemented on the sync lane. A build renders them; the backend ` +
601
+ `will not have them.\n` +
602
+ files.map((f) => ` - ${where}/${f}`).join('\n') +
603
+ `\n To publish them, make each one a page-mode directory ` +
604
+ `(\`<name>/page.yml\` + its section files). To ship them as they are, ` +
605
+ `use \`uniweb export\` or \`uniweb deploy --host <adapter>\`.` +
606
+ REPUBLISH_CLAUSE
607
+ )
608
+ }
609
+
610
+ // A push sends the page tree as ONE entity (see site-diff.js), so a republish does
611
+ // not merge: the shorter document becomes the whole tree. Measured by the backend
612
+ // lane 2026-09-18 against a current `uniwebd` — 49 stored pages replaced by 2, the
613
+ // publish reporting success. Both skip warnings carry this, because "will not be
614
+ // pushed" reads as "you publish short" when the real cost on an already-published
615
+ // site is that live pages are REMOVED.
616
+ const REPUBLISH_CLAUSE =
617
+ `\n ⚠ If this site has been published before, a push REPLACES its whole page ` +
618
+ `tree — pages already live that are missing here are REMOVED, and the publish ` +
619
+ `still reports success.`
620
+
560
621
  // Recursively build the `pages` tree: each record carries its fields, its inline
561
622
  // `page_sections` (page mode only), and its child pages under `$children`.
562
623
  async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot) {
@@ -572,7 +633,16 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
572
633
  assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
573
634
  const dyn = f.dirName.match(DYNAMIC_RE)
574
635
  const slug = dyn ? dyn[1] : f.name
575
- const mode = f.source === 'folder.yml' ? 'folder' : 'page'
636
+ // ⭐ THE BUILD'S ANSWER, NOT A SECOND ONE. `readFolderConfig` has already resolved
637
+ // this folder's mode — a `folder.yml` here, a `page.yml` here, or neither and
638
+ // INHERITED from its parent — and that resolved mode is exactly what the build
639
+ // reads (`site/content-collector.js`). ⛔ This line tested the config file's NAME
640
+ // until 2026-09-19, so a folder with no config under a folder-mode parent — the
641
+ // documented cascade, one `folder.yml` at the top of a docs tree — was pushed as
642
+ // ONE page whose sections were its `.md` files while the build rendered each as a
643
+ // page of its own. Nothing warned, and a pull then wrote a `page.yml` into the
644
+ // folder, converting the author's own site to the wrong shape.
645
+ const mode = f.internalMode === 'pages' ? 'folder' : 'page'
576
646
  const slugPath = parentSlugPath ? `${parentSlugPath}/${slug}` : slug
577
647
 
578
648
  const data = buildPageData(f.config, {
@@ -594,6 +664,20 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
594
664
  if (mode === 'page') {
595
665
  const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
596
666
  if (sections.length > 0) record.page_sections = sections
667
+ } else {
668
+ // ⛔ FOLDER MODE: the build makes each `.md` file here a PAGE; this lane
669
+ // makes it nothing. It is a v0 mapper gap (bidirectional-sync.md §"Other
670
+ // v0 mapper gaps"), and until it closes the ONLY thing standing between an
671
+ // author and a site that publishes a fraction of itself is this warning.
672
+ //
673
+ // ⭐ It has to be here, not in `status` or `push`. Those compare the built
674
+ // document against what was last sent, so with the pages already absent
675
+ // from the document both correctly report "Synced" / "Nothing to push".
676
+ // The loss happens upstream of every diff, which is why it was invisible:
677
+ // measured 2026-09-18, a 49-page documentation site published 2 pages with
678
+ // every CLI instrument reporting success [measured:backend, channel with
679
+ // backend, and reproduced here against the same site].
680
+ await warnFolderModePagesSkipped(f.path, siteRoot, f.source)
597
681
  }
598
682
 
599
683
  const children = await walkPagesNested(ctx, f.path, slugPath, f.internalMode, f.config, false)
@@ -1345,17 +1429,54 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1345
1429
  // left in an old site.yml is inert — nothing reads it, so nothing sends it.
1346
1430
  // (uwx-format.md → info.url.)
1347
1431
 
1348
- const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1349
- const pagesPath = siteYml.paths?.pages
1350
- ? join(siteRoot, siteYml.paths.pages)
1351
- : join(siteRoot, 'pages')
1352
- const pages = existsSync(pagesPath)
1353
- ? await walkPagesNested(ctx, pagesPath, '', 'sections', siteYml, true)
1354
- : []
1355
-
1356
- const layoutDir = siteYml.paths?.layout
1357
- ? join(siteRoot, siteYml.paths.layout)
1358
- : join(siteRoot, 'layout')
1432
+ // `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
1433
+ const ctx = { siteRoot, sourceLocale, translations }
1434
+ // ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
1435
+ // The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
1436
+ // `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
1437
+ // lane reads `paths.pages` alone. A sub-mounted folder therefore syncs as an
1438
+ // EMPTY container: its directory on disk holds only the `folder.yml`, so it
1439
+ // emits with no children and nothing anywhere says so. Measured 2026-09-18 on
1440
+ // a site whose entire corpus was one sub-mount: 49 pages built, 2 pushed.
1441
+ // Deferred by design for now (bidirectional-sync.md); warned about here so it
1442
+ // is not also silent.
1443
+ //
1444
+ // The same two directories the pull writes back to — see `siteContentDirs`.
1445
+ const { pagesDir: pagesPath, layoutDir } = siteContentDirs(siteRoot, siteYml)
1446
+ for (const [segment] of mountEntriesOf(siteYml.paths)) {
1447
+ // Only a LOCAL stub (`pages/<segment>/`, the folder that gives a mounted route its
1448
+ // layout) is walked, so the route arrives as an empty container; with no stub,
1449
+ // nothing here stands for the route and it is not sent at all. ⚠️ The message
1450
+ // said "empty container" in both cases until 2026-09-19.
1451
+ const fate = existsSync(join(pagesPath, segment))
1452
+ ? 'will be pushed as an empty container'
1453
+ : 'will not be pushed at all'
1454
+ console.warn(
1455
+ `uwx/site: \`paths: { pages/${segment}: … }\` is NOT read on the sync lane — ` +
1456
+ `\`${segment}\` ${fate} and none of the mounted ` +
1457
+ `pages will reach the backend. A build mounts it normally. Inline the ` +
1458
+ `directory under \`pages/${segment}/\` to push it, or ship the site with ` +
1459
+ `\`uniweb export\` / \`uniweb deploy --host <adapter>\`.` +
1460
+ REPUBLISH_CLAUSE
1461
+ )
1462
+ }
1463
+ let pages = []
1464
+ if (existsSync(pagesPath)) {
1465
+ // The root has a mode too, read as the build reads it (`rootContentMode`):
1466
+ // `pages/folder.yml` puts the whole site in folder mode, and every folder with no
1467
+ // config of its own inherits it. ⛔ This walk started in page mode unconditionally
1468
+ // until 2026-09-19, so such a site's top-level `.md` pages were dropped without a
1469
+ // word and its top-level folders were pushed as pages of sections.
1470
+ const { mode: rootMode, source: rootSource, config: rootConfig } = await readFolderConfig(pagesPath, 'sections')
1471
+ if (rootMode === 'pages') await warnFolderModePagesSkipped(pagesPath, siteRoot, rootSource)
1472
+ // The top level's order and homepage, decided by the build's own function: the
1473
+ // site's `pages:` / `index:` over the pages directory's own. ⛔ The homepage read
1474
+ // `index:` alone until 2026-09-19, so a site naming it by its first `pages:` entry
1475
+ // — as the build does — pushed no homepage at all.
1476
+ const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
1477
+ pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
1478
+ }
1479
+
1359
1480
  const layoutSections = await collectLayoutNested(layoutDir, siteRoot)
1360
1481
 
1361
1482
  // Wrap each section's content into its per-locale form (source doc + target