@uniweb/build 0.6.17 → 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.17",
3
+ "version": "0.6.18",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,8 +51,8 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/schemas": "0.2.1",
54
- "@uniweb/content-reader": "1.1.2",
55
- "@uniweb/runtime": "0.5.21"
54
+ "@uniweb/runtime": "0.5.21",
55
+ "@uniweb/content-reader": "1.1.2"
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
 
@@ -271,23 +271,130 @@ function compareFilenames(a, b) {
271
271
  }
272
272
 
273
273
  /**
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.
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.
276
325
  *
277
- * Unlike strict arrays (pages: [...], sections: [...]) which hide unlisted items,
278
- * 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
279
329
  *
280
330
  * @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)
331
+ * @param {{ mode: string, before: Array, after: Array }|null} parsed - From parseWildcardArray
332
+ * @returns {Array} Reordered items
283
333
  */
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]
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
381
+ }
382
+
383
+ /**
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
291
398
  }
292
399
 
293
400
  /**
@@ -482,6 +589,9 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
482
589
  }
483
590
  let lastModified = null
484
591
 
592
+ // Cache directory listing for prefix-based file resolution
593
+ const cachedFiles = await readdir(pagePath)
594
+
485
595
  let index = 1
486
596
  for (const item of sectionsConfig) {
487
597
  let sectionName
@@ -507,13 +617,14 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
507
617
  // Build section ID
508
618
  const id = parentId ? `${parentId}.${index}` : String(index)
509
619
 
510
- // Look for the markdown file
511
- const filePath = join(pagePath, `${sectionName}.md`)
512
- 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) {
513
623
  console.warn(`[content-collector] Section file not found: ${sectionName}.md`)
514
624
  index++
515
625
  continue
516
626
  }
627
+ const filePath = found.filePath
517
628
 
518
629
  // Process the section
519
630
  // Use sectionName as stable ID for scroll targeting (e.g., "hero", "features")
@@ -579,8 +690,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
579
690
 
580
691
  // Check for explicit sections configuration
581
692
  const { sections: sectionsConfig } = pageConfig
693
+ const sectionsParsed = Array.isArray(sectionsConfig) ? parseWildcardArray(sectionsConfig) : null
582
694
 
583
- if (sectionsConfig === undefined || sectionsConfig === '*') {
695
+ if (sectionsConfig === undefined || sectionsConfig === '*' || sectionsParsed?.mode === 'all') {
584
696
  // Default behavior: discover all .md files, sort by numeric prefix
585
697
  const files = await readdir(pagePath)
586
698
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
@@ -609,8 +721,79 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
609
721
  // Build hierarchy from dot notation
610
722
  hierarchicalSections = buildSectionHierarchy(sections)
611
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
+
612
795
  } else if (Array.isArray(sectionsConfig) && sectionsConfig.length > 0) {
613
- // Explicit sections array
796
+ // Strict: explicit sections array (only listed sections processed)
614
797
  const result = await processExplicitSections(sectionsConfig, pagePath, siteRoot)
615
798
  hierarchicalSections = result.sections
616
799
  pageAssetCollection = result.assetCollection
@@ -722,9 +905,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
722
905
  function determineIndexPage(orderConfig, availableFolders) {
723
906
  const { pages: pagesArray, index: indexName } = orderConfig || {}
724
907
 
725
- // 1. Explicit pages array - first item is index
908
+ // 1. Explicit pages array - first non-'...' item is index
726
909
  if (Array.isArray(pagesArray) && pagesArray.length > 0) {
727
- 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
728
915
  }
729
916
 
730
917
  // 2. Explicit index property
@@ -788,7 +975,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
788
975
  // Read folder.yml or page.yml to determine mode and get config
789
976
  const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
790
977
  const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
791
- const childOrderArray = Array.isArray(dirConfig.order) ? dirConfig.order : undefined
792
978
 
793
979
  pageFolders.push({
794
980
  name: entry,
@@ -798,8 +984,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
798
984
  dirMode,
799
985
  childOrderConfig: {
800
986
  pages: dirConfig.pages,
801
- index: dirConfig.index,
802
- order: childOrderArray
987
+ index: dirConfig.index
803
988
  }
804
989
  })
805
990
  }
@@ -813,8 +998,20 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
813
998
  return a.name.localeCompare(b.name)
814
999
  })
815
1000
 
816
- // Apply non-strict order from parent config (if present)
817
- 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
+ }
818
1015
 
819
1016
  // Check if this directory contains version folders (versioned section)
820
1017
  const folderNames = orderedFolders.map(f => f.name)
@@ -879,12 +1076,28 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
879
1076
  }
880
1077
  }
881
1078
 
882
- // Apply non-strict order to md-file-pages
883
- const orderedMdPages = applyNonStrictOrder(mdPageItems, orderConfig?.order)
1079
+ // Apply ordering: pages: (wildcard-aware) > order: [array] (backward compat) > default
1080
+ let orderedMdPages
1081
+ let strictPageNamesFM = null
884
1082
 
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
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
+ }
888
1101
 
889
1102
  // Add md-file-pages
890
1103
  for (const { name, result } of orderedMdPages) {
@@ -984,6 +1197,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
984
1197
  }
985
1198
  }
986
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
+
987
1211
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
988
1212
  }
989
1213
 
@@ -1092,6 +1316,17 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1092
1316
  }
1093
1317
  }
1094
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
+
1095
1330
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
1096
1331
  }
1097
1332
 
@@ -1232,8 +1467,7 @@ export async function collectSiteContent(sitePath, options = {}) {
1232
1467
  // Extract page ordering config from site.yml
1233
1468
  const siteOrderConfig = {
1234
1469
  pages: siteConfig.pages,
1235
- index: siteConfig.index,
1236
- order: Array.isArray(siteConfig.order) ? siteConfig.order : undefined
1470
+ index: siteConfig.index
1237
1471
  }
1238
1472
 
1239
1473
  // Determine root content mode from folder.yml/page.yml presence in pages directory
@@ -1291,8 +1525,11 @@ export async function collectSiteContent(sitePath, options = {}) {
1291
1525
  page.parent = parentPage ? parentPage.route : null
1292
1526
  }
1293
1527
 
1294
- // Sort pages by order
1295
- 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.
1296
1533
 
1297
1534
  // Log asset summary
1298
1535
  const assetCount = Object.keys(assetCollection.assets).length
@@ -1335,4 +1572,12 @@ export async function collectSiteContent(sitePath, options = {}) {
1335
1572
  }
1336
1573
  }
1337
1574
 
1575
+ // Exported for testing
1576
+ export {
1577
+ extractItemName,
1578
+ parseWildcardArray,
1579
+ applyWildcardOrder,
1580
+ getDirectChildName
1581
+ }
1582
+
1338
1583
  export default collectSiteContent