@uniweb/build 1.7.5 → 1.8.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/site/content-collector.js +99 -61
- package/src/uwx/git-authored.js +85 -0
- package/src/uwx/site-project.js +57 -9
- package/src/uwx/site.js +111 -120
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"sharp": "^0.35.3",
|
|
58
58
|
"yaml": "^2.5.0",
|
|
59
59
|
"@uniweb/content-writer": "~1.3.0",
|
|
60
|
+
"@uniweb/schemas": "~1.6.5",
|
|
60
61
|
"@uniweb/content-reader": "~1.5.4",
|
|
61
|
-
"@uniweb/semantic-parser": "~1.12.2",
|
|
62
62
|
"@uniweb/projections": "~1.1.6",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/
|
|
63
|
+
"@uniweb/semantic-parser": "~1.12.2",
|
|
64
|
+
"@uniweb/theming": "~1.2.1"
|
|
65
65
|
},
|
|
66
66
|
"optionalDependencies": {
|
|
67
67
|
"@uniweb/runtime": "~1.1.15"
|
|
@@ -540,18 +540,32 @@ function getContentProfile(configFile) {
|
|
|
540
540
|
*/
|
|
541
541
|
async function readFolderConfig(dirPath, inheritedMode) {
|
|
542
542
|
const folderYml = await readYamlFile(join(dirPath, 'folder.yml'))
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
543
|
+
const pageYml = Object.keys(folderYml).length > 0 ? {} : await readYamlFile(join(dirPath, 'page.yml'))
|
|
544
|
+
const { mode, source } = folderContentMode(folderYml, pageYml, existsSync(join(dirPath, 'folder.yml')), inheritedMode)
|
|
545
|
+
return { config: source === 'folder.yml' ? folderYml : source === 'page.yml' ? pageYml : {}, mode, source }
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* A folder's content mode from its two config files — `readFolderConfig`'s rule, as a pure
|
|
550
|
+
* function, so a synchronous reader applies the same one: the pull, which must know which pages
|
|
551
|
+
* a folder's `.md` files are (`uwx/git-authored.js`).
|
|
552
|
+
*
|
|
553
|
+
* - a `folder.yml` with keys → folder mode
|
|
554
|
+
* - else a `page.yml` with keys → page mode
|
|
555
|
+
* - else an empty `folder.yml` → folder mode (its presence is the declaration)
|
|
556
|
+
* - else the parent's mode
|
|
557
|
+
*
|
|
558
|
+
* @param {object|null} folderYml - the parsed `folder.yml`, or empty
|
|
559
|
+
* @param {object|null} pageYml - the parsed `page.yml`, or empty
|
|
560
|
+
* @param {boolean} hasFolderYml - whether a `folder.yml` exists at all
|
|
561
|
+
* @param {string} inheritedMode - 'sections' or 'pages'
|
|
562
|
+
* @returns {{ mode: string, source: string }}
|
|
563
|
+
*/
|
|
564
|
+
export function folderContentMode(folderYml, pageYml, hasFolderYml, inheritedMode) {
|
|
565
|
+
if (folderYml && Object.keys(folderYml).length > 0) return { mode: 'pages', source: 'folder.yml' }
|
|
566
|
+
if (pageYml && Object.keys(pageYml).length > 0) return { mode: 'sections', source: 'page.yml' }
|
|
567
|
+
if (hasFolderYml) return { mode: 'pages', source: 'folder.yml' }
|
|
568
|
+
return { mode: inheritedMode, source: 'inherited' }
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
/**
|
|
@@ -717,6 +731,63 @@ function resolveMounts(pathsConfig, sitePath, pagesPath, { strict = false } = {}
|
|
|
717
731
|
return resolved.size > 0 ? resolved : null
|
|
718
732
|
}
|
|
719
733
|
|
|
734
|
+
/**
|
|
735
|
+
* How a route read from a mount, a local folder of the same name, or both, is shaped: the two
|
|
736
|
+
* configs layered, the mode it is read in, and the mode its children are walked in. ONE rule, for
|
|
737
|
+
* the build's walk (`collectPagesRecursive`) and the push's (`uwx/site.js::orderedSubfolders`), so
|
|
738
|
+
* a mounted route is the same page tree on both lanes.
|
|
739
|
+
*
|
|
740
|
+
* The configs LAYER rather than one replacing the other: the mounted repo declares what its content
|
|
741
|
+
* is (ordering, title, how it nests), the local folder how this site presents it (layout, SEO, menu
|
|
742
|
+
* visibility). Mode is not a config key — it comes from which config file exists — so it is not
|
|
743
|
+
* spread: an explicit local declaration wins (a `page.yml` stub with its own markdown is a real
|
|
744
|
+
* landing page above mounted children); otherwise the mount decides, because it owns the content.
|
|
745
|
+
* A folder-mode route's children are walked in folder mode; a page-mode route's in the mount's own
|
|
746
|
+
* mode when they come from one — it is the mount's tree — else in the route's.
|
|
747
|
+
*
|
|
748
|
+
* @param {string} name - the route segment
|
|
749
|
+
* @param {{ config: object, mode: string, source: string }|null} local - the local folder's
|
|
750
|
+
* `readFolderConfig`, or null
|
|
751
|
+
* @param {{ config: object, mode: string, source: string }|null} mounted - the mount target's
|
|
752
|
+
* `readFolderConfig`, or null
|
|
753
|
+
* @param {string} contentMode - the mode of the level the route is on
|
|
754
|
+
* @returns {{ dirConfig: object, dirMode: string, childMode: string }}
|
|
755
|
+
*/
|
|
756
|
+
export function layerMountedFolder(name, local, mounted, contentMode) {
|
|
757
|
+
const dirConfig = mounted
|
|
758
|
+
? { title: mounted.config.title || name, ...mounted.config, ...local?.config }
|
|
759
|
+
: local.config
|
|
760
|
+
const dirMode = local && local.source !== 'inherited'
|
|
761
|
+
? local.mode
|
|
762
|
+
: mounted?.mode ?? local?.mode ?? contentMode
|
|
763
|
+
const childMode = dirMode === 'pages' ? 'pages' : mounted?.mode ?? dirMode
|
|
764
|
+
return { dirConfig, dirMode, childMode }
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* A folder-mode folder's `.md` pages, in the order the build lists them: by filename (numeric
|
|
769
|
+
* prefix first), then by the folder's own `pages:`. Each name is the page's slug — the filename
|
|
770
|
+
* without its numeric prefix. Shared by the build's walk and the push's, so the sync lane sends the
|
|
771
|
+
* pages the build makes, in the build's order.
|
|
772
|
+
*
|
|
773
|
+
* @param {string[]} entries - the folder's directory listing
|
|
774
|
+
* @param {{ pages?: Array }} [orderConfig] - the folder's own config
|
|
775
|
+
* @returns {{ files: Array<{ file: string, name: string }>, strict: Set<string>|null }} the pages
|
|
776
|
+
* in order, and the names a strict `pages:` lists (null when it is not strict)
|
|
777
|
+
*/
|
|
778
|
+
export function folderModePageFiles(entries, orderConfig) {
|
|
779
|
+
const items = entries.filter(isMarkdownFile).sort(compareFilenames).map((file) => {
|
|
780
|
+
const { name } = parse(file)
|
|
781
|
+
return { file, name: parseNumericPrefix(name).name || name }
|
|
782
|
+
})
|
|
783
|
+
const parsed = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
|
|
784
|
+
if (!parsed || parsed.mode === 'all') return { files: items, strict: null }
|
|
785
|
+
return {
|
|
786
|
+
files: applyWildcardOrder(items, parsed),
|
|
787
|
+
strict: parsed.mode === 'strict' ? new Set(parsed.before.map(extractItemName).filter(Boolean)) : null
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
720
791
|
/**
|
|
721
792
|
* Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
|
|
722
793
|
* Supports:
|
|
@@ -1885,21 +1956,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1885
1956
|
const mountPath = mounts?.get(name) ?? null
|
|
1886
1957
|
const localPath = localDirs.has(name) ? join(dirPath, name) : null
|
|
1887
1958
|
|
|
1888
|
-
// The content's declaration about itself, then the site's about presenting it.
|
|
1889
1959
|
const mounted = mountPath ? await readFolderConfig(mountPath, 'pages') : null
|
|
1890
1960
|
const local = localPath ? await readFolderConfig(localPath, contentMode) : null
|
|
1891
|
-
|
|
1892
|
-
const dirConfig = mounted
|
|
1893
|
-
? { title: mounted.config.title || name, ...mounted.config, ...local?.config }
|
|
1894
|
-
: local.config
|
|
1895
|
-
|
|
1896
|
-
// Mode is not a config key — it comes from which config file exists, so it
|
|
1897
|
-
// cannot be spread. An explicit local declaration wins (a `page.yml` stub
|
|
1898
|
-
// with its own markdown is a real landing page above mounted children);
|
|
1899
|
-
// otherwise the mount decides, because it owns the content being read.
|
|
1900
|
-
const dirMode = local && local.source !== 'inherited'
|
|
1901
|
-
? local.mode
|
|
1902
|
-
: mounted?.mode ?? local?.mode ?? contentMode
|
|
1961
|
+
const { dirConfig, dirMode, childMode } = layerMountedFolder(name, local, mounted, contentMode)
|
|
1903
1962
|
|
|
1904
1963
|
const folderLayout = normalizeLayoutConfig(dirConfig.layout)
|
|
1905
1964
|
|
|
@@ -1909,11 +1968,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1909
1968
|
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined,
|
|
1910
1969
|
dirConfig,
|
|
1911
1970
|
dirMode,
|
|
1912
|
-
|
|
1913
|
-
// the mount's tree. Without this a `page.yml` stub silently imposed page
|
|
1914
|
-
// mode on a mounted folder of pages, and only every mounted subfolder
|
|
1915
|
-
// carrying its own folder.yml kept that from collapsing them into one page.
|
|
1916
|
-
mountedContentMode: mounted?.mode ?? null,
|
|
1971
|
+
childMode,
|
|
1917
1972
|
childOrderConfig: {
|
|
1918
1973
|
pages: dirConfig.pages,
|
|
1919
1974
|
index: dirConfig.index
|
|
@@ -1998,32 +2053,13 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
1998
2053
|
|
|
1999
2054
|
// --- Pages mode: .md files are child pages ---
|
|
2000
2055
|
if (contentMode === 'pages') {
|
|
2001
|
-
//
|
|
2002
|
-
|
|
2003
|
-
const
|
|
2004
|
-
|
|
2005
|
-
for (const file of mdFiles) {
|
|
2006
|
-
const { name } = parse(file)
|
|
2007
|
-
const { name: stableName } = parseNumericPrefix(name)
|
|
2056
|
+
// Each .md file is a page, in the order `folderModePageFiles` gives — the one the push sends
|
|
2057
|
+
// them in (`uwx/site.js`): by filename, then by this folder's `pages:`.
|
|
2058
|
+
const { files: mdFiles, strict: strictPageNamesFM } = folderModePageFiles(entries, orderConfig)
|
|
2059
|
+
const orderedMdPages = []
|
|
2060
|
+
for (const { file, name } of mdFiles) {
|
|
2008
2061
|
const result = await processFileAsPage(join(dirPath, file), file, siteRoot, parentRoute)
|
|
2009
|
-
if (result) {
|
|
2010
|
-
mdPageItems.push({ name: stableName || name, result })
|
|
2011
|
-
}
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
// Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
|
|
2015
|
-
let orderedMdPages
|
|
2016
|
-
let strictPageNamesFM = null
|
|
2017
|
-
|
|
2018
|
-
const pagesParsedFM = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
|
|
2019
|
-
|
|
2020
|
-
if (pagesParsedFM && pagesParsedFM.mode !== 'all') {
|
|
2021
|
-
orderedMdPages = applyWildcardOrder(mdPageItems, pagesParsedFM)
|
|
2022
|
-
if (pagesParsedFM.mode === 'strict') {
|
|
2023
|
-
strictPageNamesFM = new Set(pagesParsedFM.before.map(extractItemName).filter(Boolean))
|
|
2024
|
-
}
|
|
2025
|
-
} else {
|
|
2026
|
-
orderedMdPages = mdPageItems
|
|
2062
|
+
if (result) orderedMdPages.push({ name, result })
|
|
2027
2063
|
}
|
|
2028
2064
|
|
|
2029
2065
|
// In folder mode, determine index page (homepage only).
|
|
@@ -2064,7 +2100,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2064
2100
|
|
|
2065
2101
|
// Process subdirectories
|
|
2066
2102
|
for (const folder of orderedFolders) {
|
|
2067
|
-
const { name: entry, path: entryPath, dirConfig, dirMode,
|
|
2103
|
+
const { name: entry, path: entryPath, dirConfig, dirMode, childMode, childOrderConfig, childLayout } = folder
|
|
2068
2104
|
assertRouteFolder(entry, parentRoute)
|
|
2069
2105
|
const isIndex = entry === indexName
|
|
2070
2106
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
@@ -2102,7 +2138,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2102
2138
|
const childDirPath = mounts?.get(entry) || entryPath
|
|
2103
2139
|
const childParentRoute = isIndex ? parentRoute : page.route
|
|
2104
2140
|
const childFetch = page.fetch || parentFetch
|
|
2105
|
-
const childContentMode =
|
|
2141
|
+
const childContentMode = childMode
|
|
2106
2142
|
const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, childContentMode, null, effectiveLayout)
|
|
2107
2143
|
pages.push(...subResult.pages)
|
|
2108
2144
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
@@ -2160,7 +2196,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2160
2196
|
// Recurse in folder mode — pass container's own fetch config (or fall back to parent's)
|
|
2161
2197
|
const childDirPath = mounts?.get(entry) || entryPath
|
|
2162
2198
|
const containerFetch = containerPage.fetch || parentFetch
|
|
2163
|
-
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext,
|
|
2199
|
+
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, childMode, null, effectiveLayout)
|
|
2164
2200
|
pages.push(...subResult.pages)
|
|
2165
2201
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
2166
2202
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
@@ -2202,7 +2238,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2202
2238
|
|
|
2203
2239
|
// Second pass: process each page folder
|
|
2204
2240
|
for (const folder of orderedFolders) {
|
|
2205
|
-
const { name: entry, path: entryPath, dirConfig, dirMode,
|
|
2241
|
+
const { name: entry, path: entryPath, dirConfig, dirMode, childMode, childOrderConfig, childLayout } = folder
|
|
2206
2242
|
assertRouteFolder(entry, parentRoute)
|
|
2207
2243
|
const isIndex = entry === indexPageName
|
|
2208
2244
|
const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
|
|
@@ -2262,7 +2298,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2262
2298
|
// container above passes it.
|
|
2263
2299
|
const childDirPath = mounts?.get(entry) || entryPath
|
|
2264
2300
|
const containerFetch = containerPage.fetch || parentFetch
|
|
2265
|
-
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext,
|
|
2301
|
+
const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, containerFetch, versionContext, childMode, null, effectiveLayout)
|
|
2266
2302
|
pages.push(...subResult.pages)
|
|
2267
2303
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
2268
2304
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
@@ -2304,7 +2340,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
2304
2340
|
? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
|
|
2305
2341
|
: page.route
|
|
2306
2342
|
const childFetch = page.fetch || parentFetch
|
|
2307
|
-
const childContentMode =
|
|
2343
|
+
const childContentMode = childMode
|
|
2308
2344
|
const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, childContentMode, null, effectiveLayout)
|
|
2309
2345
|
pages.push(...subResult.pages)
|
|
2310
2346
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
@@ -3099,6 +3135,8 @@ export {
|
|
|
3099
3135
|
parseNumericPrefix,
|
|
3100
3136
|
compareFilenames,
|
|
3101
3137
|
processMarkdownFile,
|
|
3138
|
+
processFileAsPage,
|
|
3139
|
+
resolveMounts,
|
|
3102
3140
|
findSectionFile,
|
|
3103
3141
|
processNesting,
|
|
3104
3142
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// The pages a pull leaves alone: those authored in files a pull cannot write back.
|
|
2
|
+
//
|
|
3
|
+
// ⭐ Two kinds, decided 2026-10-23 [Diego: the pull leaves them alone, since the docs are written in
|
|
4
|
+
// git]. A push sends both whole, read by the build's own rules (`site.js`), so the backend holds
|
|
5
|
+
// the site as the build makes it. A pull writes neither:
|
|
6
|
+
//
|
|
7
|
+
// · every page under a `paths: { pages/<segment>: <dir> }` mount. Its files are another
|
|
8
|
+
// repository's, outside the site; the local folder of that name holds only how this site
|
|
9
|
+
// presents them, and a pull writing the mounted pages into it would grow a second copy there.
|
|
10
|
+
// · every `.md` page of a folder-mode folder. The file IS the page, and a pull writes a page as a
|
|
11
|
+
// folder (`page.yml` + its sections), which would turn the author's folder of pages into a
|
|
12
|
+
// folder of page folders.
|
|
13
|
+
//
|
|
14
|
+
// ⇒ An edit made to one of these pages in the app is not brought back, and the next push replaces
|
|
15
|
+
// it with the file. Pruning skips them too: a pull never deletes what it does not write.
|
|
16
|
+
//
|
|
17
|
+
// A leaf module, read synchronously as the pull projects (`site-project.js`), through the build's
|
|
18
|
+
// mode rule (`folderContentMode`) and its folder-mode page list (`folderModePageFiles`), so the set
|
|
19
|
+
// is the one the push walks.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import yaml from 'js-yaml'
|
|
24
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
25
|
+
import { isIgnoredFolder } from '../utils/content-files.js'
|
|
26
|
+
import { folderContentMode, folderModePageFiles, mountEntriesOf, parseNumericPrefix } from '../site/content-collector.js'
|
|
27
|
+
|
|
28
|
+
const DYNAMIC_RE = /^\[(.+)\]$/
|
|
29
|
+
|
|
30
|
+
// The collector's own reading of a config file (`parseYaml`): absent or unreadable is empty.
|
|
31
|
+
function readYaml(filePath) {
|
|
32
|
+
try {
|
|
33
|
+
return yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
|
|
34
|
+
} catch {
|
|
35
|
+
return {}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// A folder's mode, as `readFolderConfig` reads it.
|
|
40
|
+
function modeOf(dir, inheritedMode) {
|
|
41
|
+
const folderYml = readYaml(join(dir, 'folder.yml'))
|
|
42
|
+
const pageYml = Object.keys(folderYml).length > 0 ? {} : readYaml(join(dir, 'page.yml'))
|
|
43
|
+
return folderContentMode(folderYml, pageYml, existsSync(join(dir, 'folder.yml')), inheritedMode).mode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pages of a site a pull leaves alone, by route: each a slug path with no leading slash
|
|
48
|
+
* (`docs/start/install`), as the pull composes a page's route from the document.
|
|
49
|
+
*
|
|
50
|
+
* @param {object|null} siteYml - the parsed `site.yml`
|
|
51
|
+
* @param {string} pagesDir - the site's pages directory (`siteContentDirs`)
|
|
52
|
+
* @returns {{ mounted: Set<string>, files: Set<string>, has: (route: string) => boolean }}
|
|
53
|
+
* `mounted` — the mounted segments, each leaving its whole route alone; `files` — the routes of
|
|
54
|
+
* folder-mode `.md` pages; `has` — whether a route is either
|
|
55
|
+
*/
|
|
56
|
+
export function gitAuthoredPages(siteYml, pagesDir) {
|
|
57
|
+
const mounted = new Set(mountEntriesOf(siteYml?.paths).map(([segment]) => segment))
|
|
58
|
+
const files = new Set()
|
|
59
|
+
const walk = (dir, prefix, inheritedMode) => {
|
|
60
|
+
let entries
|
|
61
|
+
try {
|
|
62
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
63
|
+
} catch {
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
const mode = modeOf(dir, inheritedMode)
|
|
67
|
+
if (mode === 'pages') {
|
|
68
|
+
for (const { name } of folderModePageFiles(entries.map((e) => e.name)).files) {
|
|
69
|
+
files.add(prefix + name)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const e of entries) {
|
|
73
|
+
if (!e.isDirectory() || isIgnoredFolder(e.name)) continue
|
|
74
|
+
if (!prefix && mounted.has(e.name)) continue // the whole route is left alone
|
|
75
|
+
const dyn = e.name.match(DYNAMIC_RE)
|
|
76
|
+
walk(join(dir, e.name), `${prefix}${dyn ? dyn[1] : parseNumericPrefix(e.name).name}/`, mode)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (existsSync(pagesDir)) walk(pagesDir, '', 'sections')
|
|
80
|
+
return {
|
|
81
|
+
mounted,
|
|
82
|
+
files,
|
|
83
|
+
has: (route) => mounted.has(String(route).split('/')[0]) || files.has(route),
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -54,6 +54,7 @@ import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
|
|
|
54
54
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
55
55
|
import { documentSourceLocale } from './source-locale.js'
|
|
56
56
|
import { siteContentDirs } from './site-dirs.js'
|
|
57
|
+
import { gitAuthoredPages } from './git-authored.js'
|
|
57
58
|
import { layoutAreaRoute, readLayoutFolderSync, DEFAULT_LAYOUT } from '../site/layout-folder.js'
|
|
58
59
|
import { layoutNameKey } from '@uniweb/core/layout-name'
|
|
59
60
|
import { orderFolders, rootOrderConfig, parseNumericPrefix, parseWildcardArray, composeLocalizedRoute, stripAtPrefix, compareFilenames } from '../site/content-collector.js'
|
|
@@ -693,8 +694,13 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
|
|
|
693
694
|
if (sectionsArray && sectionsArray.length > 0) {
|
|
694
695
|
const authored = existing?.sections
|
|
695
696
|
if (Array.isArray(authored)) {
|
|
696
|
-
// Compared by the names the files are found by, so a list naming `1-hero` keeps naming it
|
|
697
|
-
|
|
697
|
+
// Compared by the names the files are found by, so a list naming `1-hero` keeps naming it —
|
|
698
|
+
// and with the author's `nest:` applied, as the build reads the two together: a list beside a
|
|
699
|
+
// `nest:` that gives the incoming nesting is kept, its `nest:` with it. ⛔ Until 2026-10-23 the
|
|
700
|
+
// list was compared alone, so a page that lists its sections and nests with `nest:` had both
|
|
701
|
+
// rewritten into one inclusive list on every pull.
|
|
702
|
+
const said = withNest(listedNames(authored.filter((e) => e !== '...')), existing?.nest)
|
|
703
|
+
const same = JSON.stringify(said) === JSON.stringify(sectionsArray)
|
|
698
704
|
y.sections = same ? authored : [...sectionsArray, '...']
|
|
699
705
|
} else if (filesGiveTopOrder && nestSays(existing?.nest, sectionsArray)) {
|
|
700
706
|
// Nothing to list: the files and `nest:` say it, kept below.
|
|
@@ -714,6 +720,17 @@ function pageRecordToYml(record, sectionsArray, sourceLocale, existing = null, {
|
|
|
714
720
|
return y
|
|
715
721
|
}
|
|
716
722
|
|
|
723
|
+
// A `sections:` list's entries with a page's `nest:` applied, as the build applies it after the list
|
|
724
|
+
// (`content-collector.js::processNesting`): a top-level parent `nest:` names takes the children it
|
|
725
|
+
// lists, in place of any the list gives it.
|
|
726
|
+
function withNest(list, nest) {
|
|
727
|
+
if (!isPlainRecord(nest)) return list
|
|
728
|
+
return list.map((entry) => {
|
|
729
|
+
const name = typeof entry === 'string' ? entry : isPlainRecord(entry) ? Object.keys(entry)[0] : null
|
|
730
|
+
return name && Array.isArray(nest[name]) ? { [name]: nest[name].map((c) => fileSectionName(String(c))) } : entry
|
|
731
|
+
})
|
|
732
|
+
}
|
|
733
|
+
|
|
717
734
|
// Whether a `sections:` list nests: an entry that is a parent with its children.
|
|
718
735
|
function nestsIn(list) {
|
|
719
736
|
return Array.isArray(list) && list.some((entry) => isPlainRecord(entry))
|
|
@@ -805,8 +822,17 @@ export function pageDirName(record, sourceLocale) {
|
|
|
805
822
|
// accumulates the slug-path route (for the free-form translation path; matches the
|
|
806
823
|
// producer's slugPath, normalizeRouteForPath strips any leading slash on both).
|
|
807
824
|
function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix = '', list = null, canonParent = '/') {
|
|
825
|
+
let leftAlone = false
|
|
808
826
|
for (const record of pages || []) {
|
|
809
827
|
const slug = unwrapLocalized(record.slug, sourceLocale) // localized {lang:value} → canonical
|
|
828
|
+
const route = routePrefix ? `${routePrefix}/${slug}` : slug
|
|
829
|
+
// ⭐ A PAGE AUTHORED IN A FILE THE PULL CANNOT WRITE BACK IS LEFT ALONE — a mount's, or a
|
|
830
|
+
// folder-mode `.md` page (`git-authored.js`). Its children too: a mount's are the mount's.
|
|
831
|
+
if (ctx.leave?.has(route)) {
|
|
832
|
+
report.leftAlone?.push(...subtreeRoutes(record, route, sourceLocale))
|
|
833
|
+
leftAlone = true
|
|
834
|
+
continue
|
|
835
|
+
}
|
|
810
836
|
// The route the build gives the page (`buildRouteOf`, the push's rule): an index page takes its
|
|
811
837
|
// parent's, a parametric one is `:<param>`.
|
|
812
838
|
const canon = buildRouteOf(record, canonParent, sourceLocale)
|
|
@@ -829,7 +855,6 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
|
|
|
829
855
|
if (Array.isArray(record.keywords)) record.keywords.forEach((kw) => ctx.collector?.add(kw, meta, text))
|
|
830
856
|
else ctx.collector?.add(record.keywords, meta, text)
|
|
831
857
|
|
|
832
|
-
const route = routePrefix ? `${routePrefix}/${slug}` : slug
|
|
833
858
|
// Addressed as the push addresses it: its free-form translations and its translations' contexts
|
|
834
859
|
// are keyed by the build's route. ⛔ Until 2026-09-26 this was the slug path — `home` for a
|
|
835
860
|
// homepage the build reads at `/`.
|
|
@@ -865,7 +890,18 @@ function writePagesTree(pages, pagesDir, sourceLocale, report, ctx, routePrefix
|
|
|
865
890
|
|
|
866
891
|
writePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route, parentOrderList(ymlPath), canon)
|
|
867
892
|
}
|
|
868
|
-
|
|
893
|
+
// A level holding a page left alone keeps the order its files give: reconciling it would write an
|
|
894
|
+
// `order:` into a page folder that does not exist, or rewrite a `pages:` list the author keeps.
|
|
895
|
+
if (!leftAlone) reconcileLevelOrder({ pages, pagesDir, sourceLocale, list, homepage: routePrefix ? null : ctx.homepage })
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// A page's route and each of its descendants', as `writePagesTree` composes them.
|
|
899
|
+
function subtreeRoutes(record, route, sourceLocale) {
|
|
900
|
+
const out = [route]
|
|
901
|
+
for (const child of record.$children || []) {
|
|
902
|
+
out.push(...subtreeRoutes(child, `${route}/${unwrapLocalized(child.slug, sourceLocale)}`, sourceLocale))
|
|
903
|
+
}
|
|
904
|
+
return out
|
|
869
905
|
}
|
|
870
906
|
|
|
871
907
|
// ⭐ A PAGE'S LOCALIZED URL SEGMENTS — the other locales of its `slug` (`localizeRouteSlugs`, the push) —
|
|
@@ -1010,19 +1046,27 @@ function collectSectionFileBases(pageSections) {
|
|
|
1010
1046
|
|
|
1011
1047
|
// Pass 2 — prune orphan section files (per page dir) and orphan page dirs (per
|
|
1012
1048
|
// level), AFTER every relocation in pass 1. Guarded against wiping an empty level.
|
|
1013
|
-
function prunePagesTree(pages, pagesDir, sourceLocale, report, ctx = null) {
|
|
1049
|
+
function prunePagesTree(pages, pagesDir, sourceLocale, report, ctx = null, routePrefix = '') {
|
|
1014
1050
|
const incomingDirs = new Set()
|
|
1015
1051
|
for (const record of pages || []) {
|
|
1016
1052
|
incomingDirs.add(pageDirName(record, sourceLocale))
|
|
1053
|
+
const slug = unwrapLocalized(record.slug, sourceLocale)
|
|
1054
|
+
const route = routePrefix ? `${routePrefix}/${slug}` : slug
|
|
1055
|
+
// Left alone by pass 1, so nothing of it is pruned either (`git-authored.js`).
|
|
1056
|
+
if (ctx?.leave?.has(route)) continue
|
|
1017
1057
|
const pageDir = join(pagesDir, pageDirName(record, sourceLocale))
|
|
1018
1058
|
if (record.mode === 'page') {
|
|
1019
1059
|
// The files pass 1 wrote into — the author's own names among them — else the names it would give.
|
|
1020
1060
|
const keep = ctx?.sectionFiles?.get(pageDir) || collectSectionFileBases(record.page_sections)
|
|
1021
1061
|
if (keep.size > 0) pruneOrphanSectionFiles(pageDir, keep, report)
|
|
1022
1062
|
}
|
|
1023
|
-
prunePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx)
|
|
1063
|
+
prunePagesTree(record.$children || [], pageDir, sourceLocale, report, ctx, route)
|
|
1024
1064
|
}
|
|
1025
|
-
if (incomingDirs.size
|
|
1065
|
+
if (incomingDirs.size === 0) return
|
|
1066
|
+
// A mounted route's local folder is never an orphan, whatever the document holds: what it
|
|
1067
|
+
// presents is another repository's (`git-authored.js`).
|
|
1068
|
+
if (!routePrefix) for (const segment of ctx?.leave?.mounted ?? []) incomingDirs.add(segment)
|
|
1069
|
+
pruneOrphanPageDirs(pagesDir, incomingDirs, report)
|
|
1026
1070
|
}
|
|
1027
1071
|
|
|
1028
1072
|
/**
|
|
@@ -1291,7 +1335,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1291
1335
|
// Before anything is written: a pull that would leave content out of the files stops here.
|
|
1292
1336
|
refuseContentFilesCannotHold(document, sourceLocale)
|
|
1293
1337
|
|
|
1294
|
-
const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
|
|
1338
|
+
const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [], leftAlone: [] }
|
|
1295
1339
|
|
|
1296
1340
|
// Collects target-locale translations of localized scalars as they're projected;
|
|
1297
1341
|
// flushed to locales/{locale}.json at the end (the manifest stays derivable).
|
|
@@ -1327,7 +1371,11 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
1327
1371
|
// Where the tree goes is what `site.yml` says NOW — `siteInfoToConfig` above has
|
|
1328
1372
|
// written the document's `settings.paths` into it — because that is the file the
|
|
1329
1373
|
// next push and the build read. See `siteContentDirs`.
|
|
1330
|
-
const
|
|
1374
|
+
const siteYml = readAuthoredYaml(join(siteRoot, 'site.yml'))
|
|
1375
|
+
const { pagesDir, layoutDir: layoutBaseDir } = siteContentDirs(siteRoot, siteYml)
|
|
1376
|
+
// ⭐ The pages authored in files a pull cannot write back — a mount's, a folder-mode folder's
|
|
1377
|
+
// `.md` pages — are neither written nor pruned (`git-authored.js`).
|
|
1378
|
+
ctx.leave = gitAuthoredPages(siteYml, pagesDir)
|
|
1331
1379
|
// The root page the backend marks as the homepage, by the directory it is written to.
|
|
1332
1380
|
const home = (document?.pages || []).find((p) => p?.is_index)
|
|
1333
1381
|
ctx.homepage = home ? pageDirName(home, sourceLocale) : null
|
package/src/uwx/site.js
CHANGED
|
@@ -40,9 +40,10 @@
|
|
|
40
40
|
// they ride under their parent section's `$children` (page_sections is
|
|
41
41
|
// self_nesting), via the same `processNesting` the normal build uses.
|
|
42
42
|
//
|
|
43
|
-
// v0 scope (stated, not silent):
|
|
44
|
-
//
|
|
45
|
-
//
|
|
43
|
+
// v0 scope (stated, not silent): versioned scopes, and media/asset bytes
|
|
44
|
+
// (favicon/assets — carried out of band). Folder-mode `.md`-as-pages and
|
|
45
|
+
// `paths: { pages/<segment> }` mounts are carried since 2026-10-23, read by the
|
|
46
|
+
// build's own rules; a pull leaves those pages alone (`git-authored.js`).
|
|
46
47
|
|
|
47
48
|
import { readdir, readFile } from 'node:fs/promises'
|
|
48
49
|
import { dataBlockModels } from '../i18n/data-models.js'
|
|
@@ -59,11 +60,14 @@ import {
|
|
|
59
60
|
compareFilenames,
|
|
60
61
|
orderFolders,
|
|
61
62
|
processMarkdownFile,
|
|
63
|
+
processFileAsPage,
|
|
62
64
|
declaredFetch,
|
|
63
65
|
checkDeclaration,
|
|
64
66
|
fetchFromQueryShorthand,
|
|
65
67
|
assertRouteFolder,
|
|
66
|
-
|
|
68
|
+
resolveMounts,
|
|
69
|
+
layerMountedFolder,
|
|
70
|
+
folderModePageFiles,
|
|
67
71
|
rootOrderConfig,
|
|
68
72
|
composeLocalizedRoute,
|
|
69
73
|
isValidSlugSegment,
|
|
@@ -427,24 +431,37 @@ function buildPageData(config, ctx) {
|
|
|
427
431
|
return data
|
|
428
432
|
}
|
|
429
433
|
|
|
430
|
-
|
|
434
|
+
// A level's child folders, each with the config and mode the build reads it with. At the root,
|
|
435
|
+
// `mounts` adds every `paths: { pages/<segment>: … }` route, shaped by the build's own rule
|
|
436
|
+
// (`layerMountedFolder`): the mount's config under the local folder's, the mode the mount gives
|
|
437
|
+
// unless the local folder declares one, and the children read from the mount. ⛔ Until 2026-10-23
|
|
438
|
+
// this lane read no mount, so a mounted route was pushed as an empty container (a local stub) or
|
|
439
|
+
// not at all (none) — measured 2026-09-18 on a documentation site: 49 pages built, 2 pushed.
|
|
440
|
+
async function orderedSubfolders(dirPath, inheritedMode, parentConfig, mounts = null) {
|
|
431
441
|
const entries = await readdir(dirPath, { withFileTypes: true })
|
|
432
|
-
const
|
|
442
|
+
const local = new Map()
|
|
433
443
|
for (const e of entries) {
|
|
434
|
-
if (
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
)
|
|
444
|
+
if (e.isDirectory() && !isIgnoredFolder(e.name)) local.set(e.name, join(dirPath, e.name))
|
|
445
|
+
}
|
|
446
|
+
const folders = []
|
|
447
|
+
for (const dirName of new Set([...local.keys(), ...(mounts?.keys() ?? [])])) {
|
|
448
|
+
const localPath = local.get(dirName) ?? null
|
|
449
|
+
const mountPath = mounts?.get(dirName) ?? null
|
|
450
|
+
const localConfig = localPath ? await readFolderConfig(localPath, inheritedMode) : null
|
|
451
|
+
const mountedConfig = mountPath ? await readFolderConfig(mountPath, 'pages') : null
|
|
452
|
+
const { dirConfig, dirMode, childMode } = layerMountedFolder(dirName, localConfig, mountedConfig, inheritedMode)
|
|
440
453
|
folders.push({
|
|
441
|
-
dirName
|
|
442
|
-
name: parseNumericPrefix(
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
454
|
+
dirName,
|
|
455
|
+
name: parseNumericPrefix(dirName).name,
|
|
456
|
+
// Where the page's own sections are — the local folder, as the build reads them — and
|
|
457
|
+
// where its child pages are: the mount's tree, when it has one.
|
|
458
|
+
path: localPath ?? mountPath,
|
|
459
|
+
childrenPath: mountPath ?? localPath,
|
|
460
|
+
config: dirConfig,
|
|
461
|
+
internalMode: dirMode,
|
|
462
|
+
childMode,
|
|
463
|
+
source: (localConfig ?? mountedConfig).source,
|
|
464
|
+
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined,
|
|
448
465
|
})
|
|
449
466
|
}
|
|
450
467
|
// Explicit order, then numeric-prefix filename order, then the parent's `pages:` wildcard —
|
|
@@ -487,9 +504,10 @@ const CATCH_ALL_MARKER = '...path'
|
|
|
487
504
|
// `multi` section is read as new (inserted, stored counterpart deleted). See
|
|
488
505
|
// the IDENTITY note in the file header and `site-diff.js`.
|
|
489
506
|
//
|
|
490
|
-
// v0 deferrals:
|
|
491
|
-
//
|
|
492
|
-
//
|
|
507
|
+
// v0 deferrals: versioned scopes, and media/asset bytes (favicon/assets, carried
|
|
508
|
+
// out of band). `@`-prefix `nest:` hierarchy, folder-mode `.md`-as-pages and
|
|
509
|
+
// `paths: { pages/<segment> }` mounts are NOT deferred — each is read here by the
|
|
510
|
+
// build's own rule (folder-mode pages and mounts since 2026-10-23).
|
|
493
511
|
// ===========================================================================
|
|
494
512
|
|
|
495
513
|
const SITE_MODEL_NAME = '@uniweb/site-content'
|
|
@@ -573,7 +591,7 @@ function isExplicitWithRest(sectionsConfig) {
|
|
|
573
591
|
// a section name (string) or `{ name: [children…] }`, resolved to its file by
|
|
574
592
|
// name (bare / `@` / numeric-prefix tolerant) and recursed. Order and nesting
|
|
575
593
|
// come from the array, not the directory.
|
|
576
|
-
async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, { appendRest = false } = {}) {
|
|
594
|
+
async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, { appendRest = false, nest = null } = {}) {
|
|
577
595
|
const mdFiles = (await readdir(pageDir)).filter(isMarkdownFile).sort(compareFilenames)
|
|
578
596
|
const seen = new Set()
|
|
579
597
|
|
|
@@ -615,15 +633,37 @@ async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, {
|
|
|
615
633
|
sections.push(section)
|
|
616
634
|
}
|
|
617
635
|
}
|
|
636
|
+
|
|
637
|
+
// ⭐ `nest:` OVER THE LIST, as the build applies it after one (`processNesting`,
|
|
638
|
+
// `site/content-collector.js`): each top-level parent it names gets the children it lists, in
|
|
639
|
+
// that order, replacing any the list nested — "`nest:` wins" (`docs/reference/page-configuration.md`).
|
|
640
|
+
// ⛔ Until 2026-10-23 an explicit list ignored `nest:` here, so a page listing its sections and
|
|
641
|
+
// nesting a figure under each pushed none of the figures, which the build renders — and a pull
|
|
642
|
+
// then deleted their `@` files, absent from the document.
|
|
643
|
+
if (nest && typeof nest === 'object') {
|
|
644
|
+
for (const [parentName, childNames] of Object.entries(nest)) {
|
|
645
|
+
if (!Array.isArray(childNames)) continue
|
|
646
|
+
const parent = sections.find((s) => s.stableId === parentName)
|
|
647
|
+
if (!parent) continue
|
|
648
|
+
parent.subsections = []
|
|
649
|
+
for (const childName of childNames) {
|
|
650
|
+
const file = findSectionFileName(mdFiles, childName)
|
|
651
|
+
if (!file) continue
|
|
652
|
+
const { section } = await processMarkdownFile(join(pageDir, file), String(parent.subsections.length + 1), siteRoot, childName)
|
|
653
|
+
section.subsections = []
|
|
654
|
+
parent.subsections.push(section)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
618
658
|
return sections.map((s, i) => sectionToRecord(s, i))
|
|
619
659
|
}
|
|
620
660
|
|
|
621
661
|
async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
|
|
622
662
|
if (isFullyExplicitSections(pageConfig?.sections)) {
|
|
623
|
-
return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections)
|
|
663
|
+
return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { nest: pageConfig.nest })
|
|
624
664
|
}
|
|
625
665
|
if (isExplicitWithRest(pageConfig?.sections)) {
|
|
626
|
-
return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { appendRest: true })
|
|
666
|
+
return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { appendRest: true, nest: pageConfig.nest })
|
|
627
667
|
}
|
|
628
668
|
const mdFiles = (await readdir(pageDir)).filter(isMarkdownFile).sort(compareFilenames)
|
|
629
669
|
const nest = pageConfig?.nest && typeof pageConfig.nest === 'object' ? pageConfig.nest : {}
|
|
@@ -678,56 +718,45 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
|
|
|
678
718
|
return sections.map((s, i) => sectionToRecord(s, i))
|
|
679
719
|
}
|
|
680
720
|
|
|
681
|
-
// A folder-mode folder's `.md` files
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
721
|
+
// A folder-mode folder's `.md` files, as the pages the build makes of them (`processFileAsPage`):
|
|
722
|
+
// each a page whose one section is the file, titled by its first heading, a draft when the file
|
|
723
|
+
// says `hidden: true`, slugged by the filename without its numeric prefix — and in the build's
|
|
724
|
+
// order (`folderModePageFiles`). ⛔ Until 2026-10-23 this lane sent none of them and warned instead;
|
|
725
|
+
// a documentation site — folder-mode pages in a mounted repo — published 2 of its 49 pages.
|
|
726
|
+
async function folderModePageRecords(ctx, dirPath, orderConfig, isRoot) {
|
|
727
|
+
const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
|
|
728
|
+
const { files } = folderModePageFiles(await readdir(dirPath), orderConfig)
|
|
729
|
+
const records = []
|
|
730
|
+
for (const { file, name } of files) {
|
|
731
|
+
const filePath = join(dirPath, file)
|
|
732
|
+
const { page } = await processFileAsPage(filePath, file, siteRoot, '/')
|
|
733
|
+
const config = { title: page.title }
|
|
734
|
+
if (page.hidden) config.hidden = true
|
|
735
|
+
const data = buildPageData(config, {
|
|
736
|
+
where: relative(siteRoot, filePath),
|
|
737
|
+
slug: name,
|
|
738
|
+
mode: 'page',
|
|
739
|
+
isDynamic: false,
|
|
740
|
+
isRoot,
|
|
741
|
+
siteIndex,
|
|
742
|
+
sourceLocale,
|
|
743
|
+
translations,
|
|
744
|
+
pageFields,
|
|
745
|
+
})
|
|
746
|
+
const record = withIdentity(data.stable_id || name, data)
|
|
747
|
+
record.page_sections = page.sections.map((s, i) => sectionToRecord(s, i))
|
|
748
|
+
records.push(record)
|
|
695
749
|
}
|
|
696
|
-
|
|
697
|
-
const where = relative(siteRoot, dirPath)
|
|
698
|
-
const why = source === 'inherited'
|
|
699
|
-
? '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)'
|
|
700
|
-
: 'it is a folder-mode folder (`folder.yml`)'
|
|
701
|
-
console.warn(
|
|
702
|
-
`uwx/site: ${files.length} page(s) in \`${where}\` will NOT be pushed — ` +
|
|
703
|
-
`${why}, and pushing \`.md\`-as-pages ` +
|
|
704
|
-
`is not implemented on the sync lane. A build renders them; the backend ` +
|
|
705
|
-
`will not have them.\n` +
|
|
706
|
-
files.map((f) => ` - ${where}/${f}`).join('\n') +
|
|
707
|
-
`\n To publish them, make each one a page-mode directory ` +
|
|
708
|
-
`(\`<name>/page.yml\` + its section files). To ship them as they are, ` +
|
|
709
|
-
`use \`uniweb export\` or \`uniweb deploy --host <adapter>\`.` +
|
|
710
|
-
REPUBLISH_CLAUSE
|
|
711
|
-
)
|
|
750
|
+
return records
|
|
712
751
|
}
|
|
713
752
|
|
|
714
|
-
// A push sends the page tree as ONE entity (see site-diff.js), so a republish does
|
|
715
|
-
// not merge: the shorter document becomes the whole tree. Measured by the backend
|
|
716
|
-
// lane 2026-09-18 against a current `uniwebd` — 49 stored pages replaced by 2, the
|
|
717
|
-
// publish reporting success. Both skip warnings carry this, because "will not be
|
|
718
|
-
// pushed" reads as "you publish short" when the real cost on an already-published
|
|
719
|
-
// site is that live pages are REMOVED.
|
|
720
|
-
const REPUBLISH_CLAUSE =
|
|
721
|
-
`\n ⚠ If this site has been published before, a push REPLACES its whole page ` +
|
|
722
|
-
`tree — pages already live that are missing here are REMOVED, and the publish ` +
|
|
723
|
-
`still reports success.`
|
|
724
|
-
|
|
725
753
|
// Recursively build the `pages` tree: each record carries its fields, its inline
|
|
726
|
-
// `page_sections` (page mode only), and its child pages under `$children`.
|
|
727
|
-
|
|
754
|
+
// `page_sections` (page mode only), and its child pages under `$children`. A folder-mode level's
|
|
755
|
+
// own `.md` pages come first, then its folders, as the build lists them.
|
|
756
|
+
async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, parentConfig, isRoot, mounts = null) {
|
|
728
757
|
const { siteRoot, siteIndex, sourceLocale, translations, pageFields } = ctx
|
|
729
|
-
const
|
|
730
|
-
const
|
|
758
|
+
const out = inheritedMode === 'pages' ? await folderModePageRecords(ctx, dirPath, parentConfig, isRoot) : []
|
|
759
|
+
const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig, mounts)
|
|
731
760
|
// A folder inside a `[...path]` folder can never be reached, and `[dir]` /
|
|
732
761
|
// `[path]` would name a route variable — refused here as the collector refuses
|
|
733
762
|
// them, so a site that cannot build cannot sync either (ruled 2026-09-11).
|
|
@@ -775,26 +804,13 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
|
|
|
775
804
|
record.slug = { ...record.slug, [sourceLocale]: f.config.slug }
|
|
776
805
|
}
|
|
777
806
|
|
|
807
|
+
// A folder-mode folder has no sections: its `.md` files are pages, sent among its children.
|
|
778
808
|
if (mode === 'page') {
|
|
779
809
|
const sections = await collectPageSectionsNested(f.path, siteRoot, f.config)
|
|
780
810
|
if (sections.length > 0) record.page_sections = sections
|
|
781
|
-
} else {
|
|
782
|
-
// ⛔ FOLDER MODE: the build makes each `.md` file here a PAGE; this lane
|
|
783
|
-
// makes it nothing. It is a v0 mapper gap (bidirectional-sync.md §"Other
|
|
784
|
-
// v0 mapper gaps"), and until it closes the ONLY thing standing between an
|
|
785
|
-
// author and a site that publishes a fraction of itself is this warning.
|
|
786
|
-
//
|
|
787
|
-
// ⭐ It has to be here, not in `status` or `push`. Those compare the built
|
|
788
|
-
// document against what was last sent, so with the pages already absent
|
|
789
|
-
// from the document both correctly report "Synced" / "Nothing to push".
|
|
790
|
-
// The loss happens upstream of every diff, which is why it was invisible:
|
|
791
|
-
// measured 2026-09-18, a 49-page documentation site published 2 pages with
|
|
792
|
-
// every CLI instrument reporting success [measured:backend, channel with
|
|
793
|
-
// backend, and reproduced here against the same site].
|
|
794
|
-
await warnFolderModePagesSkipped(f.path, siteRoot, f.source)
|
|
795
811
|
}
|
|
796
812
|
|
|
797
|
-
const children = await walkPagesNested(ctx, f.
|
|
813
|
+
const children = await walkPagesNested(ctx, f.childrenPath, slugPath, f.childMode, f.config, false)
|
|
798
814
|
if (children.length > 0) record.$children = children
|
|
799
815
|
|
|
800
816
|
out.push(record)
|
|
@@ -1557,50 +1573,25 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1557
1573
|
// `siteIndex` — the homepage — joins below, once the pages directory's own config is read.
|
|
1558
1574
|
// …and the keys this deployment's page takes (`siteContent.pageFields`), for its Open Graph texts.
|
|
1559
1575
|
const ctx = { siteRoot, sourceLocale, translations, pageFields: opts.pageFields }
|
|
1560
|
-
// ⛔ ONE `paths:` CONVENTION, TWO READERS, AND THIS ONE IS A STRICT SUBSET.
|
|
1561
|
-
// The build honours sub-mounts — `paths: { pages/<segment>: <dir> }`, through
|
|
1562
|
-
// `resolveMounts`/`mountEntriesOf` in `site/content-collector.js` — and this
|
|
1563
|
-
// lane reads `paths.pages` alone. A sub-mounted folder therefore syncs as an
|
|
1564
|
-
// EMPTY container: its directory on disk holds only the `folder.yml`, so it
|
|
1565
|
-
// emits with no children and nothing anywhere says so. Measured 2026-09-18 on
|
|
1566
|
-
// a site whose entire corpus was one sub-mount: 49 pages built, 2 pushed.
|
|
1567
|
-
// Deferred by design for now (bidirectional-sync.md); warned about here so it
|
|
1568
|
-
// is not also silent.
|
|
1569
|
-
//
|
|
1570
1576
|
// The same two directories the pull writes back to — see `siteContentDirs`.
|
|
1571
1577
|
const { pagesDir: pagesPath, layoutDir } = siteContentDirs(siteRoot, siteYml)
|
|
1572
|
-
for (const [segment] of mountEntriesOf(siteYml.paths)) {
|
|
1573
|
-
// Only a LOCAL stub (`pages/<segment>/`, the folder that gives a mounted route its
|
|
1574
|
-
// layout) is walked, so the route arrives as an empty container; with no stub,
|
|
1575
|
-
// nothing here stands for the route and it is not sent at all. ⚠️ The message
|
|
1576
|
-
// said "empty container" in both cases until 2026-09-19.
|
|
1577
|
-
const fate = existsSync(join(pagesPath, segment))
|
|
1578
|
-
? 'will be pushed as an empty container'
|
|
1579
|
-
: 'will not be pushed at all'
|
|
1580
|
-
console.warn(
|
|
1581
|
-
`uwx/site: \`paths: { pages/${segment}: … }\` is NOT read on the sync lane — ` +
|
|
1582
|
-
`\`${segment}\` ${fate} and none of the mounted ` +
|
|
1583
|
-
`pages will reach the backend. A build mounts it normally. Inline the ` +
|
|
1584
|
-
`directory under \`pages/${segment}/\` to push it, or ship the site with ` +
|
|
1585
|
-
`\`uniweb export\` / \`uniweb deploy --host <adapter>\`.` +
|
|
1586
|
-
REPUBLISH_CLAUSE
|
|
1587
|
-
)
|
|
1588
|
-
}
|
|
1589
1578
|
let pages = []
|
|
1590
1579
|
if (existsSync(pagesPath)) {
|
|
1591
|
-
//
|
|
1592
|
-
// `
|
|
1593
|
-
//
|
|
1594
|
-
//
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1580
|
+
// ⭐ SUB-MOUNTS, AS THE BUILD READS THEM — `paths: { pages/<segment>: <dir> }`, through its own
|
|
1581
|
+
// `resolveMounts`, and STRICT as a production build is: a mount with nothing in it (a git
|
|
1582
|
+
// submodule never fetched) stops the push. A push replaces the site's whole page tree, so a
|
|
1583
|
+
// mount read as empty would remove every page it holds from a published site.
|
|
1584
|
+
const mounts = resolveMounts(siteYml.paths, siteRoot, pagesPath, { strict: true })
|
|
1585
|
+
// The root has a mode too, read as the build reads it (`rootContentMode`): `pages/folder.yml`
|
|
1586
|
+
// puts the whole site in folder mode, its `.md` files pages and every folder with no config of
|
|
1587
|
+
// its own in folder mode too.
|
|
1588
|
+
const { mode: rootMode, config: rootConfig } = await readFolderConfig(pagesPath, 'sections')
|
|
1598
1589
|
// The top level's order and homepage, decided by the build's own function: the
|
|
1599
1590
|
// site's `pages:` / `index:` over the pages directory's own. ⛔ The homepage read
|
|
1600
1591
|
// `index:` alone until 2026-09-19, so a site naming it by its first `pages:` entry
|
|
1601
1592
|
// — as the build does — pushed no homepage at all.
|
|
1602
1593
|
const top = rootOrderConfig({ pages: siteYml.pages, index: siteYml.index }, rootConfig)
|
|
1603
|
-
pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true)
|
|
1594
|
+
pages = await walkPagesNested({ ...ctx, siteIndex: top.homepage }, pagesPath, '', rootMode, { pages: top.pages }, true, mounts)
|
|
1604
1595
|
localizeRouteSlugs(pages, siteYml, sourceLocale)
|
|
1605
1596
|
}
|
|
1606
1597
|
|