@uniweb/build 0.6.4 → 0.6.5

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.4",
3
+ "version": "0.6.5",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/schemas": "0.2.1",
54
- "@uniweb/runtime": "0.5.13",
54
+ "@uniweb/runtime": "0.5.14",
55
55
  "@uniweb/content-reader": "1.1.2"
56
56
  },
57
57
  "peerDependencies": {
@@ -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.4.2"
64
+ "@uniweb/core": "0.4.3"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "vite": {
@@ -41,7 +41,7 @@ export function extractTranslatableContent(siteContent) {
41
41
  for (const layoutKey of ['header', 'footer', 'left', 'right']) {
42
42
  const layoutPage = siteContent[layoutKey]
43
43
  if (layoutPage?.sections) {
44
- const pageRoute = layoutPage.route || `/@${layoutKey}`
44
+ const pageRoute = layoutPage.route || `/layout/${layoutKey}`
45
45
  for (const section of layoutPage.sections) {
46
46
  extractFromSection(section, pageRoute, units)
47
47
  }
package/src/i18n/merge.js CHANGED
@@ -80,7 +80,7 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
80
80
  for (const layoutKey of ['header', 'footer', 'left', 'right']) {
81
81
  const layoutPage = translated[layoutKey]
82
82
  if (layoutPage?.sections) {
83
- const pageRoute = layoutPage.route || `/@${layoutKey}`
83
+ const pageRoute = layoutPage.route || `/layout/${layoutKey}`
84
84
  for (const section of layoutPage.sections) {
85
85
  translateSectionSync(section, pageRoute, translations, fallbackToSource)
86
86
  }
@@ -132,8 +132,8 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
132
132
  for (const layoutKey of ['header', 'footer', 'left', 'right']) {
133
133
  const layoutPage = translated[layoutKey]
134
134
  if (layoutPage?.sections) {
135
- // Ensure route is set for context matching (extract uses /@header, etc.)
136
- if (!layoutPage.route) layoutPage.route = `/@${layoutKey}`
135
+ // Ensure route is set for context matching
136
+ if (!layoutPage.route) layoutPage.route = `/layout/${layoutKey}`
137
137
  for (const section of layoutPage.sections) {
138
138
  await translateSectionAsync(section, layoutPage, translations, {
139
139
  fallbackToSource,
@@ -41,11 +41,6 @@ export function extractSearchContent(siteContent, options = {}) {
41
41
  continue
42
42
  }
43
43
 
44
- // Skip special pages (header, footer, etc.)
45
- if (pageRoute.startsWith('/@')) {
46
- continue
47
- }
48
-
49
44
  // Skip pages marked as noindex
50
45
  if (page.seo?.noindex) {
51
46
  continue
@@ -26,7 +26,7 @@
26
26
  */
27
27
 
28
28
  import { readFile, readdir, stat, writeFile, mkdir, copyFile } from 'node:fs/promises'
29
- import { join, basename, extname, dirname, relative } from 'node:path'
29
+ import { join, basename, extname, dirname, relative, resolve } from 'node:path'
30
30
  import { existsSync } from 'node:fs'
31
31
  import yaml from 'js-yaml'
32
32
  import { applyFilter, applySort } from './data-fetcher.js'
@@ -421,8 +421,9 @@ async function processContentItem(dir, filename, config, siteRoot) {
421
421
  * @param {Object} config - Parsed collection config
422
422
  * @returns {Promise<Array>} Array of processed items
423
423
  */
424
- async function collectItems(siteDir, config) {
425
- const collectionDir = join(siteDir, config.path)
424
+ async function collectItems(siteDir, config, collectionsBase) {
425
+ const base = collectionsBase || siteDir
426
+ const collectionDir = resolve(base, config.path)
426
427
 
427
428
  // Check if collection directory exists
428
429
  if (!existsSync(collectionDir)) {
@@ -496,7 +497,7 @@ async function collectItems(siteDir, config) {
496
497
  * })
497
498
  * // { articles: [...], products: [...] }
498
499
  */
499
- export async function processCollections(siteDir, collectionsConfig) {
500
+ export async function processCollections(siteDir, collectionsConfig, collectionsBase) {
500
501
  if (!collectionsConfig || typeof collectionsConfig !== 'object') {
501
502
  return {}
502
503
  }
@@ -505,7 +506,7 @@ export async function processCollections(siteDir, collectionsConfig) {
505
506
 
506
507
  for (const [name, config] of Object.entries(collectionsConfig)) {
507
508
  const parsed = parseCollectionConfig(name, config)
508
- const items = await collectItems(siteDir, parsed)
509
+ const items = await collectItems(siteDir, parsed, collectionsBase)
509
510
  results[name] = items
510
511
  console.log(`[collection-processor] Processed ${name}: ${items.length} items`)
511
512
  }
@@ -328,7 +328,21 @@ export async function defineSiteConfig(options = {}) {
328
328
  server: {
329
329
  fs: {
330
330
  // Allow parent directory for foundation sibling access
331
- allow: ['..']
331
+ // Plus any external content paths from site.yml paths: group
332
+ allow: (() => {
333
+ const allowed = ['..']
334
+ const parentDir = resolve(siteRoot, '..')
335
+ const paths = siteConfig.paths || {}
336
+ for (const key of ['pages', 'layout', 'collections']) {
337
+ if (paths[key]) {
338
+ const resolved = resolve(siteRoot, paths[key])
339
+ if (!resolved.startsWith(parentDir)) {
340
+ allowed.push(resolved)
341
+ }
342
+ }
343
+ }
344
+ return allowed
345
+ })()
332
346
  },
333
347
  ...(siteConfig.build?.port && { port: siteConfig.build.port }),
334
348
  ...serverOverrides
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Content Collector
3
3
  *
4
- * Collects site content from a pages/ directory structure:
4
+ * Collects site content from a site directory structure:
5
5
  * - site.yml: Site configuration
6
6
  * - pages/: Directory of page folders
7
7
  * - page.yml: Page metadata
8
8
  * - *.md: Section content with YAML frontmatter
9
+ * - layout/: Layout panel folders (header, footer, left, right)
9
10
  *
10
11
  * Section frontmatter reserved properties:
11
12
  * - type: Component type (e.g., "Hero", "Features")
@@ -23,7 +24,7 @@
23
24
  */
24
25
 
25
26
  import { readFile, readdir, stat } from 'node:fs/promises'
26
- import { join, parse } from 'node:path'
27
+ import { join, parse, resolve } from 'node:path'
27
28
  import { existsSync } from 'node:fs'
28
29
  import yaml from 'js-yaml'
29
30
  import { collectSectionAssets, mergeAssetCollections } from './assets.js'
@@ -502,10 +503,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
502
503
 
503
504
  // First, calculate the folder-based route (what the route would be without index handling)
504
505
  let folderRoute
505
- if (pageName.startsWith('@')) {
506
- // Special pages (layout areas) keep their @ prefix
507
- folderRoute = parentRoute === '/' ? `/@${pageName.slice(1)}` : `${parentRoute}/@${pageName.slice(1)}`
508
- } else if (isDynamic) {
506
+ if (isDynamic) {
509
507
  // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching)
510
508
  folderRoute = parentRoute === '/' ? `/:${paramName}` : `${parentRoute}/:${paramName}`
511
509
  } else {
@@ -648,10 +646,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
648
646
  icons: new Set(),
649
647
  bySource: new Map()
650
648
  }
651
- let header = null
652
- let footer = null
653
- let left = null
654
- let right = null
655
649
  let notFound = null
656
650
  const versionedScopes = new Map() // scope route → versionMeta
657
651
 
@@ -737,8 +731,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
737
731
  for (const [scope, meta] of subResult.versionedScopes) {
738
732
  versionedScopes.set(scope, meta)
739
733
  }
740
- } else if (!entry.startsWith('@')) {
741
- // Non-version, non-special folders in a versioned section
734
+ } else {
735
+ // Non-version folders in a versioned section
742
736
  // These could be shared across versions - process normally
743
737
  const result = await processPage(entryPath, entry, siteRoot, {
744
738
  isIndex: false,
@@ -755,14 +749,14 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
755
749
  }
756
750
 
757
751
  // Return early - we've handled all children
758
- return { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes }
752
+ return { pages, assetCollection, iconCollection, notFound, versionedScopes }
759
753
  }
760
754
 
761
755
  // Determine which page is the index for this level
762
756
  // A directory with its own .md content is a real page, not a container —
763
757
  // never promote a child as index, even if explicit config says so
764
758
  // (that config is likely a leftover from before the directory had content)
765
- const regularFolders = pageFolders.filter(f => !f.name.startsWith('@'))
759
+ const regularFolders = pageFolders
766
760
  const hasExplicitOrder = orderConfig?.index || (Array.isArray(orderConfig?.pages) && orderConfig.pages.length > 0)
767
761
  const hasMdContent = entries.some(e => isMarkdownFile(e))
768
762
  const indexPageName = hasMdContent ? null : determineIndexPage(orderConfig, regularFolders)
@@ -771,12 +765,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
771
765
  for (const folder of pageFolders) {
772
766
  const { name: entry, path: entryPath, childOrderConfig } = folder
773
767
  const isIndex = entry === indexPageName
774
- const isSpecial = entry.startsWith('@')
775
768
 
776
769
  // Process this directory as a page
777
770
  // Pass parentFetch so dynamic routes can inherit parent's data schema
778
771
  const result = await processPage(entryPath, entry, siteRoot, {
779
- isIndex: isIndex && !isSpecial,
772
+ isIndex,
780
773
  parentRoute,
781
774
  parentFetch,
782
775
  versionContext
@@ -787,27 +780,15 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
787
780
  assetCollection = mergeAssetCollections(assetCollection, pageAssets)
788
781
  iconCollection = mergeIconCollections(iconCollection, pageIcons)
789
782
 
790
- // Handle special pages (layout areas and 404) - only at root level
791
- if (parentRoute === '/') {
792
- if (entry === '@header') {
793
- header = page
794
- } else if (entry === '@footer') {
795
- footer = page
796
- } else if (entry === '@left') {
797
- left = page
798
- } else if (entry === '@right') {
799
- right = page
800
- } else if (entry === '404') {
801
- notFound = page
802
- } else {
803
- pages.push(page)
804
- }
783
+ // Handle 404 page - only at root level
784
+ if (parentRoute === '/' && entry === '404') {
785
+ notFound = page
805
786
  } else {
806
787
  pages.push(page)
807
788
  }
808
789
 
809
- // Recursively process subdirectories (but not special @ directories)
810
- if (!isSpecial) {
790
+ // Recursively process subdirectories
791
+ {
811
792
  // The child route depends on whether this page is the index
812
793
  // For explicit index (from site.yml `index:` or `pages:`), children use parentRoute
813
794
  // since that's a true structural promotion. For auto-detected index, children use
@@ -830,7 +811,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
830
811
  }
831
812
  }
832
813
 
833
- return { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes }
814
+ return { pages, assetCollection, iconCollection, notFound, versionedScopes }
834
815
  }
835
816
 
836
817
  /**
@@ -863,6 +844,43 @@ async function loadFoundationVars(foundationPath) {
863
844
  }
864
845
  }
865
846
 
847
+ /**
848
+ * Collect layout panels from the layout/ directory
849
+ *
850
+ * Layout panels (header, footer, left, right) are persistent regions
851
+ * that appear on every page. They live in layout/ parallel to pages/.
852
+ *
853
+ * @param {string} layoutDir - Path to layout directory
854
+ * @param {string} siteRoot - Path to site root
855
+ * @returns {Promise<Object>} { header, footer, left, right }
856
+ */
857
+ async function collectLayoutPanels(layoutDir, siteRoot) {
858
+ const result = { header: null, footer: null, left: null, right: null }
859
+
860
+ if (!existsSync(layoutDir)) return result
861
+
862
+ const knownPanels = ['header', 'footer', 'left', 'right']
863
+ const entries = await readdir(layoutDir)
864
+
865
+ for (const entry of entries) {
866
+ if (!knownPanels.includes(entry)) continue
867
+ const entryPath = join(layoutDir, entry)
868
+ const stats = await stat(entryPath)
869
+ if (!stats.isDirectory()) continue
870
+
871
+ const pageResult = await processPage(entryPath, entry, siteRoot, {
872
+ isIndex: false,
873
+ parentRoute: '/layout'
874
+ })
875
+
876
+ if (pageResult) {
877
+ result[entry] = pageResult.page
878
+ }
879
+ }
880
+
881
+ return result
882
+ }
883
+
866
884
  /**
867
885
  * Collect all site content
868
886
  *
@@ -873,10 +891,18 @@ async function loadFoundationVars(foundationPath) {
873
891
  */
874
892
  export async function collectSiteContent(sitePath, options = {}) {
875
893
  const { foundationPath } = options
876
- const pagesPath = join(sitePath, 'pages')
877
894
 
878
895
  // Read site config and raw theme config
879
896
  const siteConfig = await readYamlFile(join(sitePath, 'site.yml'))
897
+
898
+ // Resolve content paths from site.yml paths: group, defaulting to standard locations
899
+ const pagesPath = siteConfig.paths?.pages
900
+ ? resolve(sitePath, siteConfig.paths.pages)
901
+ : join(sitePath, 'pages')
902
+
903
+ const layoutPath = siteConfig.paths?.layout
904
+ ? resolve(sitePath, siteConfig.paths.layout)
905
+ : join(sitePath, 'layout')
880
906
  const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
881
907
 
882
908
  // Load foundation vars and process theme
@@ -907,8 +933,11 @@ export async function collectSiteContent(sitePath, options = {}) {
907
933
  index: siteConfig.index
908
934
  }
909
935
 
936
+ // Collect layout panels from layout/ directory
937
+ const { header, footer, left, right } = await collectLayoutPanels(layoutPath, sitePath)
938
+
910
939
  // Recursively collect all pages
911
- const { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes } =
940
+ const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
912
941
  await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig)
913
942
 
914
943
  // Deduplicate: remove content-less container pages whose route duplicates
@@ -376,6 +376,9 @@ export function siteContentPlugin(options = {}) {
376
376
  let collectionTranslations = {} // Cache: { locale: collection translations }
377
377
  let localesDir = 'locales' // Default, updated from site config
378
378
  let collectionsConfig = null // Cached for watcher setup
379
+ let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
380
+ let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
381
+ let resolvedCollectionsBase = null // Resolved from site.yml collectionsDir
379
382
 
380
383
  /**
381
384
  * Load translations for a specific locale
@@ -486,15 +489,43 @@ export function siteContentPlugin(options = {}) {
486
489
  const earlyContent = await collectSiteContent(resolvedSitePath, { foundationPath })
487
490
  collectionsConfig = earlyContent.config?.collections
488
491
 
492
+ // Resolve content directory paths from site.yml paths: group
493
+ const paths = earlyContent?.config?.paths || {}
494
+ resolvedPagesPath = paths.pages
495
+ ? resolve(resolvedSitePath, paths.pages)
496
+ : resolve(resolvedSitePath, pagesDir)
497
+ resolvedLayoutPath = paths.layout
498
+ ? resolve(resolvedSitePath, paths.layout)
499
+ : resolve(resolvedSitePath, 'layout')
500
+ resolvedCollectionsBase = paths.collections
501
+ ? resolve(resolvedSitePath, paths.collections)
502
+ : null
503
+
489
504
  if (collectionsConfig) {
490
505
  console.log('[site-content] Processing content collections...')
491
- const collections = await processCollections(resolvedSitePath, collectionsConfig)
506
+ const collections = await processCollections(resolvedSitePath, collectionsConfig, resolvedCollectionsBase)
492
507
  await writeCollectionFiles(resolvedSitePath, collections)
493
508
  }
494
509
  } catch (err) {
495
510
  console.warn('[site-content] Early collection processing failed:', err.message)
496
511
  }
497
512
  }
513
+
514
+ // In production, resolve content paths from site.yml directly
515
+ if (isProduction || !resolvedPagesPath) {
516
+ const { readSiteConfig } = await import('./config.js')
517
+ const cfg = readSiteConfig(resolvedSitePath)
518
+ const paths = cfg.paths || {}
519
+ resolvedPagesPath = paths.pages
520
+ ? resolve(resolvedSitePath, paths.pages)
521
+ : resolve(resolvedSitePath, pagesDir)
522
+ resolvedLayoutPath = paths.layout
523
+ ? resolve(resolvedSitePath, paths.layout)
524
+ : resolve(resolvedSitePath, 'layout')
525
+ resolvedCollectionsBase = paths.collections
526
+ ? resolve(resolvedSitePath, paths.collections)
527
+ : null
528
+ }
498
529
  },
499
530
 
500
531
  async buildStart() {
@@ -508,7 +539,7 @@ export function siteContentPlugin(options = {}) {
508
539
  // In production, do it here
509
540
  if (isProduction && siteContent.config?.collections) {
510
541
  console.log('[site-content] Processing content collections...')
511
- const collections = await processCollections(resolvedSitePath, siteContent.config.collections)
542
+ const collections = await processCollections(resolvedSitePath, siteContent.config.collections, resolvedCollectionsBase)
512
543
  await writeCollectionFiles(resolvedSitePath, collections)
513
544
  }
514
545
 
@@ -537,7 +568,6 @@ export function siteContentPlugin(options = {}) {
537
568
 
538
569
  // Watch for content changes in dev mode
539
570
  if (shouldWatch) {
540
- const pagesPath = resolve(resolvedSitePath, pagesDir)
541
571
  const siteYmlPath = resolve(resolvedSitePath, 'site.yml')
542
572
  const themeYmlPath = resolve(resolvedSitePath, 'theme.yml')
543
573
 
@@ -571,7 +601,7 @@ export function siteContentPlugin(options = {}) {
571
601
  // Use collectionsConfig (cached from configResolved) or siteContent
572
602
  const collections = collectionsConfig || siteContent?.config?.collections
573
603
  if (collections) {
574
- const processed = await processCollections(resolvedSitePath, collections)
604
+ const processed = await processCollections(resolvedSitePath, collections, resolvedCollectionsBase)
575
605
  await writeCollectionFiles(resolvedSitePath, processed)
576
606
  }
577
607
  // Send full reload to client
@@ -585,12 +615,24 @@ export function siteContentPlugin(options = {}) {
585
615
  // Track all watchers for cleanup
586
616
  const watchers = []
587
617
 
588
- // Watch pages directory
589
- try {
590
- watchers.push(watch(pagesPath, { recursive: true }, scheduleRebuild))
591
- console.log(`[site-content] Watching ${pagesPath}`)
592
- } catch (err) {
593
- console.warn('[site-content] Could not watch pages directory:', err.message)
618
+ // Watch pages directory (resolved from site.yml pagesDir or default)
619
+ if (existsSync(resolvedPagesPath)) {
620
+ try {
621
+ watchers.push(watch(resolvedPagesPath, { recursive: true }, scheduleRebuild))
622
+ console.log(`[site-content] Watching ${resolvedPagesPath}`)
623
+ } catch (err) {
624
+ console.warn('[site-content] Could not watch pages directory:', err.message)
625
+ }
626
+ }
627
+
628
+ // Watch layout directory (resolved from site.yml layoutDir or default)
629
+ if (existsSync(resolvedLayoutPath)) {
630
+ try {
631
+ watchers.push(watch(resolvedLayoutPath, { recursive: true }, scheduleRebuild))
632
+ console.log(`[site-content] Watching ${resolvedLayoutPath}`)
633
+ } catch (err) {
634
+ console.warn('[site-content] Could not watch layout directory:', err.message)
635
+ }
594
636
  }
595
637
 
596
638
  // Watch site.yml
@@ -611,10 +653,11 @@ export function siteContentPlugin(options = {}) {
611
653
  // Use collectionsConfig cached from configResolved (siteContent may be null here)
612
654
  if (collectionsConfig) {
613
655
  const contentPaths = new Set()
656
+ const collectionBase = resolvedCollectionsBase || resolvedSitePath
614
657
  for (const config of Object.values(collectionsConfig)) {
615
658
  const collectionPath = typeof config === 'string' ? config : config.path
616
659
  if (collectionPath) {
617
- contentPaths.add(resolve(resolvedSitePath, collectionPath))
660
+ contentPaths.add(resolve(collectionBase, collectionPath))
618
661
  }
619
662
  }
620
663