@uniweb/build 0.6.17 → 0.6.19

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.6.17",
3
+ "version": "0.6.19",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,9 +50,9 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/schemas": "0.2.1",
54
53
  "@uniweb/content-reader": "1.1.2",
55
- "@uniweb/runtime": "0.5.21"
54
+ "@uniweb/runtime": "0.5.21",
55
+ "@uniweb/schemas": "0.2.1"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/i18n/index.js CHANGED
@@ -169,21 +169,17 @@ async function resolveLocales(configLocales, localesPath) {
169
169
  /**
170
170
  * Extract manifest from site content and write to file
171
171
  * @param {string} siteRoot - Site root directory
172
+ * @param {Object} siteContent - Collected site content (from collectSiteContent)
172
173
  * @param {Object} options - Options
173
174
  * @returns {Object} { manifest, report }
174
175
  */
175
- export async function extractManifest(siteRoot, options = {}) {
176
+ export async function extractManifest(siteRoot, siteContent, options = {}) {
176
177
  const {
177
178
  localesDir = DEFAULTS.localesDir,
178
- siteContentPath = join(siteRoot, 'dist', 'site-content.json'),
179
179
  verbose = false,
180
180
  dryRun = false
181
181
  } = options
182
182
 
183
- // Load site content
184
- const siteContentRaw = await readFile(siteContentPath, 'utf-8')
185
- const siteContent = JSON.parse(siteContentRaw)
186
-
187
183
  // Extract translatable content
188
184
  const manifest = extractTranslatableContent(siteContent)
189
185
 
@@ -24,8 +24,8 @@
24
24
  */
25
25
 
26
26
  import { readFile, readdir, stat } from 'node:fs/promises'
27
- import { join, parse, resolve } from 'node:path'
28
- import { existsSync } from 'node:fs'
27
+ import { join, parse, resolve, sep } from 'node:path'
28
+ import { existsSync, statSync, realpathSync } from 'node:fs'
29
29
  import yaml from 'js-yaml'
30
30
  import { collectSectionAssets, mergeAssetCollections } from './assets.js'
31
31
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
@@ -229,6 +229,102 @@ async function readFolderConfig(dirPath, inheritedMode) {
229
229
  return { config: {}, mode: inheritedMode, source: 'inherited' }
230
230
  }
231
231
 
232
+ /**
233
+ * Extract page mounts from site.yml paths: config.
234
+ *
235
+ * Keys like `pages/docs: ../../../docs` map a route segment to an external
236
+ * directory. All validation happens upfront before any page collection begins.
237
+ *
238
+ * @param {Object} pathsConfig - The paths: object from site.yml
239
+ * @param {string} sitePath - Absolute path to the site directory
240
+ * @param {string} pagesPath - Resolved absolute path to the pages directory
241
+ * @returns {Map<string, string>|null} Route segment → canonical absolute path, or null
242
+ */
243
+ function resolveMounts(pathsConfig, sitePath, pagesPath) {
244
+ if (!pathsConfig || typeof pathsConfig !== 'object') return null
245
+
246
+ // Extract entries with "pages/" prefix (e.g., "pages/docs": "../../../docs")
247
+ const mountEntries = Object.entries(pathsConfig)
248
+ .filter(([key]) => key.startsWith('pages/'))
249
+ .map(([key, value]) => [key.slice('pages/'.length), value])
250
+
251
+ if (mountEntries.length === 0) return null
252
+
253
+ const resolved = new Map()
254
+ const canonicalPagesPath = existsSync(pagesPath) ? realpathSync(pagesPath) : resolve(pagesPath)
255
+
256
+ for (const [routeSegment, relativePath] of mountEntries) {
257
+ // Validate route segment (simple name, no slashes, no special chars)
258
+ if (!routeSegment || routeSegment.includes('/') || routeSegment.startsWith('.') || routeSegment.startsWith('_')) {
259
+ throw new Error(
260
+ `[content-collector] Invalid mount "pages/${routeSegment}" in site.yml paths.\n` +
261
+ ` The segment after "pages/" must be a simple name (no slashes, dots, or underscores prefix).`
262
+ )
263
+ }
264
+
265
+ const absolutePath = resolve(sitePath, relativePath)
266
+
267
+ // Check existence
268
+ if (!existsSync(absolutePath)) {
269
+ throw new Error(
270
+ `[content-collector] External pages path does not exist: ${absolutePath}\n` +
271
+ ` Declared in site.yml: pages/${routeSegment}: ${relativePath}`
272
+ )
273
+ }
274
+
275
+ // Check it's a directory
276
+ if (!statSync(absolutePath).isDirectory()) {
277
+ throw new Error(
278
+ `[content-collector] External pages path is not a directory: ${absolutePath}\n` +
279
+ ` Declared in site.yml: pages/${routeSegment}: ${relativePath}`
280
+ )
281
+ }
282
+
283
+ const canonical = realpathSync(absolutePath)
284
+
285
+ // Reject node_modules
286
+ if (canonical.includes(`${sep}node_modules${sep}`)) {
287
+ throw new Error(
288
+ `[content-collector] External pages path must not be inside node_modules: ${canonical}\n` +
289
+ ` Declared in site.yml: pages/${routeSegment}: ${relativePath}`
290
+ )
291
+ }
292
+
293
+ // Self-inclusion: must not overlap with site pages directory
294
+ if (
295
+ canonical === canonicalPagesPath ||
296
+ canonical.startsWith(canonicalPagesPath + sep) ||
297
+ canonicalPagesPath.startsWith(canonical + sep)
298
+ ) {
299
+ throw new Error(
300
+ `[content-collector] External pages path overlaps with site pages directory:\n` +
301
+ ` Path: ${canonical}\n` +
302
+ ` Site pages: ${canonicalPagesPath}\n` +
303
+ ` Declared in site.yml: pages/${routeSegment}`
304
+ )
305
+ }
306
+
307
+ // Cross-mount overlap: no mount target should be ancestor/descendant of another
308
+ for (const [otherKey, otherPath] of resolved) {
309
+ if (
310
+ canonical === otherPath ||
311
+ canonical.startsWith(otherPath + sep) ||
312
+ otherPath.startsWith(canonical + sep)
313
+ ) {
314
+ throw new Error(
315
+ `[content-collector] External pages paths overlap:\n` +
316
+ ` "pages/${routeSegment}" → ${canonical}\n` +
317
+ ` "pages/${otherKey}" → ${otherPath}`
318
+ )
319
+ }
320
+ }
321
+
322
+ resolved.set(routeSegment, canonical)
323
+ }
324
+
325
+ return resolved.size > 0 ? resolved : null
326
+ }
327
+
232
328
  /**
233
329
  * Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
234
330
  * Supports:
@@ -271,23 +367,130 @@ function compareFilenames(a, b) {
271
367
  }
272
368
 
273
369
  /**
274
- * Apply non-strict ordering to a list of items.
275
- * Listed items appear first in array order, then unlisted items in their existing order.
370
+ * Extract the name from a config array item.
371
+ * Handles both string entries ("hero") and object entries ({ features: [...] }).
372
+ * @param {*} item - Array item from sections: or pages: config
373
+ * @returns {string|null} The name, or null if not a valid entry
374
+ */
375
+ function extractItemName(item) {
376
+ if (typeof item === 'string') return item
377
+ if (typeof item === 'object' && item !== null) {
378
+ const keys = Object.keys(item)
379
+ if (keys.length === 1) return keys[0]
380
+ }
381
+ return null
382
+ }
383
+
384
+ /**
385
+ * Parse a config array that may contain '...' rest markers.
386
+ *
387
+ * Returns structured info:
388
+ * - mode 'strict': no '...' — only listed items visible in navigation
389
+ * - mode 'inclusive': '...' present — pinned items + auto-discovered rest
390
+ * - mode 'all': array is just ['...'] — equivalent to omitting config
391
+ *
392
+ * @param {Array} arr - Config array (may contain '...' strings and/or objects)
393
+ * @returns {{ mode: 'strict'|'inclusive'|'all', before: Array, after: Array }|null}
394
+ */
395
+ function parseWildcardArray(arr) {
396
+ if (!Array.isArray(arr) || arr.length === 0) return null
397
+
398
+ const firstRestIndex = arr.indexOf('...')
399
+ if (firstRestIndex === -1) {
400
+ return { mode: 'strict', before: [...arr], after: [] }
401
+ }
402
+
403
+ // Find last '...' index
404
+ let lastRestIndex = firstRestIndex
405
+ for (let i = arr.length - 1; i >= 0; i--) {
406
+ if (arr[i] === '...') { lastRestIndex = i; break }
407
+ }
408
+
409
+ const before = arr.slice(0, firstRestIndex).filter(x => x !== '...')
410
+ const after = arr.slice(lastRestIndex + 1).filter(x => x !== '...')
411
+
412
+ if (before.length === 0 && after.length === 0) {
413
+ return { mode: 'all', before: [], after: [] }
414
+ }
415
+
416
+ return { mode: 'inclusive', before, after }
417
+ }
418
+
419
+ /**
420
+ * Apply wildcard-aware ordering to a list of named items.
276
421
  *
277
- * Unlike strict arrays (pages: [...], sections: [...]) which hide unlisted items,
278
- * this preserves all items it only affects order.
422
+ * - strict: listed items first in listed order, then unlisted (all items returned)
423
+ * - inclusive: before items, then rest (in existing order), then after items
424
+ * - all/null: return items unchanged
279
425
  *
280
426
  * @param {Array} items - Items with a .name property
281
- * @param {Array<string>} orderArray - Names in desired order
282
- * @returns {Array} Reordered items (all items preserved)
427
+ * @param {{ mode: string, before: Array, after: Array }|null} parsed - From parseWildcardArray
428
+ * @returns {Array} Reordered items
429
+ */
430
+ function applyWildcardOrder(items, parsed) {
431
+ if (!parsed || parsed.mode === 'all') return items
432
+
433
+ const itemMap = new Map(items.map(i => [i.name, i]))
434
+ const beforeNames = parsed.before.map(extractItemName).filter(Boolean)
435
+ const afterNames = parsed.after.map(extractItemName).filter(Boolean)
436
+ const allPinnedNames = new Set([...beforeNames, ...afterNames])
437
+
438
+ const beforeItems = beforeNames.filter(n => itemMap.has(n)).map(n => itemMap.get(n))
439
+ const afterItems = afterNames.filter(n => itemMap.has(n)).map(n => itemMap.get(n))
440
+ const rest = items.filter(i => !allPinnedNames.has(i.name))
441
+
442
+ if (parsed.mode === 'strict') {
443
+ // Listed items first, then unlisted (hiding is applied separately)
444
+ return [...beforeItems, ...rest]
445
+ }
446
+
447
+ // Inclusive: before + rest + after
448
+ return [...beforeItems, ...rest, ...afterItems]
449
+ }
450
+
451
+ /**
452
+ * Find the markdown file for a section name, handling numeric prefixes.
453
+ * Tries exact match first ("hero.md"), then prefix-based ("1-hero.md").
454
+ *
455
+ * @param {string} pagePath - Directory containing section files
456
+ * @param {string} sectionName - Logical section name (e.g., 'hero')
457
+ * @param {string[]} [cachedFiles] - Pre-read directory listing (optimization)
458
+ * @returns {{ filePath: string, stableName: string, prefix: string|null }|null}
459
+ */
460
+ function findSectionFile(pagePath, sectionName, cachedFiles) {
461
+ const exactPath = join(pagePath, `${sectionName}.md`)
462
+ if (existsSync(exactPath)) {
463
+ return { filePath: exactPath, stableName: sectionName, prefix: null }
464
+ }
465
+
466
+ const files = cachedFiles || []
467
+ for (const file of files) {
468
+ if (!isMarkdownFile(file)) continue
469
+ const { name } = parse(file)
470
+ const { prefix, name: parsedName } = parseNumericPrefix(name)
471
+ if (parsedName === sectionName) {
472
+ return { filePath: join(pagePath, file), stableName: sectionName, prefix }
473
+ }
474
+ }
475
+
476
+ return null
477
+ }
478
+
479
+ /**
480
+ * Extract a direct child's folder name from its route, relative to parentRoute.
481
+ * Returns null for the index page (route === parentRoute) or non-direct-children.
482
+ *
483
+ * @param {string} route - Page route (e.g., '/about')
484
+ * @param {string} parentRoute - Parent route (e.g., '/')
485
+ * @returns {string|null}
283
486
  */
284
- function applyNonStrictOrder(items, orderArray) {
285
- if (!Array.isArray(orderArray) || orderArray.length === 0) return items
286
- const orderMap = new Map(orderArray.map((name, i) => [name, i]))
287
- const listed = items.filter(i => orderMap.has(i.name))
288
- .sort((a, b) => orderMap.get(a.name) - orderMap.get(b.name))
289
- const unlisted = items.filter(i => !orderMap.has(i.name))
290
- return [...listed, ...unlisted]
487
+ function getDirectChildName(route, parentRoute) {
488
+ if (!route || route === parentRoute) return null
489
+ const prefix = parentRoute === '/' ? '/' : parentRoute + '/'
490
+ if (!route.startsWith(prefix)) return null
491
+ const rest = route.slice(prefix.length)
492
+ if (rest.includes('/')) return null
493
+ return rest
291
494
  }
292
495
 
293
496
  /**
@@ -482,6 +685,9 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
482
685
  }
483
686
  let lastModified = null
484
687
 
688
+ // Cache directory listing for prefix-based file resolution
689
+ const cachedFiles = await readdir(pagePath)
690
+
485
691
  let index = 1
486
692
  for (const item of sectionsConfig) {
487
693
  let sectionName
@@ -507,13 +713,14 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
507
713
  // Build section ID
508
714
  const id = parentId ? `${parentId}.${index}` : String(index)
509
715
 
510
- // Look for the markdown file
511
- const filePath = join(pagePath, `${sectionName}.md`)
512
- if (!existsSync(filePath)) {
716
+ // Look for the markdown file (exact match or prefix-based, e.g., "hero" → "1-hero.md")
717
+ const found = findSectionFile(pagePath, sectionName, cachedFiles)
718
+ if (!found) {
513
719
  console.warn(`[content-collector] Section file not found: ${sectionName}.md`)
514
720
  index++
515
721
  continue
516
722
  }
723
+ const filePath = found.filePath
517
724
 
518
725
  // Process the section
519
726
  // Use sectionName as stable ID for scroll targeting (e.g., "hero", "features")
@@ -579,8 +786,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
579
786
 
580
787
  // Check for explicit sections configuration
581
788
  const { sections: sectionsConfig } = pageConfig
789
+ const sectionsParsed = Array.isArray(sectionsConfig) ? parseWildcardArray(sectionsConfig) : null
582
790
 
583
- if (sectionsConfig === undefined || sectionsConfig === '*') {
791
+ if (sectionsConfig === undefined || sectionsConfig === '*' || sectionsParsed?.mode === 'all') {
584
792
  // Default behavior: discover all .md files, sort by numeric prefix
585
793
  const files = await readdir(pagePath)
586
794
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
@@ -609,8 +817,79 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
609
817
  // Build hierarchy from dot notation
610
818
  hierarchicalSections = buildSectionHierarchy(sections)
611
819
 
820
+ } else if (sectionsParsed?.mode === 'inclusive') {
821
+ // Inclusive: pinned sections + auto-discovered rest via '...' wildcard
822
+ const files = await readdir(pagePath)
823
+ const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
824
+
825
+ // Build name → file info map from discovered files
826
+ const discoveredMap = new Map()
827
+ for (const file of mdFiles) {
828
+ const { name } = parse(file)
829
+ const { prefix, name: stableName } = parseNumericPrefix(name)
830
+ const key = stableName || name
831
+ if (!discoveredMap.has(key)) {
832
+ discoveredMap.set(key, { file, prefix, stableName: key })
833
+ }
834
+ }
835
+
836
+ // Create items with .name property for applyWildcardOrder
837
+ const allItems = [...discoveredMap.keys()].map(name => ({ name }))
838
+ const ordered = applyWildcardOrder(allItems, sectionsParsed)
839
+
840
+ // Collect subsection configs from the original array (e.g., { features: [a, b] })
841
+ const subsectionConfigs = new Map()
842
+ for (const item of [...sectionsParsed.before, ...sectionsParsed.after]) {
843
+ if (typeof item === 'object' && item !== null) {
844
+ const keys = Object.keys(item)
845
+ if (keys.length === 1) {
846
+ subsectionConfigs.set(keys[0], item[keys[0]])
847
+ }
848
+ }
849
+ }
850
+
851
+ // Process sections in wildcard-expanded order
852
+ const sections = []
853
+ let sectionIndex = 1
854
+ for (const { name } of ordered) {
855
+ const entry = discoveredMap.get(name)
856
+ if (!entry) {
857
+ console.warn(`[content-collector] Section '${name}' not found in ${pagePath}`)
858
+ continue
859
+ }
860
+
861
+ const id = String(sectionIndex)
862
+ const { section, assetCollection: sectionAssets, iconCollection: sectionIcons } =
863
+ await processMarkdownFile(join(pagePath, entry.file), id, siteRoot, entry.stableName)
864
+ sections.push(section)
865
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, sectionAssets)
866
+ pageIconCollection = mergeIconCollections(pageIconCollection, sectionIcons)
867
+
868
+ // Track last modified
869
+ const fileStat = await stat(join(pagePath, entry.file))
870
+ if (!lastModified || fileStat.mtime > lastModified) {
871
+ lastModified = fileStat.mtime
872
+ }
873
+
874
+ // Process subsections if configured (e.g., { features: [logocloud, stats] })
875
+ const subsections = subsectionConfigs.get(name)
876
+ if (Array.isArray(subsections) && subsections.length > 0) {
877
+ const subResult = await processExplicitSections(subsections, pagePath, siteRoot, id)
878
+ section.subsections = subResult.sections
879
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, subResult.assetCollection)
880
+ pageIconCollection = mergeIconCollections(pageIconCollection, subResult.iconCollection)
881
+ if (subResult.lastModified && (!lastModified || subResult.lastModified > lastModified)) {
882
+ lastModified = subResult.lastModified
883
+ }
884
+ }
885
+
886
+ sectionIndex++
887
+ }
888
+
889
+ hierarchicalSections = buildSectionHierarchy(sections)
890
+
612
891
  } else if (Array.isArray(sectionsConfig) && sectionsConfig.length > 0) {
613
- // Explicit sections array
892
+ // Strict: explicit sections array (only listed sections processed)
614
893
  const result = await processExplicitSections(sectionsConfig, pagePath, siteRoot)
615
894
  hierarchicalSections = result.sections
616
895
  pageAssetCollection = result.assetCollection
@@ -722,9 +1001,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
722
1001
  function determineIndexPage(orderConfig, availableFolders) {
723
1002
  const { pages: pagesArray, index: indexName } = orderConfig || {}
724
1003
 
725
- // 1. Explicit pages array - first item is index
1004
+ // 1. Explicit pages array - first non-'...' item is index
726
1005
  if (Array.isArray(pagesArray) && pagesArray.length > 0) {
727
- return pagesArray[0]
1006
+ const parsed = parseWildcardArray(pagesArray)
1007
+ if (parsed && parsed.before.length > 0) {
1008
+ return extractItemName(parsed.before[0])
1009
+ }
1010
+ // Array starts with '...' or is ['...'] — no index from pages, fall through
728
1011
  }
729
1012
 
730
1013
  // 2. Explicit index property
@@ -762,7 +1045,7 @@ function determineIndexPage(orderConfig, availableFolders) {
762
1045
  * @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
763
1046
  * @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
764
1047
  */
765
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections') {
1048
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null) {
766
1049
  const entries = await readdir(dirPath)
767
1050
  const pages = []
768
1051
  let assetCollection = {
@@ -788,7 +1071,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
788
1071
  // Read folder.yml or page.yml to determine mode and get config
789
1072
  const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
790
1073
  const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
791
- const childOrderArray = Array.isArray(dirConfig.order) ? dirConfig.order : undefined
792
1074
 
793
1075
  pageFolders.push({
794
1076
  name: entry,
@@ -798,12 +1080,31 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
798
1080
  dirMode,
799
1081
  childOrderConfig: {
800
1082
  pages: dirConfig.pages,
801
- index: dirConfig.index,
802
- order: childOrderArray
1083
+ index: dirConfig.index
803
1084
  }
804
1085
  })
805
1086
  }
806
1087
 
1088
+ // Inject virtual entries for mounts without physical directories
1089
+ if (mounts) {
1090
+ for (const [routeSegment, mountPath] of mounts) {
1091
+ if (!pageFolders.some(f => f.name === routeSegment)) {
1092
+ const { config: mountConfig } = await readFolderConfig(mountPath, 'pages')
1093
+ pageFolders.push({
1094
+ name: routeSegment,
1095
+ path: mountPath,
1096
+ order: typeof mountConfig.order === 'number' ? mountConfig.order : undefined,
1097
+ dirConfig: { title: mountConfig.title || routeSegment, ...mountConfig },
1098
+ dirMode: 'pages',
1099
+ childOrderConfig: {
1100
+ pages: mountConfig.pages,
1101
+ index: mountConfig.index
1102
+ }
1103
+ })
1104
+ }
1105
+ }
1106
+ }
1107
+
807
1108
  // Sort page folders by order (ascending), then alphabetically
808
1109
  // Pages without explicit order come after ordered pages (order ?? Infinity)
809
1110
  pageFolders.sort((a, b) => {
@@ -813,8 +1114,20 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
813
1114
  return a.name.localeCompare(b.name)
814
1115
  })
815
1116
 
816
- // Apply non-strict order from parent config (if present)
817
- const orderedFolders = applyNonStrictOrder(pageFolders, orderConfig?.order)
1117
+ // Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
1118
+ let orderedFolders
1119
+ let strictPageNames = null
1120
+
1121
+ const pagesParsed = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
1122
+
1123
+ if (pagesParsed && pagesParsed.mode !== 'all') {
1124
+ orderedFolders = applyWildcardOrder(pageFolders, pagesParsed)
1125
+ if (pagesParsed.mode === 'strict') {
1126
+ strictPageNames = new Set(pagesParsed.before.map(extractItemName).filter(Boolean))
1127
+ }
1128
+ } else {
1129
+ orderedFolders = pageFolders
1130
+ }
818
1131
 
819
1132
  // Check if this directory contains version folders (versioned section)
820
1133
  const folderNames = orderedFolders.map(f => f.name)
@@ -879,12 +1192,28 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
879
1192
  }
880
1193
  }
881
1194
 
882
- // Apply non-strict order to md-file-pages
883
- const orderedMdPages = applyNonStrictOrder(mdPageItems, orderConfig?.order)
1195
+ // Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
1196
+ let orderedMdPages
1197
+ let strictPageNamesFM = null
884
1198
 
885
- // In folder mode, only promote an index if explicitly set via index: in folder.yml
886
- // The container page itself owns the parent route — don't auto-promote children
887
- const indexName = orderConfig?.index || null
1199
+ const pagesParsedFM = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
1200
+
1201
+ if (pagesParsedFM && pagesParsedFM.mode !== 'all') {
1202
+ orderedMdPages = applyWildcardOrder(mdPageItems, pagesParsedFM)
1203
+ if (pagesParsedFM.mode === 'strict') {
1204
+ strictPageNamesFM = new Set(pagesParsedFM.before.map(extractItemName).filter(Boolean))
1205
+ }
1206
+ } else {
1207
+ orderedMdPages = mdPageItems
1208
+ }
1209
+
1210
+ // In folder mode, determine index: pages: first item, or explicit index:
1211
+ let indexName = null
1212
+ if (pagesParsedFM && pagesParsedFM.before.length > 0) {
1213
+ indexName = extractItemName(pagesParsedFM.before[0])
1214
+ } else {
1215
+ indexName = orderConfig?.index || null
1216
+ }
888
1217
 
889
1218
  // Add md-file-pages
890
1219
  for (const { name, result } of orderedMdPages) {
@@ -920,9 +1249,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
920
1249
  pages.push(page)
921
1250
 
922
1251
  // Recurse into subdirectories (page mode)
1252
+ const childDirPath = mounts?.get(entry) || entryPath
923
1253
  const childParentRoute = isIndex ? parentRoute : page.route
924
1254
  const childFetch = page.fetch || parentFetch
925
- const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections')
1255
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections', null)
926
1256
  pages.push(...subResult.pages)
927
1257
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
928
1258
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -974,7 +1304,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
974
1304
  pages.push(containerPage)
975
1305
 
976
1306
  // Recurse in folder mode
977
- const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
1307
+ const childDirPath = mounts?.get(entry) || entryPath
1308
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null)
978
1309
  pages.push(...subResult.pages)
979
1310
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
980
1311
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -984,6 +1315,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
984
1315
  }
985
1316
  }
