@uniweb/build 0.6.16 → 0.6.18

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.16",
3
+ "version": "0.6.18",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,8 +50,8 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/runtime": "0.5.21",
54
53
  "@uniweb/schemas": "0.2.1",
54
+ "@uniweb/runtime": "0.5.21",
55
55
  "@uniweb/content-reader": "1.1.2"
56
56
  },
57
57
  "peerDependencies": {
package/src/i18n/index.js CHANGED
@@ -169,20 +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
- verbose = false
179
+ verbose = false,
180
+ dryRun = false
180
181
  } = options
181
182
 
182
- // Load site content
183
- const siteContentRaw = await readFile(siteContentPath, 'utf-8')
184
- const siteContent = JSON.parse(siteContentRaw)
185
-
186
183
  // Extract translatable content
187
184
  const manifest = extractTranslatableContent(siteContent)
188
185
 
@@ -203,8 +200,10 @@ export async function extractManifest(siteRoot, options = {}) {
203
200
  // Generate sync report
204
201
  const report = syncManifests(previousManifest, manifest)
205
202
 
206
- // Write new manifest
207
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
203
+ // Write new manifest (skip in dry-run mode)
204
+ if (!dryRun) {
205
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
206
+ }
208
207
 
209
208
  if (verbose) {
210
209
  console.log(formatSyncReport(report))
@@ -222,7 +221,7 @@ export async function extractManifest(siteRoot, options = {}) {
222
221
  * @returns {Promise<Object>} { manifest, report }
223
222
  */
224
223
  export async function extractCollectionManifest(siteRoot, options = {}) {
225
- const { localesDir = DEFAULTS.localesDir } = options
224
+ const { localesDir = DEFAULTS.localesDir, dryRun = false } = options
226
225
 
227
226
  // Extract translatable content from collections
228
227
  const manifest = await extractCollectionContent(siteRoot)
@@ -249,8 +248,10 @@ export async function extractCollectionManifest(siteRoot, options = {}) {
249
248
  // Generate sync report
250
249
  const report = syncManifests(previousManifest, manifest)
251
250
 
252
- // Write new manifest
253
- await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
251
+ // Write new manifest (skip in dry-run mode)
252
+ if (!dryRun) {
253
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
254
+ }
254
255
 
255
256
  return { manifest, report }
256
257
  }
package/src/i18n/sync.js CHANGED
@@ -143,6 +143,15 @@ export function formatSyncReport(report) {
143
143
 
144
144
  if (report.moved.length > 0) {
145
145
  lines.push(` ↻ ${report.moved.length} strings moved (contexts updated)`)
146
+ for (const item of report.moved.slice(0, 5)) {
147
+ const preview = truncate(item.source, 40)
148
+ const oldCtx = formatContext(item.previousContexts?.[0])
149
+ const newCtx = formatContext(item.currentContexts?.[0])
150
+ lines.push(` - "${preview}" ${oldCtx} → ${newCtx}`)
151
+ }
152
+ if (report.moved.length > 5) {
153
+ lines.push(` ... and ${report.moved.length - 5} more`)
154
+ }
146
155
  }
147
156
 
148
157
  if (report.changed.length > 0) {
@@ -150,7 +159,7 @@ export function formatSyncReport(report) {
150
159
  for (const item of report.changed.slice(0, 5)) {
151
160
  const prevPreview = truncate(item.previousSource, 30)
152
161
  const currPreview = truncate(item.source, 30)
153
- lines.push(` - ${item.previousHash}: "${prevPreview}" → "${currPreview}"`)
162
+ lines.push(` - "${prevPreview}" → "${currPreview}"`)
154
163
  }
155
164
  if (report.changed.length > 5) {
156
165
  lines.push(` ... and ${report.changed.length - 5} more`)
@@ -159,15 +168,42 @@ export function formatSyncReport(report) {
159
168
 
160
169
  if (report.added.length > 0) {
161
170
  lines.push(` + ${report.added.length} new strings`)
171
+ for (const item of report.added.slice(0, 5)) {
172
+ const preview = truncate(item.source, 40)
173
+ const ctx = formatContext(item.contexts?.[0])
174
+ lines.push(` - "${preview}" ${ctx}`)
175
+ }
176
+ if (report.added.length > 5) {
177
+ lines.push(` ... and ${report.added.length - 5} more`)
178
+ }
162
179
  }
163
180
 
164
181
  if (report.removed.length > 0) {
165
182
  lines.push(` - ${report.removed.length} strings removed`)
183
+ for (const item of report.removed.slice(0, 5)) {
184
+ const preview = truncate(item.source, 40)
185
+ const ctx = formatContext(item.contexts?.[0])
186
+ lines.push(` - "${preview}" ${ctx}`)
187
+ }
188
+ if (report.removed.length > 5) {
189
+ lines.push(` ... and ${report.removed.length - 5} more`)
190
+ }
166
191
  }
167
192
 
168
193
  return lines.join('\n')
169
194
  }
170
195
 
196
+ /**
197
+ * Format a context object for display
198
+ */
199
+ function formatContext(context) {
200
+ if (!context) return ''
201
+ const location = context.page || context.collection || ''
202
+ const section = context.section || context.item || ''
203
+ if (!location && !section) return ''
204
+ return `(${location}:${section})`
205
+ }
206
+
171
207
  /**
172
208
  * Truncate string for display
173
209
  */
@@ -203,10 +203,12 @@ function isIgnoredFolder(name) {
203
203
  /**
204
204
  * Read folder configuration, determining content mode from config file presence.
205
205
  *
206
- * - folder.yml present → pages mode (md files are child pages)
207
- * - page.yml present → sections mode (md files are sections of this page)
206
+ * - folder.yml present → folder mode (md files are child pages)
207
+ * - page.yml present → page mode (md files are sections of this page)
208
208
  * - Neither → inherit mode from parent
209
209
  *
210
+ * Internal mode values: 'pages' (folder mode), 'sections' (page mode)
211
+ *
210
212
  * @param {string} dirPath - Directory path
211
213
  * @param {string} inheritedMode - Mode inherited from parent ('sections' or 'pages')
212
214
  * @returns {Promise<{config: Object, mode: string, source: string}>}
@@ -220,7 +222,7 @@ async function readFolderConfig(dirPath, inheritedMode) {
220
222
  if (Object.keys(pageYml).length > 0) {
221
223
  return { config: pageYml, mode: 'sections', source: 'page.yml' }
222
224
  }
223
- // Check for empty folder.yml (presence signals pages mode even if empty)
225
+ // Check for empty folder.yml (presence signals folder mode even if empty)
224
226
  if (existsSync(join(dirPath, 'folder.yml'))) {
225
227
  return { config: {}, mode: 'pages', source: 'folder.yml' }
226
228
  }
@@ -269,27 +271,134 @@ function compareFilenames(a, b) {
269
271
  }
270
272
 
271
273
  /**
272
- * Apply non-strict ordering to a list of items.
273
- * Listed items appear first in array order, then unlisted items in their existing order.
274
+ * Extract the name from a config array item.
275
+ * Handles both string entries ("hero") and object entries ({ features: [...] }).
276
+ * @param {*} item - Array item from sections: or pages: config
277
+ * @returns {string|null} The name, or null if not a valid entry
278
+ */
279
+ function extractItemName(item) {
280
+ if (typeof item === 'string') return item
281
+ if (typeof item === 'object' && item !== null) {
282
+ const keys = Object.keys(item)
283
+ if (keys.length === 1) return keys[0]
284
+ }
285
+ return null
286
+ }
287
+
288
+ /**
289
+ * Parse a config array that may contain '...' rest markers.
290
+ *
291
+ * Returns structured info:
292
+ * - mode 'strict': no '...' — only listed items visible in navigation
293
+ * - mode 'inclusive': '...' present — pinned items + auto-discovered rest
294
+ * - mode 'all': array is just ['...'] — equivalent to omitting config
295
+ *
296
+ * @param {Array} arr - Config array (may contain '...' strings and/or objects)
297
+ * @returns {{ mode: 'strict'|'inclusive'|'all', before: Array, after: Array }|null}
298
+ */
299
+ function parseWildcardArray(arr) {
300
+ if (!Array.isArray(arr) || arr.length === 0) return null
301
+
302
+ const firstRestIndex = arr.indexOf('...')
303
+ if (firstRestIndex === -1) {
304
+ return { mode: 'strict', before: [...arr], after: [] }
305
+ }
306
+
307
+ // Find last '...' index
308
+ let lastRestIndex = firstRestIndex
309
+ for (let i = arr.length - 1; i >= 0; i--) {
310
+ if (arr[i] === '...') { lastRestIndex = i; break }
311
+ }
312
+
313
+ const before = arr.slice(0, firstRestIndex).filter(x => x !== '...')
314
+ const after = arr.slice(lastRestIndex + 1).filter(x => x !== '...')
315
+
316
+ if (before.length === 0 && after.length === 0) {
317
+ return { mode: 'all', before: [], after: [] }
318
+ }
319
+
320
+ return { mode: 'inclusive', before, after }
321
+ }
322
+
323
+ /**
324
+ * Apply wildcard-aware ordering to a list of named items.
274
325
  *
275
- * Unlike strict arrays (pages: [...], sections: [...]) which hide unlisted items,
276
- * this preserves all items it only affects order.
326
+ * - strict: listed items first in listed order, then unlisted (all items returned)
327
+ * - inclusive: before items, then rest (in existing order), then after items
328
+ * - all/null: return items unchanged
277
329
  *
278
330
  * @param {Array} items - Items with a .name property
279
- * @param {Array<string>} orderArray - Names in desired order
280
- * @returns {Array} Reordered items (all items preserved)
331
+ * @param {{ mode: string, before: Array, after: Array }|null} parsed - From parseWildcardArray
332
+ * @returns {Array} Reordered items
281
333
  */
282
- function applyNonStrictOrder(items, orderArray) {
283
- if (!Array.isArray(orderArray) || orderArray.length === 0) return items
284
- const orderMap = new Map(orderArray.map((name, i) => [name, i]))
285
- const listed = items.filter(i => orderMap.has(i.name))
286
- .sort((a, b) => orderMap.get(a.name) - orderMap.get(b.name))
287
- const unlisted = items.filter(i => !orderMap.has(i.name))
288
- return [...listed, ...unlisted]
334
+ function applyWildcardOrder(items, parsed) {
335
+ if (!parsed || parsed.mode === 'all') return items
336
+
337
+ const itemMap = new Map(items.map(i => [i.name, i]))
338
+ const beforeNames = parsed.before.map(extractItemName).filter(Boolean)
339
+ const afterNames = parsed.after.map(extractItemName).filter(Boolean)
340
+ const allPinnedNames = new Set([...beforeNames, ...afterNames])
341
+
342
+ const beforeItems = beforeNames.filter(n => itemMap.has(n)).map(n => itemMap.get(n))
343
+ const afterItems = afterNames.filter(n => itemMap.has(n)).map(n => itemMap.get(n))
344
+ const rest = items.filter(i => !allPinnedNames.has(i.name))
345
+
346
+ if (parsed.mode === 'strict') {
347
+ // Listed items first, then unlisted (hiding is applied separately)
348
+ return [...beforeItems, ...rest]
349
+ }
350
+
351
+ // Inclusive: before + rest + after
352
+ return [...beforeItems, ...rest, ...afterItems]
353
+ }
354
+
355
+ /**
356
+ * Find the markdown file for a section name, handling numeric prefixes.
357
+ * Tries exact match first ("hero.md"), then prefix-based ("1-hero.md").
358
+ *
359
+ * @param {string} pagePath - Directory containing section files
360
+ * @param {string} sectionName - Logical section name (e.g., 'hero')
361
+ * @param {string[]} [cachedFiles] - Pre-read directory listing (optimization)
362
+ * @returns {{ filePath: string, stableName: string, prefix: string|null }|null}
363
+ */
364
+ function findSectionFile(pagePath, sectionName, cachedFiles) {
365
+ const exactPath = join(pagePath, `${sectionName}.md`)
366
+ if (existsSync(exactPath)) {
367
+ return { filePath: exactPath, stableName: sectionName, prefix: null }
368
+ }
369
+
370
+ const files = cachedFiles || []
371
+ for (const file of files) {
372
+ if (!isMarkdownFile(file)) continue
373
+ const { name } = parse(file)
374
+ const { prefix, name: parsedName } = parseNumericPrefix(name)
375
+ if (parsedName === sectionName) {
376
+ return { filePath: join(pagePath, file), stableName: sectionName, prefix }
377
+ }
378
+ }
379
+
380
+ return null
289
381
  }
290
382
 
291
383
  /**
292
- * Process a markdown file as a standalone page (pages mode).
384
+ * Extract a direct child's folder name from its route, relative to parentRoute.
385
+ * Returns null for the index page (route === parentRoute) or non-direct-children.
386
+ *
387
+ * @param {string} route - Page route (e.g., '/about')
388
+ * @param {string} parentRoute - Parent route (e.g., '/')
389
+ * @returns {string|null}
390
+ */
391
+ function getDirectChildName(route, parentRoute) {
392
+ if (!route || route === parentRoute) return null
393
+ const prefix = parentRoute === '/' ? '/' : parentRoute + '/'
394
+ if (!route.startsWith(prefix)) return null
395
+ const rest = route.slice(prefix.length)
396
+ if (rest.includes('/')) return null
397
+ return rest
398
+ }
399
+
400
+ /**
401
+ * Process a markdown file as a standalone page (folder mode).
293
402
  * Creates a page with a single section from the markdown content.
294
403
  *
295
404
  * @param {string} filePath - Path to markdown file
@@ -480,6 +589,9 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
480
589
  }
481
590
  let lastModified = null
482
591
 
592
+ // Cache directory listing for prefix-based file resolution
593
+ const cachedFiles = await readdir(pagePath)
594
+
483
595
  let index = 1
484
596
  for (const item of sectionsConfig) {
485
597
  let sectionName
@@ -505,13 +617,14 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
505
617
  // Build section ID
506
618
  const id = parentId ? `${parentId}.${index}` : String(index)
507
619
 
508
- // Look for the markdown file
509
- const filePath = join(pagePath, `${sectionName}.md`)
510
- if (!existsSync(filePath)) {
620
+ // Look for the markdown file (exact match or prefix-based, e.g., "hero" → "1-hero.md")
621
+ const found = findSectionFile(pagePath, sectionName, cachedFiles)
622
+ if (!found) {
511
623
  console.warn(`[content-collector] Section file not found: ${sectionName}.md`)
512
624
  index++
513
625
  continue
514
626
  }
627
+ const filePath = found.filePath
515
628
 
516
629
  // Process the section
517
630
  // Use sectionName as stable ID for scroll targeting (e.g., "hero", "features")
@@ -577,8 +690,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
577
690
 
578
691
  // Check for explicit sections configuration
579
692
  const { sections: sectionsConfig } = pageConfig
693
+ const sectionsParsed = Array.isArray(sectionsConfig) ? parseWildcardArray(sectionsConfig) : null
580
694
 
581
- if (sectionsConfig === undefined || sectionsConfig === '*') {
695
+ if (sectionsConfig === undefined || sectionsConfig === '*' || sectionsParsed?.mode === 'all') {
582
696
  // Default behavior: discover all .md files, sort by numeric prefix
583
697
  const files = await readdir(pagePath)
584
698
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
@@ -607,8 +721,79 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
607
721
  // Build hierarchy from dot notation
608
722
  hierarchicalSections = buildSectionHierarchy(sections)
609
723
 
724
+ } else if (sectionsParsed?.mode === 'inclusive') {
725
+ // Inclusive: pinned sections + auto-discovered rest via '...' wildcard
726
+ const files = await readdir(pagePath)
727
+ const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
728
+
729
+ // Build name → file info map from discovered files
730
+ const discoveredMap = new Map()
731
+ for (const file of mdFiles) {
732
+ const { name } = parse(file)
733
+ const { prefix, name: stableName } = parseNumericPrefix(name)
734
+ const key = stableName || name
735
+ if (!discoveredMap.has(key)) {
736
+ discoveredMap.set(key, { file, prefix, stableName: key })
737
+ }
738
+ }
739
+
740
+ // Create items with .name property for applyWildcardOrder
741
+ const allItems = [...discoveredMap.keys()].map(name => ({ name }))
742
+ const ordered = applyWildcardOrder(allItems, sectionsParsed)
743
+
744
+ // Collect subsection configs from the original array (e.g., { features: [a, b] })
745
+ const subsectionConfigs = new Map()
746
+ for (const item of [...sectionsParsed.before, ...sectionsParsed.after]) {
747
+ if (typeof item === 'object' && item !== null) {
748
+ const keys = Object.keys(item)
749
+ if (keys.length === 1) {
750
+ subsectionConfigs.set(keys[0], item[keys[0]])
751
+ }
752
+ }
753
+ }
754
+
755
+ // Process sections in wildcard-expanded order
756
+ const sections = []
757
+ let sectionIndex = 1
758
+ for (const { name } of ordered) {
759
+ const entry = discoveredMap.get(name)
760
+ if (!entry) {
761
+ console.warn(`[content-collector] Section '${name}' not found in ${pagePath}`)
762
+ continue
763
+ }
764
+
765
+ const id = String(sectionIndex)
766
+ const { section, assetCollection: sectionAssets, iconCollection: sectionIcons } =
767
+ await processMarkdownFile(join(pagePath, entry.file), id, siteRoot, entry.stableName)
768
+ sections.push(section)
769
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, sectionAssets)
770
+ pageIconCollection = mergeIconCollections(pageIconCollection, sectionIcons)
771
+
772
+ // Track last modified
773
+ const fileStat = await stat(join(pagePath, entry.file))
774
+ if (!lastModified || fileStat.mtime > lastModified) {
775
+ lastModified = fileStat.mtime
776
+ }
777
+
778
+ // Process subsections if configured (e.g., { features: [logocloud, stats] })
779
+ const subsections = subsectionConfigs.get(name)
780
+ if (Array.isArray(subsections) && subsections.length > 0) {
781
+ const subResult = await processExplicitSections(subsections, pagePath, siteRoot, id)
782
+ section.subsections = subResult.sections
783
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, subResult.assetCollection)
784
+ pageIconCollection = mergeIconCollections(pageIconCollection, subResult.iconCollection)
785
+ if (subResult.lastModified && (!lastModified || subResult.lastModified > lastModified)) {
786
+ lastModified = subResult.lastModified
787
+ }
788
+ }
789
+
790
+ sectionIndex++
791
+ }
792
+
793
+ hierarchicalSections = buildSectionHierarchy(sections)
794
+
610
795
  } else if (Array.isArray(sectionsConfig) && sectionsConfig.length > 0) {
611
- // Explicit sections array
796
+ // Strict: explicit sections array (only listed sections processed)
612
797
  const result = await processExplicitSections(sectionsConfig, pagePath, siteRoot)
613
798
  hierarchicalSections = result.sections
614
799
  pageAssetCollection = result.assetCollection
@@ -720,9 +905,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
720
905
  function determineIndexPage(orderConfig, availableFolders) {
721
906
  const { pages: pagesArray, index: indexName } = orderConfig || {}
722
907
 
723
- // 1. Explicit pages array - first item is index
908
+ // 1. Explicit pages array - first non-'...' item is index
724
909
  if (Array.isArray(pagesArray) && pagesArray.length > 0) {
725
- return pagesArray[0]
910
+ const parsed = parseWildcardArray(pagesArray)
911
+ if (parsed && parsed.before.length > 0) {
912
+ return extractItemName(parsed.before[0])
913
+ }
914
+ // Array starts with '...' or is ['...'] — no index from pages, fall through
726
915
  }
727
916
 
728
917
  // 2. Explicit index property
@@ -786,7 +975,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
786
975
  // Read folder.yml or page.yml to determine mode and get config
787
976
  const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
788
977
  const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
789
- const childOrderArray = Array.isArray(dirConfig.order) ? dirConfig.order : undefined
790
978
 
791
979
  pageFolders.push({
792
980
  name: entry,
@@ -796,8 +984,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
796
984
  dirMode,
797
985
  childOrderConfig: {
798
986
  pages: dirConfig.pages,
799
- index: dirConfig.index,
800
- order: childOrderArray
987
+ index: dirConfig.index
801
988
  }
802
989
  })
803
990
  }
@@ -811,14 +998,26 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
811
998
  return a.name.localeCompare(b.name)
812
999
  })
813
1000
 
814
- // Apply non-strict order from parent config (if present)
815
- const orderedFolders = applyNonStrictOrder(pageFolders, orderConfig?.order)
1001
+ // Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
1002
+ let orderedFolders
1003
+ let strictPageNames = null
1004
+
1005
+ const pagesParsed = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
1006
+
1007
+ if (pagesParsed && pagesParsed.mode !== 'all') {
1008
+ orderedFolders = applyWildcardOrder(pageFolders, pagesParsed)
1009
+ if (pagesParsed.mode === 'strict') {
1010
+ strictPageNames = new Set(pagesParsed.before.map(extractItemName).filter(Boolean))
1011
+ }
1012
+ } else {
1013
+ orderedFolders = pageFolders
1014
+ }
816
1015
 
817
1016
  // Check if this directory contains version folders (versioned section)
818
1017
  const folderNames = orderedFolders.map(f => f.name)
819
1018
  const detectedVersions = detectVersions(folderNames)
820
1019
 
821
- // If versioned section, handle version folders specially (always sections mode)
1020
+ // If versioned section, handle version folders specially (always page mode)
822
1021
  if (detectedVersions && !versionContext) {
823
1022
  const parentConfig = await readYamlFile(join(dirPath, 'page.yml'))
824
1023
  const versionMeta = buildVersionMetadata(detectedVersions, parentConfig)
@@ -877,12 +1076,28 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
877
1076
  }
878
1077
  }
879
1078
 
880
- // Apply non-strict order to md-file-pages
881
- const orderedMdPages = applyNonStrictOrder(mdPageItems, orderConfig?.order)
1079
+ // Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
1080
+ let orderedMdPages
1081
+ let strictPageNamesFM = null
882
1082
 
883
- // In pages mode, only promote an index if explicitly set via index: in folder.yml
884
- // The container page itself owns the parent route — don't auto-promote children
885
- const indexName = orderConfig?.index || null
1083
+ const pagesParsedFM = Array.isArray(orderConfig?.pages) ? parseWildcardArray(orderConfig.pages) : null
1084
+
1085
+ if (pagesParsedFM && pagesParsedFM.mode !== 'all') {
1086
+ orderedMdPages = applyWildcardOrder(mdPageItems, pagesParsedFM)
1087
+ if (pagesParsedFM.mode === 'strict') {
1088
+ strictPageNamesFM = new Set(pagesParsedFM.before.map(extractItemName).filter(Boolean))
1089
+ }
1090
+ } else {
1091
+ orderedMdPages = mdPageItems
1092
+ }
1093
+
1094
+ // In folder mode, determine index: pages: first item, or explicit index:
1095
+ let indexName = null
1096
+ if (pagesParsedFM && pagesParsedFM.before.length > 0) {
1097
+ indexName = extractItemName(pagesParsedFM.before[0])
1098
+ } else {
1099
+ indexName = orderConfig?.index || null
1100
+ }
886
1101
 
887
1102
  // Add md-file-pages
888
1103
  for (const { name, result } of orderedMdPages) {
@@ -906,7 +1121,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
906
1121
  const isIndex = entry === indexName
907
1122
 
908
1123
  if (dirMode === 'sections') {
909
- // Subdirectory overrides to sections mode — process normally
1124
+ // Subdirectory overrides to page mode — process normally
910
1125
  const result = await processPage(entryPath, entry, siteRoot, {
911
1126
  isIndex, parentRoute, parentFetch, versionContext
912
1127
  })
@@ -917,7 +1132,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
917
1132
  iconCollection = mergeIconCollections(iconCollection, pageIcons)
918
1133
  pages.push(page)
919
1134
 
920
- // Recurse into subdirectories (sections mode)
1135
+ // Recurse into subdirectories (page mode)
921
1136
  const childParentRoute = isIndex ? parentRoute : page.route
922
1137
  const childFetch = page.fetch || parentFetch
923
1138
  const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections')
@@ -929,7 +1144,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
929
1144
  }
930
1145
  }
931
1146
  } else {
932
- // Container directory in pages mode — create minimal page, recurse
1147
+ // Container directory in folder mode — create minimal page, recurse
933
1148
  const containerRoute = isIndex
934
1149
  ? parentRoute
935
1150
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
@@ -971,7 +1186,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
971
1186
 
972
1187
  pages.push(containerPage)
973
1188
 
974
- // Recurse in pages mode
1189
+ // Recurse in folder mode
975
1190
  const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
976
1191
  pages.push(...subResult.pages)
977
1192
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
@@ -982,6 +1197,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
982
1197
  }
983
1198
  }
984
1199
 
1200
+ // When pages: is strict (no '...'), hide unlisted direct children from navigation
1201
+ if (strictPageNamesFM) {
1202
+ for (const page of pages) {
1203
+ const childName = getDirectChildName(page.route, parentRoute)
1204
+ || (page.sourcePath ? getDirectChildName(page.sourcePath, parentRoute) : null)
1205
+ if (childName && !strictPageNamesFM.has(childName) && !page.hidden) {
1206
+ page.hidden = true
1207
+ }
1208
+ }
1209
+ }
1210
+
985
1211
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
986
1212
  }
987
1213
 
@@ -1000,8 +1226,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1000
1226
  const isIndex = entry === indexPageName
1001
1227
 
1002
1228
  if (dirMode === 'pages') {
1003
- // Child directory switches to pages mode (has folder.yml) —
1004
- // create container page with empty sections, recurse in pages mode
1229
+ // Child directory switches to folder mode (has folder.yml) —
1230
+ // create container page with empty sections, recurse in folder mode
1005
1231
  const containerRoute = isIndex
1006
1232
  ? parentRoute
1007
1233
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
@@ -1090,6 +1316,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1090
1316
  }
1091
1317
  }
1092
1318
 
1319
+ // When pages: is strict (no '...'), hide unlisted direct children from navigation
1320
+ if (strictPageNames) {
1321
+ for (const page of pages) {
1322
+ const childName = getDirectChildName(page.route, parentRoute)
1323
+ || (page.sourcePath ? getDirectChildName(page.sourcePath, parentRoute) : null)
1324
+ if (childName && !strictPageNames.has(childName) && !page.hidden) {
1325
+ page.hidden = true
1326
+ }
1327
+ }
1328
+ }
1329
+
1093
1330
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
1094
1331
  }
1095
1332
 
@@ -1230,8 +1467,7 @@ export async function collectSiteContent(sitePath, options = {}) {
1230
1467
  // Extract page ordering config from site.yml
1231
1468
  const siteOrderConfig = {
1232
1469
  pages: siteConfig.pages,
1233
- index: siteConfig.index,
1234
- order: Array.isArray(siteConfig.order) ? siteConfig.order : undefined
1470
+ index: siteConfig.index
1235
1471
  }
1236
1472
 
1237
1473
  // Determine root content mode from folder.yml/page.yml presence in pages directory
@@ -1289,8 +1525,11 @@ export async function collectSiteContent(sitePath, options = {}) {
1289
1525
  page.parent = parentPage ? parentPage.route : null
1290
1526
  }
1291
1527
 
1292
- // Sort pages by order
1293
- pages.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
1528
+ // Page order is determined by per-level sorting during collection:
1529
+ // 1. Numeric 'order' property in page.yml (lower first, within each level)
1530
+ // 2. pages: array in parent config (wildcard-aware, overrides numeric order)
1531
+ // 3. order: [array] in parent config (non-strict, backward compat)
1532
+ // No global re-sort — collection order is authoritative.
1294
1533
 
1295
1534
  // Log asset summary
1296
1535
  const assetCount = Object.keys(assetCollection.assets).length
@@ -1333,4 +1572,12 @@ export async function collectSiteContent(sitePath, options = {}) {
1333
1572
  }
1334
1573
  }
1335
1574
 
1575
+ // Exported for testing
1576
+ export {
1577
+ extractItemName,
1578
+ parseWildcardArray,
1579
+ applyWildcardOrder,
1580
+ getDirectChildName
1581
+ }
1582
+
1336
1583
  export default collectSiteContent