@uniweb/build 0.80.1 → 0.80.3
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/merge.js +51 -1
- package/src/site/layout-folder.js +20 -9
- package/src/uwx/index.js +2 -0
- package/src/uwx/locale-sync.js +31 -3
- package/src/uwx/project-writer.js +4 -1
- package/src/uwx/site-project.js +140 -9
- package/src/uwx/site.js +9 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.80.
|
|
3
|
+
"version": "0.80.3",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
-
"@uniweb/content-reader": "^1.2.8",
|
|
61
|
-
"@uniweb/semantic-parser": "^1.5.1",
|
|
62
60
|
"@uniweb/projections": "^0.7.15",
|
|
63
|
-
"@uniweb/
|
|
61
|
+
"@uniweb/content-reader": "^1.2.8",
|
|
64
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
63
|
+
"@uniweb/schemas": "^0.13.0",
|
|
64
|
+
"@uniweb/semantic-parser": "^1.5.1",
|
|
65
65
|
"@uniweb/theming": "^0.2.0"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
package/src/i18n/merge.js
CHANGED
|
@@ -443,7 +443,7 @@ function linkRuns(source) {
|
|
|
443
443
|
runs.push({ link, marks: [...node.marks], before: gap })
|
|
444
444
|
}
|
|
445
445
|
gap = []
|
|
446
|
-
} else if (
|
|
446
|
+
} else if (isGap(node)) {
|
|
447
447
|
gap.push(node)
|
|
448
448
|
} else {
|
|
449
449
|
return null
|
|
@@ -452,6 +452,49 @@ function linkRuns(source) {
|
|
|
452
452
|
return runs.length ? runs : null
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
// What may sit between two links of an element made only of links: a line break, or spaces.
|
|
456
|
+
function isGap(node) {
|
|
457
|
+
return node.type === 'hardBreak' || (node.type === 'text' && !node.marks?.length && !node.text?.trim())
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The labels of a translated element made only of links — `asLinkLabels` the other way, for a
|
|
462
|
+
* pull: one run of the target's own nodes per link of the source's, each without the marks its
|
|
463
|
+
* link carries. Null when the source is not only links, or the target's links are not the source's
|
|
464
|
+
* own, one to one and in order.
|
|
465
|
+
*
|
|
466
|
+
* ⚠️ It does not ask whether the merge reads the labels back to the target — a caller does
|
|
467
|
+
* (`uwx/locale-sync.js`), since only then are they the same translation.
|
|
468
|
+
*
|
|
469
|
+
* @param {Array} source - the element's source inline content
|
|
470
|
+
* @param {Array} target - the element's translated inline content
|
|
471
|
+
* @returns {Array<Array>|null} each link's label, as inline nodes
|
|
472
|
+
*/
|
|
473
|
+
export function linkLabels(source, target) {
|
|
474
|
+
const links = linkRuns(source)
|
|
475
|
+
if (!links) return null
|
|
476
|
+
const labels = []
|
|
477
|
+
let current = null
|
|
478
|
+
for (const node of target || []) {
|
|
479
|
+
if (!node) continue
|
|
480
|
+
const link = linkMarkOf(node)
|
|
481
|
+
if (link) {
|
|
482
|
+
if (!current || !sameMark(current, link)) {
|
|
483
|
+
const next = links[labels.length]
|
|
484
|
+
if (!next || !sameMark(next.link, link)) return null
|
|
485
|
+
labels.push([])
|
|
486
|
+
current = link
|
|
487
|
+
}
|
|
488
|
+
labels[labels.length - 1].push(withoutMarks(node, links[labels.length - 1].marks))
|
|
489
|
+
} else if (isGap(node)) {
|
|
490
|
+
current = null
|
|
491
|
+
} else {
|
|
492
|
+
return null
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return labels.length === links.length ? labels : null
|
|
496
|
+
}
|
|
497
|
+
|
|
455
498
|
function linkMarkOf(node) {
|
|
456
499
|
return node?.type === 'text' ? node.marks?.find((mark) => mark.type === 'link') || null : null
|
|
457
500
|
}
|
|
@@ -493,6 +536,13 @@ function withMarks(node, marks) {
|
|
|
493
536
|
return { ...node, marks: [...marks, ...own] }
|
|
494
537
|
}
|
|
495
538
|
|
|
539
|
+
// A label node without its link's marks — `withMarks` the other way.
|
|
540
|
+
function withoutMarks(node, marks) {
|
|
541
|
+
const own = (node.marks || []).filter((mark) => !marks.some((linkMark) => linkMark.type === mark.type))
|
|
542
|
+
const { marks: _linkMarks, ...rest } = node
|
|
543
|
+
return own.length ? { ...rest, marks: own } : rest
|
|
544
|
+
}
|
|
545
|
+
|
|
496
546
|
/**
|
|
497
547
|
* Translate the human-readable strings inside a section's TAGGED DATA BLOCKS,
|
|
498
548
|
* in place. The other half of the fix in `extract.js` — a manifest entry nobody
|
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
* @module @uniweb/build/site/layout-folder
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import {
|
|
34
|
-
import { existsSync } from 'node:fs'
|
|
33
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
35
34
|
import { dirname, join, parse, relative } from 'node:path'
|
|
36
35
|
import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
|
|
37
36
|
import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
|
|
@@ -93,7 +92,19 @@ export function _resetLayoutFolderWarnings() {
|
|
|
93
92
|
* section order; `source` is the area's path, relative, for messages.
|
|
94
93
|
* @throws when the folder declares something no lane can read the same way
|
|
95
94
|
*/
|
|
96
|
-
export async function readLayoutFolder(layoutDir,
|
|
95
|
+
export async function readLayoutFolder(layoutDir, options = {}) {
|
|
96
|
+
return readLayoutFolderSync(layoutDir, options)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `readLayoutFolder`, synchronously — the same reader, for a caller that cannot await: the pull,
|
|
101
|
+
* which writes each area's sections back where this finds them (`uwx/site-project.js`).
|
|
102
|
+
*
|
|
103
|
+
* @param {string} layoutDir
|
|
104
|
+
* @param {Object} [options] - as `readLayoutFolder`'s
|
|
105
|
+
* @returns {Array<{ layout: string, area: string, form: 'file'|'folder', dir: string, files: string[], source: string }>}
|
|
106
|
+
*/
|
|
107
|
+
export function readLayoutFolderSync(layoutDir, { siteRoot = null, onWarning = (m) => console.warn(m) } = {}) {
|
|
97
108
|
if (!layoutDir || !existsSync(layoutDir)) return []
|
|
98
109
|
|
|
99
110
|
const base = siteRoot || dirname(layoutDir)
|
|
@@ -140,8 +151,8 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
|
|
|
140
151
|
}
|
|
141
152
|
|
|
142
153
|
/** The areas inside one layout's folder (the root folder is the default layout's). */
|
|
143
|
-
const readAreas =
|
|
144
|
-
const entries = (
|
|
154
|
+
const readAreas = (layoutName, dir, { isRoot }) => {
|
|
155
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))
|
|
145
156
|
|
|
146
157
|
const files = []
|
|
147
158
|
const folders = []
|
|
@@ -168,14 +179,14 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
|
|
|
168
179
|
const areaDir = join(dir, folder)
|
|
169
180
|
const area = parseNumericPrefix(folder).name
|
|
170
181
|
const sectionFiles = []
|
|
171
|
-
for (const entry of
|
|
182
|
+
for (const entry of readdirSync(areaDir, { withFileTypes: true })) {
|
|
172
183
|
if (entry.isFile()) {
|
|
173
184
|
refusePageConfig(areaDir, entry.name)
|
|
174
185
|
if (!isMarkdownFile(entry.name)) continue
|
|
175
186
|
refuseChild(areaDir, entry.name)
|
|
176
187
|
sectionFiles.push(entry.name)
|
|
177
188
|
} else if (entry.isDirectory() && !isIgnoredFolder(entry.name)) {
|
|
178
|
-
const nested = (
|
|
189
|
+
const nested = readdirSync(join(areaDir, entry.name)).filter(isMarkdownFile)
|
|
179
190
|
if (nested.length > 0) {
|
|
180
191
|
fail(
|
|
181
192
|
join(areaDir, entry.name),
|
|
@@ -197,7 +208,7 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
|
|
|
197
208
|
return []
|
|
198
209
|
}
|
|
199
210
|
|
|
200
|
-
const layoutFolders =
|
|
211
|
+
const layoutFolders = readAreas(DEFAULT_LAYOUT, layoutDir, { isRoot: true })
|
|
201
212
|
|
|
202
213
|
// Each folder directly under layout/ is a named layout; `default` (in any case, with
|
|
203
214
|
// or without a trailing `Layout`) is the default layout written as a folder.
|
|
@@ -229,7 +240,7 @@ export async function readLayoutFolder(layoutDir, { siteRoot = null, onWarning =
|
|
|
229
240
|
)
|
|
230
241
|
}
|
|
231
242
|
}
|
|
232
|
-
|
|
243
|
+
readAreas(layoutName, join(layoutDir, folder), { isRoot: false })
|
|
233
244
|
}
|
|
234
245
|
|
|
235
246
|
return areas
|
package/src/uwx/index.js
CHANGED
|
@@ -91,6 +91,8 @@ export {
|
|
|
91
91
|
// The file the author keeps a section in (`1-hero.md` holds `hero`) — what a section
|
|
92
92
|
// unit's path names on disk, for a push recording what it delivered.
|
|
93
93
|
existingSectionFile,
|
|
94
|
+
// …and the file a layout unit names on disk (`default/footer.md` holds the `footer.md` place).
|
|
95
|
+
layoutUnitFile,
|
|
94
96
|
} from './site-project.js'
|
|
95
97
|
export {
|
|
96
98
|
createTranslationCollector,
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -24,9 +24,10 @@ import { proseMirrorToMarkdown, serializeInlineContent } from '@uniweb/content-w
|
|
|
24
24
|
import { computeHash } from '../i18n/hash.js'
|
|
25
25
|
import { blockElements, elementText, dataBlockNodes } from '../i18n/extract.js'
|
|
26
26
|
import { visitDataBlockStrings } from '../i18n/data-strings.js'
|
|
27
|
-
import { resolveDocForLocale } from '../i18n/merge.js'
|
|
27
|
+
import { resolveDocForLocale, translatedInline, linkLabels } from '../i18n/merge.js'
|
|
28
28
|
import { computeSourceHash } from '../i18n/freeform-manifest.js'
|
|
29
29
|
import { sameMarkdownDocument, canonicalJson } from './same-content.js'
|
|
30
|
+
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
30
31
|
|
|
31
32
|
const FREEFORM_MANIFEST = '.manifest.json'
|
|
32
33
|
|
|
@@ -380,7 +381,7 @@ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
|
|
|
380
381
|
const srcMd = serializeInlineContent(src[i].content || [])
|
|
381
382
|
const tgtMd = serializeInlineContent(tgt[i].content || [])
|
|
382
383
|
if (srcMd === tgtMd) continue // unchanged → untranslated → omit
|
|
383
|
-
map[key] = tgtMd
|
|
384
|
+
map[key] = labelsAlone(src[i].content || [], tgt[i].content || [], tgtMd) ?? tgtMd
|
|
384
385
|
}
|
|
385
386
|
} catch {
|
|
386
387
|
// a mark we can't serialize → don't risk a lossy map; store as free-form
|
|
@@ -389,6 +390,31 @@ function deriveStructuralMap(sourceDoc, targetDoc, modelFor = () => null) {
|
|
|
389
390
|
return addDataBlockStrings(map, sourceDoc, targetDoc, modelFor)
|
|
390
391
|
}
|
|
391
392
|
|
|
393
|
+
// Whether a free-form file says `target`, read as a push reads it — its body, as stored.
|
|
394
|
+
function sameStoredFreeform(text, filePath, target) {
|
|
395
|
+
try {
|
|
396
|
+
return sameMarkdownDocument(parseFrontmatter(text, filePath).body, target)
|
|
397
|
+
} catch {
|
|
398
|
+
return false
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ⭐ A LINK'S LABEL, NOT THE LINK, when only its words were translated. The merge reads a label onto
|
|
403
|
+
// the source's own link (`merge.js::translatedInline`), so it keeps following that link wherever the
|
|
404
|
+
// source moves it, where `[Donar Ahora](/contact)` would keep `/contact`. Several links are a label a
|
|
405
|
+
// line, as a translator writes them. Written only when the merge reads it back to this very element;
|
|
406
|
+
// a link a translator changed — the Spanish button pointed at a Spanish page — is written whole.
|
|
407
|
+
// ⛔ Until 2026-10-08 the element was always written whole: once the merge read a label (2026-10-01),
|
|
408
|
+
// a pull into the copy that pushed rewrote `"Donar Ahora"` as `"[Donar Ahora](/contact)"`, and split a
|
|
409
|
+
// string a page title and a button share into a default and per-place overrides.
|
|
410
|
+
function labelsAlone(source, target, whole) {
|
|
411
|
+
const labels = linkLabels(source, target)
|
|
412
|
+
if (!labels) return null
|
|
413
|
+
const value = labels.map((nodes) => serializeInlineContent(nodes)).join('\n')
|
|
414
|
+
const back = translatedInline(source, value)
|
|
415
|
+
return back && serializeInlineContent(back) === whole ? value : null
|
|
416
|
+
}
|
|
417
|
+
|
|
392
418
|
// ⭐ A TAGGED DATA BLOCK's translated strings, read pairwise — the strings the build translates
|
|
393
419
|
// (`visitDataBlockStrings`: a block's declared shape, else the extractor's heuristic), keyed by their
|
|
394
420
|
// source text as the build keys them. ⭐ Walked by the same model the push translated with — the pairing
|
|
@@ -548,7 +574,9 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
|
|
|
548
574
|
}
|
|
549
575
|
// ⭐ A file whose markdown already says this is left as the author wrote it — the writer's
|
|
550
576
|
// markdown is not theirs (blank lines, line breaks), as for a section body (`writeSectionFile`).
|
|
551
|
-
|
|
577
|
+
// Compared as a push reads it (`i18n/freeform.js::loadFreeformTranslation`). ⛔ Until 2026-10-08 the
|
|
578
|
+
// file was trimmed first, so one whose last line ended in a space was rewritten by every pull.
|
|
579
|
+
if (current !== text && !(current !== null && sameStoredFreeform(current, filePath, target))) {
|
|
552
580
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
553
581
|
writeFileSync(filePath, text)
|
|
554
582
|
report.written.push(filePath)
|
|
@@ -204,7 +204,10 @@ export function writeSectionFile({ filePath, content, params, reserved = DEFAULT
|
|
|
204
204
|
// only frontmatter — a parametric page's `type:` and `fetch:` — was re-serialized, and lost its
|
|
205
205
|
// comments, on a pull that changed nothing. So is no document at all (a params-only update).
|
|
206
206
|
const authoredBody = (existingBody || '').replace(/^\n+/, '').replace(/\s+$/, '')
|
|
207
|
-
|
|
207
|
+
// Compared as a push reads it: the body as stored (`content-collector.js::processMarkdownFile`).
|
|
208
|
+
// ⛔ Until 2026-10-08 the trimmed copy was compared, so a body whose last line ended in a space never
|
|
209
|
+
// matched what a push had sent from it, and every pull rewrote it — trailing blank lines and all.
|
|
210
|
+
const keepsBody = !content || sameMarkdownDocument(existingBody || '', content)
|
|
208
211
|
if (existing && keepsBody && canonicalJson(nextFrontmatter) === canonicalJson(frontmatter)) return 'unchanged'
|
|
209
212
|
const body = keepsBody ? authoredBody : proseMirrorToMarkdown(content)
|
|
210
213
|
return writeIfChanged(filePath, assembleSection(nextFrontmatter, body))
|
package/src/uwx/site-project.js
CHANGED
|
@@ -52,7 +52,8 @@ import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
|
|
|
52
52
|
import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
53
53
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
54
54
|
import { siteContentDirs } from './site-dirs.js'
|
|
55
|
-
import { layoutAreaRoute } from '../site/layout-folder.js'
|
|
55
|
+
import { layoutAreaRoute, readLayoutFolderSync, DEFAULT_LAYOUT } from '../site/layout-folder.js'
|
|
56
|
+
import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
56
57
|
import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
|
|
57
58
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
58
59
|
|
|
@@ -503,6 +504,7 @@ export function sectionFileBase(record) {
|
|
|
503
504
|
export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext }) {
|
|
504
505
|
const written = []
|
|
505
506
|
const files = [] // the top level's file names, in the document's order
|
|
507
|
+
const childFiles = [] // every nested section's file name
|
|
506
508
|
let nested = false
|
|
507
509
|
const buildEntries = (records, top) => {
|
|
508
510
|
const entries = []
|
|
@@ -539,6 +541,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
|
|
|
539
541
|
context: pageContext ? translationContext({ stableId }, pageContext.route) : null,
|
|
540
542
|
})
|
|
541
543
|
written.push(filePath)
|
|
544
|
+
if (!top) childFiles.push(basename(filePath))
|
|
542
545
|
const children = Array.isArray(record.$children) ? record.$children : []
|
|
543
546
|
if (children.length > 0) nested = true
|
|
544
547
|
// Listed by the name the build finds the file by — which is not the id for a file found by its `id:`.
|
|
@@ -552,8 +555,12 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
|
|
|
552
555
|
ctx?.sectionFiles?.set(pageDir, new Set(written.map((f) => basename(f, extname(f)))))
|
|
553
556
|
// ⭐ Whether the files give the document's order by themselves, as the build reads a page with no
|
|
554
557
|
// `sections:` list — by name, numeric prefixes first — so the page needs no list to say it.
|
|
555
|
-
const
|
|
556
|
-
|
|
558
|
+
const topInOrder = files.every((f) => !f.startsWith('@')) && [...files].sort(compareFilenames).join('\n') === files.join('\n')
|
|
559
|
+
const filesGiveOrder = !nested && topInOrder
|
|
560
|
+
// …and, for a nested page, whether they give its top level, every child being an `@` file — which
|
|
561
|
+
// is all a `nest:` needs beside them (`pageRecordToYml`).
|
|
562
|
+
const filesGiveTopOrder = nested && topInOrder && childFiles.every((f) => f.startsWith('@'))
|
|
563
|
+
return { sections, written, filesGiveOrder, filesGiveTopOrder }
|
|
557
564
|
}
|
|
558
565
|
|
|
559
566
|
// The name the build reads a section file's section by: its file name without the `@` child mark and
|
|
@@ -585,8 +592,12 @@ function listedNames(list) {
|
|
|
585
592
|
export function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
|
|
586
593
|
if (!existsSync(pageDir)) return null
|
|
587
594
|
const entries = readdirSync(pageDir).filter((e) => isMarkdownFile(e) && (child || !e.startsWith('@')))
|
|
595
|
+
// A child is looked for as the build looks for a `nest:` child (`findSectionFile`): `name.md`, then
|
|
596
|
+
// `@name.md`, then a numbered one. ⛔ Until 2026-10-08 a child went to the first file of its name in
|
|
597
|
+
// directory order, so `@booking-form.md` came back into a top-level `1-booking-form.md` beside it.
|
|
588
598
|
const found =
|
|
589
599
|
entries.find((e) => basename(e, '.md') === fileBase) ||
|
|
600
|
+
(child && entries.find((e) => e === `@${fileBase}.md`)) ||
|
|
590
601
|
entries.find((e) => fileSectionName(e) === fileBase) ||
|
|
591
602
|
entries.find((e) => {
|
|
592
603
|
try {
|
|
@@ -608,7 +619,7 @@ export function existingSectionFile(pageDir, fileBase, stableId, { child = false
|
|
|
608
619
|
const PAGE_YML_MANAGED_KEYS = new Set([
|
|
609
620
|
'id', 'title', 'description', 'label', 'keywords', 'index', 'hidden',
|
|
610
621
|
'hideIn', 'knowledge', 'trackSections', 'redirect', 'rewrite', 'layout', 'seo',
|
|
611
|
-
'query', 'fetch', 'data', 'sections',
|
|
622
|
+
'query', 'fetch', 'data', 'sections', 'nest',
|
|
612
623
|
])
|
|
613
624
|
|
|
614
625
|
// Keys a pull WRITES and never removes — kept out of the managed set on purpose. ⭐ `slug`, a page's
|
|
@@ -623,7 +634,7 @@ const PAGE_YML_KEPT_KEYS = new Set(['slug'])
|
|
|
623
634
|
// entry names the directory; its other locales are the page's `slug:` map.
|
|
624
635
|
// Identity (the backend uuid) is NOT written here — it lives in the gitignored
|
|
625
636
|
// `.uniweb/` index so authored files stay clean.
|
|
626
|
-
function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, { isRoot = false, slug = null, filesGiveOrder = false } = {}) {
|
|
637
|
+
function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, { isRoot = false, slug = null, filesGiveOrder = false, filesGiveTopOrder = false } = {}) {
|
|
627
638
|
const y = {}
|
|
628
639
|
if (record.stable_id !== undefined) y.id = record.stable_id
|
|
629
640
|
// The page's localized URL segments, when the site keeps them here (`pageSlugToYml`).
|
|
@@ -671,22 +682,53 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
|
|
|
671
682
|
// their nesting, and anything new is discovered and appended as it would be in a
|
|
672
683
|
// page that was never pulled.
|
|
673
684
|
// ⭐ The author's own list is kept when it gives the same order — its `...` stays where they put it.
|
|
674
|
-
// A page with no list gets none where its files' names give the order (`filesGiveOrder`)
|
|
685
|
+
// A page with no list gets none where its files' names give the order (`filesGiveOrder`) — nor where
|
|
686
|
+
// they give the top level and the author's `nest:` the nesting, the form the docs lead with.
|
|
675
687
|
if (sectionsArray && sectionsArray.length > 0) {
|
|
676
688
|
const authored = existing?.sections
|
|
677
689
|
if (Array.isArray(authored)) {
|
|
678
690
|
// Compared by the names the files are found by, so a list naming `1-hero` keeps naming it.
|
|
679
691
|
const same = JSON.stringify(listedNames(authored.filter((e) => e !== '...'))) === JSON.stringify(sectionsArray)
|
|
680
692
|
y.sections = same ? authored : [...sectionsArray, '...']
|
|
693
|
+
} else if (filesGiveTopOrder && nestSays(existing?.nest, sectionsArray)) {
|
|
694
|
+
// Nothing to list: the files and `nest:` say it, kept below.
|
|
681
695
|
} else if (!filesGiveOrder) {
|
|
682
696
|
y.sections = [...sectionsArray, '...']
|
|
683
697
|
} else if (authored !== undefined) {
|
|
684
698
|
y.sections = authored // `'*'`, which says what no list says
|
|
685
699
|
}
|
|
686
700
|
}
|
|
701
|
+
// ⭐ `nest:` stays as the author wrote it — unless this pull writes a list that nests: `nest:` wins
|
|
702
|
+
// over a list's nesting (`content-collector.js::processNesting`), so beside a new one it would put
|
|
703
|
+
// back the nesting the list carries. ⛔ Until 2026-10-08 a pull added a list to every page that
|
|
704
|
+
// nested with `nest:`, beside it, saying the same nesting twice — and a nesting changed in the app
|
|
705
|
+
// came back in the list, where the stale `nest:` overrode it.
|
|
706
|
+
const listed = y.sections !== undefined && y.sections !== existing?.sections && nestsIn(y.sections)
|
|
707
|
+
if (existing?.nest !== undefined && !listed) y.nest = existing.nest
|
|
687
708
|
return y
|
|
688
709
|
}
|
|
689
710
|
|
|
711
|
+
// Whether a `sections:` list nests: an entry that is a parent with its children.
|
|
712
|
+
function nestsIn(list) {
|
|
713
|
+
return Array.isArray(list) && list.some((entry) => isPlainRecord(entry))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Whether a page's `nest:` gives exactly the nesting of the sections the pull wrote — each parent the
|
|
717
|
+
// list nests, its children in order, one level down, as the build attaches them to top-level sections.
|
|
718
|
+
function nestSays(nest, list) {
|
|
719
|
+
if (!isPlainRecord(nest)) return false
|
|
720
|
+
const parents = list.filter((entry) => isPlainRecord(entry))
|
|
721
|
+
if (parents.length !== Object.keys(nest).length) return false
|
|
722
|
+
return parents.every((entry) => {
|
|
723
|
+
const [parent, children] = Object.entries(entry)[0]
|
|
724
|
+
return (
|
|
725
|
+
Array.isArray(nest[parent]) &&
|
|
726
|
+
children.every((child) => typeof child === 'string') &&
|
|
727
|
+
JSON.stringify(nest[parent].map(String)) === JSON.stringify(children)
|
|
728
|
+
)
|
|
729
|
+
})
|
|
730
|
+
}
|
|
731
|
+
|
|
690
732
|
// ⛔ A PRUNE MAY ONLY DELETE WHAT A PUSH COULD HAVE SENT. An orphan is a file the
|
|
691
733
|
// incoming document no longer has — but a push reads the tree with the build's own
|
|
692
734
|
// rules (`isMarkdownFile`, `isIgnoredFolder`), so a `_draft.md`, a `README.md`, a
|
|
@@ -787,10 +829,12 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
|
|
|
787
829
|
|
|
788
830
|
let sectionsArray = []
|
|
789
831
|
let filesGiveOrder = false
|
|
832
|
+
let filesGiveTopOrder = false
|
|
790
833
|
if (record.mode === 'page' && Array.isArray(record.page_sections)) {
|
|
791
834
|
const r = pageSectionsToFiles({ pageDir, pageSections: record.page_sections, ctx, pageContext })
|
|
792
835
|
sectionsArray = r.sections
|
|
793
836
|
filesGiveOrder = r.filesGiveOrder
|
|
837
|
+
filesGiveTopOrder = r.filesGiveTopOrder
|
|
794
838
|
report.sections.push(...r.written)
|
|
795
839
|
}
|
|
796
840
|
|
|
@@ -800,7 +844,15 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
|
|
|
800
844
|
// owns only PAGE_YML_MANAGED_KEYS.
|
|
801
845
|
const existing = readAuthoredYaml(ymlPath)
|
|
802
846
|
const pageSlug = pageSlugToYml(record, canon, existing, sourceLocale, ctx)
|
|
803
|
-
|
|
847
|
+
const yml = pageRecordToYml(record, sectionsArray, sourceLocale, existing, { isRoot: !routePrefix, slug: pageSlug, filesGiveOrder, filesGiveTopOrder })
|
|
848
|
+
// ⭐ A `page.yml` with nothing to say is not created: the build reads an empty one as none
|
|
849
|
+
// (`content-collector.js::readFolderConfig`). ⛔ Until 2026-10-08 a pull wrote `{}` into every page
|
|
850
|
+
// whose folder had none. An empty `folder.yml` is written still — its presence is folder mode.
|
|
851
|
+
if (ymlName === 'page.yml' && Object.keys(yml).length === 0 && !existsSync(ymlPath)) {
|
|
852
|
+
writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
|
|
853
|
+
continue
|
|
854
|
+
}
|
|
855
|
+
writeMergedYaml(ymlPath, yml, PAGE_YML_MANAGED_KEYS)
|
|
804
856
|
report.pages.push(ymlPath)
|
|
805
857
|
|
|
806
858
|
writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
|
|
@@ -1003,6 +1055,82 @@ export function layoutSectionPaths(layoutSections) {
|
|
|
1003
1055
|
return out
|
|
1004
1056
|
}
|
|
1005
1057
|
|
|
1058
|
+
// The key an area is held under on both sides: its layout's name as the runtime compares it.
|
|
1059
|
+
const layoutAreaKey = (layout, area) => `${layoutNameKey(layout || DEFAULT_LAYOUT)}/${area}`
|
|
1060
|
+
|
|
1061
|
+
// The areas the author's layout folder holds, by key — read by the build's own reader, so found
|
|
1062
|
+
// wherever the build finds them. None for a folder the build refuses.
|
|
1063
|
+
function authoredLayoutAreas(layoutBaseDir) {
|
|
1064
|
+
let areas = []
|
|
1065
|
+
try {
|
|
1066
|
+
areas = readLayoutFolderSync(layoutBaseDir, { onWarning: () => {} })
|
|
1067
|
+
} catch {
|
|
1068
|
+
areas = []
|
|
1069
|
+
}
|
|
1070
|
+
return new Map(areas.map((area) => [layoutAreaKey(area.layout, area.area), area]))
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// The layout and area a placement names, and the section's id where it names one.
|
|
1074
|
+
function placementOf(relPath) {
|
|
1075
|
+
const parts = relPath.split('/')
|
|
1076
|
+
const name = (file) => basename(file, extname(file))
|
|
1077
|
+
if (parts.length === 1) return { layout: DEFAULT_LAYOUT, area: name(parts[0]), id: null }
|
|
1078
|
+
if (parts.length === 2) return { layout: parts[0], area: name(parts[1]), id: null }
|
|
1079
|
+
return { layout: parts[0], area: parts[1], id: fileSectionName(parts[2]) }
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* ⭐ THE FILES THE AUTHOR KEEPS A LAYOUT'S SECTIONS IN — the area found by the build's own reader
|
|
1084
|
+
* (`readLayoutFolderSync`): one file, at the root or in its layout's folder (`default/footer.md`), or a
|
|
1085
|
+
* folder of section files numbered as the author numbers them. Each placed section → its file: the
|
|
1086
|
+
* author's where the area holds exactly the incoming sections, by name and in order; else the place
|
|
1087
|
+
* `layoutSectionPaths` gives it, as for a clone.
|
|
1088
|
+
* ⛔ Until 2026-10-08 every section went to that place, so a pull into the copy that pushed moved
|
|
1089
|
+
* `layout/default/footer.md` to `layout/footer.md` and renumbered `0-alert.md` and `1-header.md` as
|
|
1090
|
+
* `1-alert.md` and `2-header.md` — beside the old files, or, pruning, in place of them.
|
|
1091
|
+
*/
|
|
1092
|
+
function authoredLayoutFiles(layoutBaseDir, placed) {
|
|
1093
|
+
const areas = authoredLayoutAreas(layoutBaseDir)
|
|
1094
|
+
const byArea = new Map()
|
|
1095
|
+
for (const entry of placed) {
|
|
1096
|
+
const { layout, area } = placementOf(entry.relPath)
|
|
1097
|
+
const key = layoutAreaKey(layout, area)
|
|
1098
|
+
if (!byArea.has(key)) byArea.set(key, [])
|
|
1099
|
+
byArea.get(key).push(entry)
|
|
1100
|
+
}
|
|
1101
|
+
const fileOf = new Map()
|
|
1102
|
+
for (const [key, entries] of byArea) {
|
|
1103
|
+
const area = areas.get(key)
|
|
1104
|
+
let files = null
|
|
1105
|
+
if (area?.form === 'file' && entries.length === 1) files = [area.files[0]]
|
|
1106
|
+
else if (area?.form === 'folder' && area.files.length === entries.length) {
|
|
1107
|
+
const named = area.files.every((file, i) => fileSectionName(file) === recordStableId(entries[i].record))
|
|
1108
|
+
if (named) files = area.files
|
|
1109
|
+
}
|
|
1110
|
+
entries.forEach((entry, i) => fileOf.set(entry, files ? join(area.dir, files[i]) : join(layoutBaseDir, entry.relPath)))
|
|
1111
|
+
}
|
|
1112
|
+
return fileOf
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* The file a layout unit — a `layoutSectionPaths` place, relative to the layout folder — is on disk:
|
|
1117
|
+
* that place, else the author's file for the same section (`authoredLayoutFiles`). For a reader that
|
|
1118
|
+
* maps a unit to the file a pull wrote it to (`uniweb`'s `unitFilesOf`).
|
|
1119
|
+
*
|
|
1120
|
+
* @param {string} layoutBaseDir - the site's layout folder
|
|
1121
|
+
* @param {string} relPath - the unit's place, as `layoutSectionPaths` gives it
|
|
1122
|
+
* @returns {string} an absolute path
|
|
1123
|
+
*/
|
|
1124
|
+
export function layoutUnitFile(layoutBaseDir, relPath) {
|
|
1125
|
+
const placed = join(layoutBaseDir, relPath)
|
|
1126
|
+
if (existsSync(placed)) return placed
|
|
1127
|
+
const { layout, area, id } = placementOf(relPath)
|
|
1128
|
+
const found = authoredLayoutAreas(layoutBaseDir).get(layoutAreaKey(layout, area))
|
|
1129
|
+
if (!found) return placed
|
|
1130
|
+
const file = found.form === 'file' || found.files.length === 1 ? found.files[0] : found.files.find((f) => fileSectionName(f) === id)
|
|
1131
|
+
return file ? join(found.dir, file) : placed
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1006
1134
|
// Delete orphan layout section files not in `keep` — at every depth the layout
|
|
1007
1135
|
// folder reader reads (`<area>.md`, `<layout>/<area>.md`, `<layout>/<area>/*.md`) —
|
|
1008
1136
|
// and remove an area or layout folder left empty. Only files the reader treats as
|
|
@@ -1045,8 +1173,11 @@ function pruneOrphanLayout(layoutBaseDir, keep, report) {
|
|
|
1045
1173
|
// layout and area folders. Pruning is guarded against an empty incoming set.
|
|
1046
1174
|
function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
|
|
1047
1175
|
const written = []
|
|
1048
|
-
|
|
1049
|
-
|
|
1176
|
+
const placed = layoutSectionPaths(layoutSections)
|
|
1177
|
+
const fileOf = authoredLayoutFiles(layoutBaseDir, placed)
|
|
1178
|
+
for (const entry of placed) {
|
|
1179
|
+
const { record } = entry
|
|
1180
|
+
const filePath = fileOf.get(entry)
|
|
1050
1181
|
// A relocation into a new area folder needs the folder first.
|
|
1051
1182
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
1052
1183
|
placeByUuid(ctx, record.$uuid, filePath)
|
package/src/uwx/site.js
CHANGED
|
@@ -513,10 +513,16 @@ function sectionToRecord(section, index) {
|
|
|
513
513
|
return rec
|
|
514
514
|
}
|
|
515
515
|
|
|
516
|
-
// Resolve a section's logical name (`hero`, `card-a`) to its file in `mdFiles`,
|
|
517
|
-
//
|
|
518
|
-
//
|
|
516
|
+
// Resolve a section's logical name (`hero`, `card-a`) to its file in `mdFiles`, in the
|
|
517
|
+
// normal build's order (`content-collector.js::findSectionFile`): `name.md`, then
|
|
518
|
+
// `@name.md`, then the first file whose name is `name` once its `@` and `N-` prefixes
|
|
519
|
+
// are off. ⛔ Until 2026-10-08 this took the first match in file order, so a child
|
|
520
|
+
// `@booking-form.md` that `nest:` names beside a top-level `1-booking-form.md` resolved to
|
|
521
|
+
// the top-level file — already placed, so skipped — and the child was pushed as an
|
|
522
|
+
// orphan at the end of the page, where the build nests it.
|
|
519
523
|
function findSectionFileName(mdFiles, sectionName) {
|
|
524
|
+
if (mdFiles.includes(`${sectionName}.md`)) return `${sectionName}.md`
|
|
525
|
+
if (mdFiles.includes(`@${sectionName}.md`)) return `@${sectionName}.md`
|
|
520
526
|
for (const file of mdFiles) {
|
|
521
527
|
const bare = stripAtPrefix(parse(file).name)
|
|
522
528
|
if (parseNumericPrefix(bare).name === sectionName) return file
|