986
1317
 
1318
+ // When pages: is strict (no '...'), hide unlisted direct children from navigation
1319
+ if (strictPageNamesFM) {
1320
+ for (const page of pages) {
1321
+ const childName = getDirectChildName(page.route, parentRoute)
1322
+ || (page.sourcePath ? getDirectChildName(page.sourcePath, parentRoute) : null)
1323
+ if (childName && !strictPageNamesFM.has(childName) && !page.hidden) {
1324
+ page.hidden = true
1325
+ }
1326
+ }
1327
+ }
1328
+
987
1329
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
988
1330
  }
989
1331
 
@@ -1049,7 +1391,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1049
1391
  pages.push(containerPage)
1050
1392
  }
1051
1393
 
1052
- const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
1394
+ const childDirPath = mounts?.get(entry) || entryPath
1395
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null)
1053
1396
  pages.push(...subResult.pages)
1054
1397
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1055
1398
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1076,11 +1419,12 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1076
1419
 
1077
1420
  // Recursively process subdirectories
1078
1421
  {
1422
+ const childDirPath = mounts?.get(entry) || entryPath
1079
1423
  const childParentRoute = isIndex
1080
1424
  ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
1081
1425
  : page.route
1082
1426
  const childFetch = page.fetch || parentFetch
1083
- const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode)
1427
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode, null)
1084
1428
  pages.push(...subResult.pages)
