@uniweb/build 0.80.2 → 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/site/layout-folder.js +20 -9
- package/src/uwx/index.js +2 -0
- package/src/uwx/locale-sync.js +13 -1
- 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,10 +57,10 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
+
"@uniweb/projections": "^0.7.15",
|
|
60
61
|
"@uniweb/content-reader": "^1.2.8",
|
|
61
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
62
63
|
"@uniweb/schemas": "^0.13.0",
|
|
63
|
-
"@uniweb/projections": "^0.7.15",
|
|
64
64
|
"@uniweb/semantic-parser": "^1.5.1",
|
|
65
65
|
"@uniweb/theming": "^0.2.0"
|
|
66
66
|
},
|
|
@@ -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/core": "^0.38.0",
|
|
78
|
+
"@uniweb/runtime": "^0.29.4"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
|
@@ -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
|
@@ -27,6 +27,7 @@ import { visitDataBlockStrings } from '../i18n/data-strings.js'
|
|
|
27
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
|
|
|
@@ -389,6 +390,15 @@ 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
|
+
|
|
392
402
|
// ⭐ A LINK'S LABEL, NOT THE LINK, when only its words were translated. The merge reads a label onto
|
|
393
403
|
// the source's own link (`merge.js::translatedInline`), so it keeps following that link wherever the
|
|
394
404
|
// source moves it, where `[Donar Ahora](/contact)` would keep `/contact`. Several links are a label a
|
|
@@ -564,7 +574,9 @@ export function writeFreeformTranslations(siteRoot, freeformPending) {
|
|
|
564
574
|
}
|
|
565
575
|
// ⭐ A file whose markdown already says this is left as the author wrote it — the writer's
|
|
566
576
|
// markdown is not theirs (blank lines, line breaks), as for a section body (`writeSectionFile`).
|
|
567
|
-
|
|
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))) {
|
|
568
580
|
mkdirSync(dirname(filePath), { recursive: true })
|
|
569
581
|
writeFileSync(filePath, text)
|
|
570
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
|