@uniweb/build 0.8.1 → 0.8.2

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.8.1",
3
+ "version": "0.8.2",
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/content-reader": "1.1.3",
54
- "@uniweb/runtime": "0.6.1",
55
- "@uniweb/schemas": "0.2.1"
53
+ "@uniweb/content-reader": "1.1.4",
54
+ "@uniweb/schemas": "0.2.1",
55
+ "@uniweb/runtime": "0.6.2"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -61,7 +61,7 @@
61
61
  "@tailwindcss/vite": "^4.0.0",
62
62
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
63
63
  "vite-plugin-svgr": "^4.0.0",
64
- "@uniweb/core": "0.5.2"
64
+ "@uniweb/core": "0.5.3"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "vite": {
@@ -190,9 +190,9 @@ export function extractRuntimeSchema(fullMeta) {
190
190
 
191
191
  const runtime = {}
192
192
 
193
- // Embed flag: signals this component is designed for inline embedding via @ refs
194
- if (fullMeta.embed) {
195
- runtime.embed = true
193
+ // Inset flag: signals this component is available for inline @ references
194
+ if (fullMeta.inset) {
195
+ runtime.inset = true
196
196
  }
197
197
 
198
198
  // Background opt-out: 'self' means the component renders its own background
@@ -179,42 +179,42 @@ async function readYamlFile(filePath) {
179
179
  }
180
180
 
181
181
  /**
182
- * Extract inline child references from a ProseMirror document.
182
+ * Extract inset references from a ProseMirror document.
183
183
  *
184
- * Walks top-level nodes for `inline_child_ref` (produced by content-reader
184
+ * Walks top-level nodes for `inset_ref` (produced by content-reader
185
185
  * for `![alt](@ComponentName){params}` syntax). Each ref is removed from the
186
- * document and replaced with an `inline_child_placeholder` node carrying a
186
+ * document and replaced with an `inset_placeholder` node carrying a
187
187
  * unique refId. The extracted refs are returned as an array.
188
188
  *
189
189
  * @param {Object} doc - ProseMirror document (mutated in place)
190
- * @returns {Array} Array of { refId, type, params, alt }
190
+ * @returns {Array} Array of { refId, type, params, description }
191
191
  */
192
- function extractInlineChildren(doc) {
192
+ function extractInsets(doc) {
193
193
  if (!doc?.content || !Array.isArray(doc.content)) return []
194
194
 
195
- const inlineChildren = []
195
+ const insets = []
196
196
  let refIndex = 0
197
197
 
198
198
  for (let i = 0; i < doc.content.length; i++) {
199
199
  const node = doc.content[i]
200
- if (node.type === 'inline_child_ref') {
200
+ if (node.type === 'inset_ref') {
201
201
  const { component, alt, ...params } = node.attrs || {}
202
- const refId = `inline_${refIndex++}`
203
- inlineChildren.push({
202
+ const refId = `inset_${refIndex++}`
203
+ insets.push({
204
204
  refId,
205
205
  type: component,
206
206
  params: Object.keys(params).length > 0 ? params : {},
207
- alt: alt || null,
207
+ description: alt || null,
208
208
  })
209
209
  // Replace in-place with placeholder
210
210
  doc.content[i] = {
211
- type: 'inline_child_placeholder',
211
+ type: 'inset_placeholder',
212
212
  attrs: { refId },
213
213
  }
214
214
  }
215
215
  }
216
216
 
217
- return inlineChildren
217
+ return insets
218
218
  }
219
219
 
220
220
  /**
@@ -231,6 +231,23 @@ function isMarkdownFile(filename) {
231
231
  return true
232
232
  }
233
233
 
234
+ /**
235
+ * Check if a filename uses the @ prefix (child section convention).
236
+ * @-prefixed files are excluded from auto-discovered top-level sections —
237
+ * they exist to be nested under a parent via `nest:` in page.yml.
238
+ */
239
+ function isChildSection(filename) {
240
+ return filename.startsWith('@')
241
+ }
242
+
243
+ /**
244
+ * Strip leading @ characters from a filename to get the section name.
245
+ * @card-a → card-a, @@sub-item → sub-item
246
+ */
247
+ function stripAtPrefix(filename) {
248
+ return filename.replace(/^@+/, '')
249
+ }
250
+
234
251
  /**
235
252
  * Check if a folder should be ignored.
236
253
  * Excludes folders starting with _ (drafts/private).
@@ -369,11 +386,9 @@ function resolveMounts(pathsConfig, sitePath, pagesPath) {
369
386
  * Supports:
370
387
  * - Simple: "1", "2", "3"
371
388
  * - Decimal ordering: "1.5" (between 1 and 2), "2.5" (between 2 and 3)
372
- * - Hierarchy via comma: "1,1" (child of 1), "1,2" (second child of 1)
373
- * - Mixed: "1.5,1" (child of section 1.5)
374
389
  */
375
390
  function parseNumericPrefix(filename) {
376
- const match = filename.match(/^(\d+(?:[.,]\d+)*)-?(.*)$/)
391
+ const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
377
392
  if (match) {
378
393
  return { prefix: match[1], name: match[2] || match[1] }
379
394
  }
@@ -382,19 +397,20 @@ function parseNumericPrefix(filename) {
382
397
 
383
398
  /**
384
399
  * Compare filenames for sorting by numeric prefix.
385
- * Both . and , are treated as separators for sorting purposes.
386
- * This ensures correct ordering: 1, 1,1, 1.5, 2, 2,1, etc.
400
+ * Dots are treated as sub-level separators: 1, 1.5, 2, 2.5, etc.
387
401
  */
388
402
  function compareFilenames(a, b) {
389
- const { prefix: prefixA } = parseNumericPrefix(parse(a).name)
390
- const { prefix: prefixB } = parseNumericPrefix(parse(b).name)
403
+ const nameA = isChildSection(parse(a).name) ? stripAtPrefix(parse(a).name) : parse(a).name
404
+ const nameB = isChildSection(parse(b).name) ? stripAtPrefix(parse(b).name) : parse(b).name
405
+ const { prefix: prefixA } = parseNumericPrefix(nameA)
406
+ const { prefix: prefixB } = parseNumericPrefix(nameB)
391
407
 
392
408
  if (!prefixA && !prefixB) return a.localeCompare(b)
393
409
  if (!prefixA) return 1
394
410
  if (!prefixB) return -1
395
411
 
396
- const partsA = prefixA.split(/[.,]/).map(Number)
397
- const partsB = prefixB.split(/[.,]/).map(Number)
412
+ const partsA = prefixA.split('.').map(Number)
413
+ const partsB = prefixB.split('.').map(Number)
398
414
 
399
415
  for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
400
416
  const numA = partsA[i] ?? 0
@@ -488,27 +504,37 @@ function applyWildcardOrder(items, parsed) {
488
504
  }
489
505
 
490
506
  /**
491
- * Find the markdown file for a section name, handling numeric prefixes.
492
- * Tries exact match first ("hero.md"), then prefix-based ("1-hero.md").
507
+ * Find the markdown file for a section name, handling numeric prefixes and @ prefix.
508
+ * Search order: name.md @name.md N-name.md → @N-name.md
493
509
  *
494
510
  * @param {string} pagePath - Directory containing section files
495
- * @param {string} sectionName - Logical section name (e.g., 'hero')
511
+ * @param {string} sectionName - Logical section name (e.g., 'hero', 'card-a')
496
512
  * @param {string[]} [cachedFiles] - Pre-read directory listing (optimization)
497
- * @returns {{ filePath: string, stableName: string, prefix: string|null }|null}
513
+ * @returns {{ filePath: string, stableName: string, prefix: string|null, isChild: boolean }|null}
498
514
  */
499
515
  function findSectionFile(pagePath, sectionName, cachedFiles) {
516
+ // Try exact match: name.md
500
517
  const exactPath = join(pagePath, `${sectionName}.md`)
501
518
  if (existsSync(exactPath)) {
502
- return { filePath: exactPath, stableName: sectionName, prefix: null }
519
+ return { filePath: exactPath, stableName: sectionName, prefix: null, isChild: false }
503
520
  }
504
521
 
522
+ // Try @-prefixed exact match: @name.md
523
+ const atPath = join(pagePath, `@${sectionName}.md`)
524
+ if (existsSync(atPath)) {
525
+ return { filePath: atPath, stableName: sectionName, prefix: null, isChild: true }
526
+ }
527
+
528
+ // Try prefix-based: N-name.md or @N-name.md
505
529
  const files = cachedFiles || []
506
530
  for (const file of files) {
507
531
  if (!isMarkdownFile(file)) continue
508
532
  const { name } = parse(file)
509
- const { prefix, name: parsedName } = parseNumericPrefix(name)
533
+ const isChild = isChildSection(name)
534
+ const stripped = isChild ? stripAtPrefix(name) : name
535
+ const { prefix, name: parsedName } = parseNumericPrefix(stripped)
510
536
  if (parsedName === sectionName) {
511
- return { filePath: join(pagePath, file), stableName: sectionName, prefix }
537
+ return { filePath: join(pagePath, file), stableName: sectionName, prefix, isChild }
512
538
  }
513
539
  }
514
540
 
@@ -618,8 +644,8 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
618
644
  // Convert markdown to ProseMirror
619
645
  const proseMirrorContent = markdownToProseMirror(markdown)
620
646
 
621
- // Extract @ component references → inline children (mutates doc)
622
- const inlineChildren = extractInlineChildren(proseMirrorContent)
647
+ // Extract @ component references → insets (mutates doc)
648
+ const insets = extractInsets(proseMirrorContent)
623
649
 
624
650
  // Support 'data:' shorthand for collection fetch
625
651
  // data: team → fetch: { collection: team }
@@ -643,7 +669,7 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
643
669
  params: { ...params, ...props },
644
670
  content: proseMirrorContent,
645
671
  fetch: parseFetchConfig(resolvedFetch),
646
- ...(inlineChildren.length > 0 ? { inlineChildren } : {}),
672
+ ...(insets.length > 0 ? { insets } : {}),
647
673
  subsections: []
648
674
  }
649
675
 
@@ -657,41 +683,80 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
657
683
  }
658
684
 
659
685
  /**
660
- * Build section hierarchy from flat list.
661
- * Hierarchy is determined by comma separators:
662
- * - "1", "1.5", "2" → all top-level (dots are for ordering)
663
- * - "1,1", "1,2" → children of section "1"
664
- * - "1.5,1" → child of section "1.5"
686
+ * Process `nest:` config from page.yml to attach child sections to parents.
687
+ *
688
+ * nest:
689
+ * features: [card-a, card-b]
690
+ * card-a: [sub-1, sub-2]
691
+ *
692
+ * For each parent→children mapping, finds the parent section in the list,
693
+ * processes child files (expected to use @ prefix), and attaches as subsections.
694
+ * nest: overrides any inline nesting from sections: config.
695
+ *
696
+ * @param {Array} sections - Built top-level sections
697
+ * @param {Object} nestConfig - The nest: object from page.yml
698
+ * @param {string} pagePath - Path to page directory
699
+ * @param {string} siteRoot - Site root for asset resolution
700
+ * @param {string[]} cachedFiles - Pre-read directory listing
701
+ * @returns {Object} { assetCollection, iconCollection, lastModified, attachedChildren }
665
702
  */
666
- function buildSectionHierarchy(sections) {
667
- const sectionMap = new Map()
668
- const topLevel = []
669
-
670
- // First pass: create map
671
- for (const section of sections) {
672
- sectionMap.set(section.id, section)
703
+ async function processNesting(sections, nestConfig, pagePath, siteRoot, cachedFiles) {
704
+ const result = {
705
+ assetCollection: { assets: {}, hasExplicitPoster: new Set(), hasExplicitPreview: new Set() },
706
+ iconCollection: { icons: new Set(), bySource: new Map() },
707
+ lastModified: null,
708
+ attachedChildren: new Set(),
673
709
  }
674
710
 
675
- // Second pass: build hierarchy (comma = hierarchy)
676
- for (const section of sections) {
677
- if (!section.id.includes(',')) {
678
- topLevel.push(section)
711
+ if (!nestConfig || typeof nestConfig !== 'object') return result
712
+
713
+ for (const [parentName, childNames] of Object.entries(nestConfig)) {
714
+ if (!Array.isArray(childNames)) continue
715
+
716
+ // Find parent section by stableId
717
+ const parent = sections.find(s => s.stableId === parentName)
718
+ if (!parent) {
719
+ console.warn(`[content-collector] nest: parent section '${parentName}' not found`)
679
720
  continue
680
721
  }
681
722
 
682
- const parts = section.id.split(',')
683
- const parentId = parts.slice(0, -1).join(',')
684
- const parent = sectionMap.get(parentId)
723
+ // Override any existing subsections (nest: wins over inline sections: nesting)
724
+ parent.subsections = []
685
725
 
686
- if (parent) {
726
+ let childIndex = 1
727
+ for (const childName of childNames) {
728
+ const found = findSectionFile(pagePath, childName, cachedFiles)
729
+ if (!found) {
730
+ console.warn(`[content-collector] nest: child section '${childName}' not found`)
731
+ continue
732
+ }
733
+
734
+ // Validate @ prefix
735
+ if (!found.isChild) {
736
+ console.warn(
737
+ `[content-collector] Section '${childName}' is declared as a child of '${parentName}' ` +
738
+ `but the file doesn't use the @ prefix`
739
+ )
740
+ }
741
+
742
+ const childId = `${parent.id}.${childIndex}`
743
+ const { section, assetCollection, iconCollection } =
744
+ await processMarkdownFile(found.filePath, childId, siteRoot, childName)
687
745
  parent.subsections.push(section)
688
- } else {
689
- // Orphan subsection - add to top level
690
- topLevel.push(section)
746
+ result.assetCollection = mergeAssetCollections(result.assetCollection, assetCollection)
747
+ result.iconCollection = mergeIconCollections(result.iconCollection, iconCollection)
748
+ result.attachedChildren.add(childName)
749
+
750
+ const fileStat = await stat(found.filePath)
751
+ if (!result.lastModified || fileStat.mtime > result.lastModified) {
752
+ result.lastModified = fileStat.mtime
753
+ }
754
+
755
+ childIndex++
691
756
  }
692
757
  }
693
758
 
694
- return topLevel
759
+ return result
695
760
  }
696
761
 
697
762
  /**
@@ -828,11 +893,14 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
828
893
 
829
894
  if (sectionsConfig === undefined || sectionsConfig === '*' || sectionsParsed?.mode === 'all') {
830
895
  // Default behavior: discover all .md files, sort by numeric prefix
896
+ // @-prefixed files are excluded from top-level (they're child sections for nest:)
831
897
  const files = await readdir(pagePath)
832
898
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
833
899
 
834
900
  const sections = []
835
901
  for (const file of mdFiles) {
902
+ if (isChildSection(file)) continue // Skip @-prefixed child sections
903
+
836
904
  const { name } = parse(file)
837
905
  const { prefix, name: stableName } = parseNumericPrefix(name)
838
906
  const id = prefix || name
@@ -852,17 +920,45 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
852
920
  }
853
921
  }
854
922
 
855
- // Build hierarchy from dot notation
856
- hierarchicalSections = buildSectionHierarchy(sections)
923
+ // Process nest: config to attach child sections to parents
924
+ const nestResult = await processNesting(sections, pageConfig.nest, pagePath, siteRoot, files)
925
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, nestResult.assetCollection)
926
+ pageIconCollection = mergeIconCollections(pageIconCollection, nestResult.iconCollection)
927
+ if (nestResult.lastModified && (!lastModified || nestResult.lastModified > lastModified)) {
928
+ lastModified = nestResult.lastModified
929
+ }
930
+
931
+ // Warn about orphaned @-prefixed files (no parent in nest: or sections:)
932
+ const childFiles = mdFiles.filter(isChildSection)
933
+ for (const file of childFiles) {
934
+ const { name } = parse(file)
935
+ const stripped = stripAtPrefix(name)
936
+ const { name: childName } = parseNumericPrefix(stripped)
937
+ if (!nestResult.attachedChildren.has(childName)) {
938
+ console.warn(`[content-collector] Orphaned child section: ${file} (no parent declared in nest:)`)
939
+ // Graceful degradation: add to top level to avoid silent data loss
940
+ const id = String(sections.length + 1)
941
+ const { section, assetCollection, iconCollection } =
942
+ await processMarkdownFile(join(pagePath, file), id, siteRoot, childName)
943
+ sections.push(section)
944
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, assetCollection)
945
+ pageIconCollection = mergeIconCollections(pageIconCollection, iconCollection)
946
+ }
947
+ }
948
+
949
+ hierarchicalSections = sections
857
950
 
858
951
  } else if (sectionsParsed?.mode === 'inclusive') {
859
952
  // Inclusive: pinned sections + auto-discovered rest via '...' wildcard
953
+ // @-prefixed files are excluded from auto-discovery (they're child sections for nest:)
860
954
  const files = await readdir(pagePath)
861
955
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
862
956
 
863
- // Build name → file info map from discovered files
957
+ // Build name → file info map from discovered files (excluding @ children)
864
958
  const discoveredMap = new Map()
865
959
  for (const file of mdFiles) {
960
+ if (isChildSection(file)) continue // Skip @-prefixed child sections
961
+
866
962
  const { name } = parse(file)
867
963
  const { prefix, name: stableName } = parseNumericPrefix(name)
868
964
  const key = stableName || name
@@ -924,16 +1020,37 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
924
1020
  sectionIndex++
925
1021
  }
926
1022
 
927
- hierarchicalSections = buildSectionHierarchy(sections)
1023
+ // Process nest: config to attach child sections (overrides inline nesting)
1024
+ if (pageConfig.nest) {
1025
+ const nestResult = await processNesting(sections, pageConfig.nest, pagePath, siteRoot, files)
1026
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, nestResult.assetCollection)
1027
+ pageIconCollection = mergeIconCollections(pageIconCollection, nestResult.iconCollection)
1028
+ if (nestResult.lastModified && (!lastModified || nestResult.lastModified > lastModified)) {
1029
+ lastModified = nestResult.lastModified
1030
+ }
1031
+ }
1032
+
1033
+ hierarchicalSections = sections
928
1034
 
929
1035
  } else if (Array.isArray(sectionsConfig) && sectionsConfig.length > 0) {
930
1036
  // Strict: explicit sections array (only listed sections processed)
1037
+ const cachedFiles = await readdir(pagePath)
931
1038
  const result = await processExplicitSections(sectionsConfig, pagePath, siteRoot)
932
1039
  hierarchicalSections = result.sections
933
1040
  pageAssetCollection = result.assetCollection
934
1041
  pageIconCollection = result.iconCollection
935
1042
  lastModified = result.lastModified
936
1043
 
1044
+ // Process nest: config (overrides inline nesting from sections:)
1045
+ if (pageConfig.nest) {
1046
+ const nestResult = await processNesting(hierarchicalSections, pageConfig.nest, pagePath, siteRoot, cachedFiles)
1047
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, nestResult.assetCollection)
1048
+ pageIconCollection = mergeIconCollections(pageIconCollection, nestResult.iconCollection)
1049
+ if (nestResult.lastModified && (!lastModified || nestResult.lastModified > lastModified)) {
1050
+ lastModified = nestResult.lastModified
1051
+ }
1052
+ }
1053
+
937
1054
  } else {
938
1055
  // Empty sections (null, empty array, or invalid) = pure route with no content
939
1056
  // hierarchicalSections stays empty, lastModified stays null
@@ -1866,7 +1983,7 @@ export {
1866
1983
  parseWildcardArray,
1867
1984
  applyWildcardOrder,
1868
1985
  getDirectChildName,
1869
- extractInlineChildren
1986
+ extractInsets
1870
1987
  }
1871
1988
 
1872
1989
  export default collectSiteContent