1085
1429
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1086
1430
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1092,6 +1436,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1092
1436
  }
1093
1437
  }
1094
1438
 
1439
+ // When pages: is strict (no '...'), hide unlisted direct children from navigation
1440
+ if (strictPageNames) {
1441
+ for (const page of pages) {
1442
+ const childName = getDirectChildName(page.route, parentRoute)
1443
+ || (page.sourcePath ? getDirectChildName(page.sourcePath, parentRoute) : null)
1444
+ if (childName && !strictPageNames.has(childName) && !page.hidden) {
1445
+ page.hidden = true
1446
+ }
1447
+ }
1448
+ }
1449
+
1095
1450
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
1096
1451
  }
1097
1452
 
@@ -1202,6 +1557,8 @@ export async function collectSiteContent(sitePath, options = {}) {
1202
1557
  ? resolve(sitePath, siteConfig.paths.pages)
1203
1558
  : join(sitePath, 'pages')
1204
1559
 
1560
+ const mounts = resolveMounts(siteConfig.paths, sitePath, pagesPath)
1561
+
1205
1562
  const layoutPath = siteConfig.paths?.layout
1206
1563
  ? resolve(sitePath, siteConfig.paths.layout)
1207
1564
  : join(sitePath, 'layout')
@@ -1232,8 +1589,7 @@ export async function collectSiteContent(sitePath, options = {}) {
1232
1589
  // Extract page ordering config from site.yml
1233
1590
  const siteOrderConfig = {
1234
1591
  pages: siteConfig.pages,
1235
- index: siteConfig.index,
1236
- order: Array.isArray(siteConfig.order) ? siteConfig.order : undefined
1592
+ index: siteConfig.index
1237
1593
  }
1238
1594
 
1239
1595
  // Determine root content mode from folder.yml/page.yml presence in pages directory
@@ -1244,7 +1600,7 @@ export async function collectSiteContent(sitePath, options = {}) {
1244
1600
 
1245
1601
  // Recursively collect all pages
1246
1602
  const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
1247
- await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode)
1603
+ await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts)
1248
1604
 
1249
1605
  // Deduplicate: remove content-less container pages whose route duplicates
1250
1606
  // a content-bearing page (e.g., a promoted index page)
@@ -1291,8 +1647,11 @@ export async function collectSiteContent(sitePath, options = {}) {
1291
1647
  page.parent = parentPage ? parentPage.route : null
1292
1648
  }
1293
1649
 
1294
- // Sort pages by order
1295
- pages.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
1650
+ // Page order is determined by per-level sorting during collection:
1651
+ // 1. Numeric 'order' property in page.yml (lower first, within each level)
1652
+ // 2. pages: array in parent config (wildcard-aware, overrides numeric order)
1653
+ // 3. order: [array] in parent config (non-strict, backward compat)
1654
+ // No global re-sort — collection order is authoritative.
1296
1655
 
1297
1656
  // Log asset summary
1298
1657
  const assetCount = Object.keys(assetCollection.assets).length
@@ -1335,4 +1694,12 @@ export async function collectSiteContent(sitePath, options = {}) {
1335
1694
  }
1336
1695
  }
1337
1696
 
1697
+ // Exported for testing
1698
+ export {
1699
+ extractItemName,
1700
+ parseWildcardArray,
1701
+ applyWildcardOrder,
1702
+ getDirectChildName
1703
+ }
1704
+
1338
1705
  export default collectSiteContent