@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 +4 -4
- 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/content-collector.js +81 -20
- package/src/uwx/site-diff.js +3 -2
- package/src/uwx/site-dirs.js +32 -0
- package/src/uwx/site-project.js +20 -8
- package/src/uwx/site.js +135 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
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
|
|
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)) {
|
|
@@ -1714,14 +1714,18 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1714
1714
|
}
|
|
1715
1715
|
|
|
1716
1716
|
/**
|
|
1717
|
-
*
|
|
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
|
-
*
|
|
1720
|
-
*
|
|
1721
|
-
*
|
|
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
|
|
1724
|
-
const { pages: pagesArray, index: indexName } =
|
|
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
|
-
|
|
1737
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } =
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -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 `
|
|
68
|
-
* would differ from disk, which is a labelling
|
|
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
|
+
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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
|
-
//
|
|
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 (
|
|
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
|
|
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()
|
|
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
|
-
|
|
874
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1349
|
-
const
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
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
|