@uniweb/build 0.8.0 → 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.0",
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.2",
53
+ "@uniweb/content-reader": "1.1.4",
54
54
  "@uniweb/schemas": "0.2.1",
55
- "@uniweb/runtime": "0.6.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.1"
64
+ "@uniweb/core": "0.5.3"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "vite": {
@@ -190,6 +190,11 @@ export function extractRuntimeSchema(fullMeta) {
190
190
 
191
191
  const runtime = {}
192
192
 
193
+ // Inset flag: signals this component is available for inline @ references
194
+ if (fullMeta.inset) {
195
+ runtime.inset = true
196
+ }
197
+
193
198
  // Background opt-out: 'self' means the component renders its own background
194
199
  // layer (solid colors, insets, effects), so the runtime skips its Background.
195
200
  if (fullMeta.background) {
@@ -178,6 +178,45 @@ async function readYamlFile(filePath) {
178
178
  }
179
179
  }
180
180
 
181
+ /**
182
+ * Extract inset references from a ProseMirror document.
183
+ *
184
+ * Walks top-level nodes for `inset_ref` (produced by content-reader
185
+ * for `![alt](@ComponentName){params}` syntax). Each ref is removed from the
186
+ * document and replaced with an `inset_placeholder` node carrying a
187
+ * unique refId. The extracted refs are returned as an array.
188
+ *
189
+ * @param {Object} doc - ProseMirror document (mutated in place)
190
+ * @returns {Array} Array of { refId, type, params, description }
191
+ */
192
+ function extractInsets(doc) {
193
+ if (!doc?.content || !Array.isArray(doc.content)) return []
194
+
195
+ const insets = []
196
+ let refIndex = 0
197
+
198
+ for (let i = 0; i < doc.content.length; i++) {
199
+ const node = doc.content[i]
200
+ if (node.type === 'inset_ref') {
201
+ const { component, alt, ...params } = node.attrs || {}
202
+ const refId = `inset_${refIndex++}`
203
+ insets.push({
204
+ refId,
205
+ type: component,
206
+ params: Object.keys(params).length > 0 ? params : {},
207
+ description: alt || null,
208
+ })
209
+ // Replace in-place with placeholder
210
+ doc.content[i] = {
211
+ type: 'inset_placeholder',
212
+ attrs: { refId },
213
+ }
214
+ }
215
+ }
216
+
217
+ return insets
218
+ }
219
+
181
220
  /**
182
221
  * Check if a file is a markdown file that should be processed.
183
222
  * Excludes:
@@ -192,6 +231,23 @@ function isMarkdownFile(filename) {
192
231
  return true
193
232
  }
194
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
+
195
251
  /**
196
252
  * Check if a folder should be ignored.
197
253
  * Excludes folders starting with _ (drafts/private).
@@ -330,11 +386,9 @@ function resolveMounts(pathsConfig, sitePath, pagesPath) {
330
386
  * Supports:
331
387
  * - Simple: "1", "2", "3"
332
388
  * - Decimal ordering: "1.5" (between 1 and 2), "2.5" (between 2 and 3)
333
- * - Hierarchy via comma: "1,1" (child of 1), "1,2" (second child of 1)
334
- * - Mixed: "1.5,1" (child of section 1.5)
335
389
  */
336
390
  function parseNumericPrefix(filename) {
337
- const match = filename.match(/^(\d+(?:[.,]\d+)*)-?(.*)$/)
391
+ const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
338
392
  if (match) {
339
393
  return { prefix: match[1], name: match[2] || match[1] }
340
394
  }
@@ -343,19 +397,20 @@ function parseNumericPrefix(filename) {
343
397
 
344
398
  /**
345
399
  * Compare filenames for sorting by numeric prefix.
346
- * Both . and , are treated as separators for sorting purposes.
347
- * 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.
348
401
  */
349
402
  function compareFilenames(a, b) {
350
- const { prefix: prefixA } = parseNumericPrefix(parse(a).name)
351
- 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)
352
407
 
353
408
  if (!prefixA && !prefixB) return a.localeCompare(b)
354
409
  if (!prefixA) return 1
355
410
  if (!prefixB) return -1
356
411
 
357
- const partsA = prefixA.split(/[.,]/).map(Number)
358
- const partsB = prefixB.split(/[.,]/).map(Number)
412
+ const partsA = prefixA.split('.').map(Number)
413
+ const partsB = prefixB.split('.').map(Number)
359
414
 
360
415
  for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
361
416
  const numA = partsA[i] ?? 0
@@ -449,27 +504,37 @@ function applyWildcardOrder(items, parsed) {
449
504
  }
450
505
 
451
506
  /**
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").
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
454
509
  *
455
510
  * @param {string} pagePath - Directory containing section files
456
- * @param {string} sectionName - Logical section name (e.g., 'hero')
511
+ * @param {string} sectionName - Logical section name (e.g., 'hero', 'card-a')
457
512
  * @param {string[]} [cachedFiles] - Pre-read directory listing (optimization)
458
- * @returns {{ filePath: string, stableName: string, prefix: string|null }|null}
513
+ * @returns {{ filePath: string, stableName: string, prefix: string|null, isChild: boolean }|null}
459
514
  */
460
515
  function findSectionFile(pagePath, sectionName, cachedFiles) {
516
+ // Try exact match: name.md
461
517
  const exactPath = join(pagePath, `${sectionName}.md`)
462
518
  if (existsSync(exactPath)) {
463
- return { filePath: exactPath, stableName: sectionName, prefix: null }
519
+ return { filePath: exactPath, stableName: sectionName, prefix: null, isChild: false }
520
+ }
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 }
464
526
  }
465
527
 
528
+ // Try prefix-based: N-name.md or @N-name.md
466
529
  const files = cachedFiles || []
467
530
  for (const file of files) {
468
531
  if (!isMarkdownFile(file)) continue
469
532
  const { name } = parse(file)
470
- 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)
471
536
  if (parsedName === sectionName) {
472
- return { filePath: join(pagePath, file), stableName: sectionName, prefix }
537
+ return { filePath: join(pagePath, file), stableName: sectionName, prefix, isChild }
473
538
  }
474
539
  }
475
540
 
@@ -579,6 +644,9 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
579
644
  // Convert markdown to ProseMirror
580
645
  const proseMirrorContent = markdownToProseMirror(markdown)
581
646
 
647
+ // Extract @ component references → insets (mutates doc)
648
+ const insets = extractInsets(proseMirrorContent)
649
+
582
650
  // Support 'data:' shorthand for collection fetch
583
651
  // data: team → fetch: { collection: team }
584
652
  // data: [team, articles] → fetch: { collection: team } (first item, others via inheritData)
@@ -601,6 +669,7 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
601
669
  params: { ...params, ...props },
602
670
  content: proseMirrorContent,
603
671
  fetch: parseFetchConfig(resolvedFetch),
672
+ ...(insets.length > 0 ? { insets } : {}),
604
673
  subsections: []
605
674
  }
606
675
 
@@ -614,41 +683,80 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
614
683
  }
