@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 +7 -7
- package/src/i18n/audit.js +59 -15
- package/src/i18n/extract.js +56 -6
- package/src/i18n/hash.js +15 -34
- package/src/i18n/index.js +77 -2
- package/src/i18n/merge.js +57 -12
- package/src/i18n/sync.js +33 -0
- package/src/site/config.js +4 -1
- package/src/site/plugin.js +68 -20
- package/src/uwx/emit-surface.json +0 -3
- package/src/uwx/site-project.js +6 -6
- package/src/uwx/site.js +68 -11
- package/src/uwx/yaml-upsert.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
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
|
|
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
|
|
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
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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.
|
|
165
|
-
lines.push(`
|
|
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.
|
|
180
|
-
lines.push(`\n Translations
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
lines.push(` - ${entry.hash}
|
|
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.
|
|
186
|
-
lines.push(` ... and ${result.
|
|
229
|
+
if (result.losesMarkup.length > 10) {
|
|
230
|
+
lines.push(` ... and ${result.losesMarkup.length - 10} more`)
|
|
187
231
|
}
|
|
188
232
|
}
|
|
189
233
|
}
|
package/src/i18n/extract.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
212
|
-
|
|
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 =
|
|
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/site/config.js
CHANGED
|
@@ -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
|
|
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)
|
package/src/site/plugin.js
CHANGED
|
@@ -493,6 +493,51 @@ function escapeHtml(str) {
|
|
|
493
493
|
.replace(/"/g, '"')
|
|
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
|
|
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
|
-
|
|
552
|
-
const
|
|
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:
|
|
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' &&
|
|
1235
|
+
if (req.url === '/sitemap.xml' && devSeo.baseUrl && siteContent?.pages) {
|
|
1190
1236
|
res.setHeader('Content-Type', 'application/xml')
|
|
1191
|
-
res.end(generateSitemap(siteContent.pages,
|
|
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' &&
|
|
1242
|
+
if (req.url === '/robots.txt' && devSeo.baseUrl) {
|
|
1197
1243
|
res.setHeader('Content-Type', 'text/plain')
|
|
1198
|
-
res.end(generateRobotsTxt(
|
|
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
|
-
|
|
1402
|
-
|
|
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
|
-
|
|
1605
|
+
const buildSeo = resolveSeo(finalContent)
|
|
1606
|
+
if (buildSeo.baseUrl && finalContent?.pages) {
|
|
1559
1607
|
// Generate sitemap.xml
|
|
1560
|
-
const sitemap = generateSitemap(finalContent.pages,
|
|
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(
|
|
1617
|
+
const robotsTxt = generateRobotsTxt(buildSeo.baseUrl, buildSeo.robots)
|
|
1570
1618
|
this.emitFile({
|
|
1571
1619
|
type: 'asset',
|
|
1572
1620
|
fileName: 'robots.txt',
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
// ⚠️
|
|
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
|
-
|
|
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
|
-
// ⚠️
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
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
|
-
//
|
|
1341
|
-
//
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
1344
|
-
//
|
|
1345
|
-
// the
|
|
1346
|
-
|
|
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')
|
package/src/uwx/yaml-upsert.js
CHANGED
|
@@ -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.
|
|
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) {
|