615
684
 
616
685
  /**
617
- * Build section hierarchy from flat list.
618
- * Hierarchy is determined by comma separators:
619
- * - "1", "1.5", "2" → all top-level (dots are for ordering)
620
- * - "1,1", "1,2" → children of section "1"
621
- * - "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 }
622
702
  */
623
- function buildSectionHierarchy(sections) {
624
- const sectionMap = new Map()
625
- const topLevel = []
626
-
627
- // First pass: create map
628
- for (const section of sections) {
629
- 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(),
630
709
  }
631
710
 
632
- // Second pass: build hierarchy (comma = hierarchy)
633
- for (const section of sections) {
634
- if (!section.id.includes(',')) {
635
- 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`)
636
720
  continue
637
721
  }
638
722
 
639
- const parts = section.id.split(',')
640
- const parentId = parts.slice(0, -1).join(',')
641
- const parent = sectionMap.get(parentId)
723
+ // Override any existing subsections (nest: wins over inline sections: nesting)
724
+ parent.subsections = []
725
+
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
+ }
642
741
 
643
- if (parent) {
742
+ const childId = `${parent.id}.${childIndex}`
743
+ const { section, assetCollection, iconCollection } =
744
+ await processMarkdownFile(found.filePath, childId, siteRoot, childName)
644
745
  parent.subsections.push(section)
645
- } else {
646
- // Orphan subsection - add to top level
647
- 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++
648
756
  }
649
757
  }
650
758
 
651
- return topLevel
759
+ return result
652
760
  }
653
761
 
654
762
  /**
@@ -785,11 +893,14 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
785
893
 
786
894
  if (sectionsConfig === undefined || sectionsConfig === '*' || sectionsParsed?.mode === 'all') {
787
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:)
788
897
  const files = await readdir(pagePath)
789
898
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
790
899
 
791
900
  const sections = []
792
901
  for (const file of mdFiles) {
902
+ if (isChildSection(file)) continue // Skip @-prefixed child sections
903
+
793
904
  const { name } = parse(file)
794
905
  const { prefix, name: stableName } = parseNumericPrefix(name)
795
906
  const id = prefix || name
@@ -809,17 +920,45 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
809
920
  }
810
921
  }
811
922
 
812
- // Build hierarchy from dot notation
813
- 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
814
950
 
815
951
  } else if (sectionsParsed?.mode === 'inclusive') {
816
952
  // Inclusive: pinned sections + auto-discovered rest via '...' wildcard
953
+ // @-prefixed files are excluded from auto-discovery (they're child sections for nest:)
817
954
  const files = await readdir(pagePath)
818
955
  const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
819
956
 
820
- // Build name → file info map from discovered files
957
+ // Build name → file info map from discovered files (excluding @ children)
821
958
  const discoveredMap = new Map()
822
959
  for (const file of mdFiles) {
960
+ if (isChildSection(file)) continue // Skip @-prefixed child sections
961
+
823
962
  const { name } = parse(file)
824
963
  const { prefix, name: stableName } = parseNumericPrefix(name)
825
964
  const key = stableName || name
@@ -881,16 +1020,37 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
881
1020
  sectionIndex++
882
1021
  }
883
1022
 
884
- 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
885
1034
 
886
1035
  } else if (Array.isArray(sectionsConfig) && sectionsConfig.length > 0) {
887
1036
  // Strict: explicit sections array (only listed sections processed)
1037
+ const cachedFiles = await readdir(pagePath)
888
1038
  const result = await processExplicitSections(sectionsConfig, pagePath, siteRoot)
889
1039
  hierarchicalSections = result.sections
890
1040
  pageAssetCollection = result.assetCollection
891
1041
  pageIconCollection = result.iconCollection
892
1042
  lastModified = result.lastModified
893
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
+
894
1054
  } else {
895
1055
  // Empty sections (null, empty array, or invalid) = pure route with no content
896
1056
  // hierarchicalSections stays empty, lastModified stays null
@@ -1822,7 +1982,8 @@ export {
1822
1982
  extractItemName,
1823
1983
  parseWildcardArray,
1824
1984
  applyWildcardOrder,
1825
- getDirectChildName
1985
+ getDirectChildName,
1986
+ extractInsets
1826
1987
  }
1827
1988
 
1828
1989
  export default collectSiteContent