@uniweb/build 0.8.25 → 0.8.27

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.25",
3
+ "version": "0.8.27",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,10 +51,10 @@
51
51
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
52
52
  "js-yaml": "^4.1.0",
53
53
  "sharp": "^0.33.2",
54
- "@uniweb/theming": "0.1.2"
54
+ "@uniweb/theming": "0.1.3"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@uniweb/runtime": "0.6.21",
57
+ "@uniweb/runtime": "0.6.23",
58
58
  "@uniweb/content-reader": "1.1.4",
59
59
  "@uniweb/schemas": "0.2.1"
60
60
  },
@@ -65,7 +65,7 @@
65
65
  "@tailwindcss/vite": "^4.0.0",
66
66
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
67
67
  "vite-plugin-svgr": "^4.0.0",
68
- "@uniweb/core": "0.5.15"
68
+ "@uniweb/core": "0.5.17"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "vite": {
package/src/i18n/index.js CHANGED
@@ -154,7 +154,8 @@ async function resolveLocales(configLocales, localesPath) {
154
154
  if (configLocales.includes('*')) {
155
155
  return getAvailableLocales(localesPath)
156
156
  }
157
- return configLocales
157
+ // Normalize: support both string codes and objects ({code, label})
158
+ return configLocales.map(l => typeof l === 'string' ? l : l.code)
158
159
  }
159
160
 
160
161
  // String value '*' means all available
package/src/prerender.js CHANGED
@@ -270,7 +270,7 @@ async function discoverLocaleContents(distDir, defaultContent) {
270
270
  if (!statSync(entryPath).isDirectory()) continue
271
271
 
272
272
  // Check if this looks like a locale code (2-3 letter code)
273
- if (!/^[a-z]{2,3}(-[A-Z]{2})?$/.test(entry)) continue
273
+ if (!/^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/.test(entry)) continue
274
274
 
275
275
  // Check if it has a site-content.json
276
276
  const localeContentPath = join(entryPath, 'site-content.json')
@@ -510,6 +510,23 @@ export async function prerenderSite(siteDir, options = {}) {
510
510
  const translatedPageRoute = isDefault ? page.route : website.translateRoute(page.route, locale)
511
511
  const outputRoute = routePrefix + translatedPageRoute
512
512
 
513
+ // Redirect pages: emit a redirect HTML instead of rendering content
514
+ if (page.redirect) {
515
+ onProgress(` Redirect ${outputRoute} → ${page.redirect}`)
516
+ const redirectHtml = `<!DOCTYPE html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0;url=${page.redirect}"><link rel="canonical" href="${page.redirect}"><title>Redirecting...</title></head><body><p>Redirecting to <a href="${page.redirect}">${page.redirect}</a></p></body></html>`
517
+ const outputPath = getOutputPath(distDir, outputRoute)
518
+ await mkdir(dirname(outputPath), { recursive: true })
519
+ await writeFile(outputPath, redirectHtml)
520
+ renderedFiles.push(outputPath)
521
+ continue
522
+ }
523
+
524
+ // Rewrite pages: served by an external site, skip rendering entirely
525
+ if (page.rewrite) {
526
+ onProgress(` Rewrite ${outputRoute} → ${page.rewrite}`)
527
+ continue
528
+ }
529
+
513
530
  onProgress(`Rendering ${outputRoute}...`)
514
531
 
515
532
  const result = renderPage(page, website)
@@ -562,6 +579,32 @@ export async function prerenderSite(siteDir, options = {}) {
562
579
  onProgress(` → ${routePrefix || ''}404.html (${fallbackNote})`)
563
580
  }
564
581
 
582
+ // Generate _redirects file for Cloudflare Pages / Netlify
583
+ // Format: source destination status
584
+ // 302 = redirect (browser URL changes)
585
+ // 200 = rewrite/proxy (browser URL stays, host proxies transparently)
586
+ const routingEntries = []
587
+ for (const localeConfig of localeConfigs) {
588
+ const siteContent = JSON.parse(await readFile(localeConfig.contentPath, 'utf8'))
589
+ const prefix = localeConfig.routePrefix || ''
590
+ for (const page of siteContent.pages || []) {
591
+ if (page.redirect) {
592
+ routingEntries.push(`${prefix}${page.route} ${page.redirect} 302`)
593
+ }
594
+ if (page.rewrite) {
595
+ routingEntries.push(`${prefix}${page.route}/* ${page.rewrite}/:splat 200`)
596
+ }
597
+ }
598
+ }
599
+ if (routingEntries.length > 0) {
600
+ const redirectsPath = join(distDir, '_redirects')
601
+ // Append to existing _redirects if the developer maintains one
602
+ const existing = existsSync(redirectsPath) ? await readFile(redirectsPath, 'utf8') : ''
603
+ const generated = `# Auto-generated from page.yml redirect: and rewrite: declarations\n${routingEntries.join('\n')}\n`
604
+ await writeFile(redirectsPath, existing ? `${existing.trimEnd()}\n\n${generated}` : generated)
605
+ onProgress(`Generated _redirects (${routingEntries.length} entries)`)
606
+ }
607
+
565
608
  onProgress(`\nPre-rendered ${renderedFiles.length} pages across ${localeConfigs.length} locale(s)`)
566
609
 
567
610
  return {
@@ -387,6 +387,34 @@ function resolveMounts(pathsConfig, sitePath, pagesPath) {
387
387
  * - Simple: "1", "2", "3"
388
388
  * - Decimal ordering: "1.5" (between 1 and 2), "2.5" (between 2 and 3)
389
389
  */
390
+ /**
391
+ * Extract the first H1 text from a ProseMirror document.
392
+ * Returns null if no H1 is found.
393
+ */
394
+ function extractH1(proseMirrorDoc) {
395
+ const nodes = proseMirrorDoc?.content || proseMirrorDoc
396
+ if (!Array.isArray(nodes)) return null
397
+
398
+ for (const node of nodes) {
399
+ if (node.type === 'heading' && node.attrs?.level === 1 && node.content) {
400
+ return node.content.map(n => n.text || '').join('').trim() || null
401
+ }
402
+ }
403
+ return null
404
+ }
405
+
406
+ /**
407
+ * Prettify a slug into a human-readable title.
408
+ * Strips numeric prefixes, replaces hyphens with spaces, title-cases.
409
+ */
410
+ function prettifySlug(slug) {
411
+ const stripped = slug.replace(/^\d+[-.]?\s*/, '')
412
+ return stripped
413
+ .split('-')
414
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
415
+ .join(' ')
416
+ }
417
+
390
418
  function parseNumericPrefix(filename) {
391
419
  const match = filename.match(/^(\d+(?:\.\d+)*)-?(.*)$/)
392
420
  if (match) {
@@ -587,7 +615,7 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
587
615
  sourcePath: null,
588
616
  id: null,
589
617
  isIndex: false,
590
- title: pageName,
618
+ title: extractH1(section.content) || prettifySlug(pageName),
591
619
  description: '',
592
620
  label: null,
593
621
  lastModified: fileStat.mtime?.toISOString() || null,
@@ -875,6 +903,65 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
875
903
  // they're just filtered from navigation. This allows direct linking to hidden pages.
876
904
  // if (pageConfig.hidden) return null
877
905
 
906
+ // Redirect pages have no content — resolve target and return early
907
+ if (pageConfig.redirect) {
908
+ const route = isIndex ? parentRoute
909
+ : parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
910
+ const redirect = pageConfig.redirect
911
+ // Resolve relative redirects against the page's own route
912
+ const target = redirect.startsWith('/') || redirect.startsWith('http')
913
+ ? redirect
914
+ : (route === '/' ? `/${redirect}` : `${route}/${redirect}`)
915
+
916
+ return {
917
+ page: {
918
+ route,
919
+ sourcePath: parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`,
920
+ id: pageConfig.id || null,
921
+ isIndex,
922
+ title: pageConfig.title || prettifySlug(pageName),
923
+ description: pageConfig.description || '',
924
+ label: pageConfig.label || null,
925
+ hidden: pageConfig.hidden || false,
926
+ hideInHeader: pageConfig.hideInHeader || false,
927
+ hideInFooter: pageConfig.hideInFooter || false,
928
+ layout: {},
929
+ seo: { noindex: true },
930
+ redirect: target,
931
+ sections: []
932
+ },
933
+ assetCollection: { assets: {}, hasExplicitPoster: new Set(), hasExplicitPreview: new Set() },
934
+ iconCollection: { icons: new Set(), bySource: new Map() }
935
+ }
936
+ }
937
+
938
+ // Rewrite pages proxy to an external site — no content, no HTML output
939
+ if (pageConfig.rewrite) {
940
+ const route = isIndex ? parentRoute
941
+ : parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
942
+
943
+ return {
944
+ page: {
945
+ route,
946
+ sourcePath: parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`,
947
+ id: pageConfig.id || null,
948
+ isIndex,
949
+ title: pageConfig.title || prettifySlug(pageName),
950
+ description: pageConfig.description || '',
951
+ label: pageConfig.label || null,
952
+ hidden: pageConfig.hidden || false,
953
+ hideInHeader: pageConfig.hideInHeader || false,
954
+ hideInFooter: pageConfig.hideInFooter || false,
955
+ layout: {},
956
+ seo: { noindex: true },
957
+ rewrite: pageConfig.rewrite,
958
+ sections: []
959
+ },
960
+ assetCollection: { assets: {}, hasExplicitPoster: new Set(), hasExplicitPreview: new Set() },
961
+ iconCollection: { icons: new Set(), bySource: new Map() }
962
+ }
963
+ }
964
+
878
965
  let hierarchicalSections = []
879
966
  let pageAssetCollection = {
880
967
  assets: {},
@@ -1103,7 +1190,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1103
1190
  sourcePath, // Original folder-based path (for ancestor checking in navigation)
1104
1191
  id: pageConfig.id || null, // Stable page ID for page: links (survives reorganization)
1105
1192
  isIndex, // Marks this page as the index for its parent route
1106
- title: pageConfig.title || pageName,
1193
+ title: pageConfig.title || extractH1(hierarchicalSections[0]?.content) || prettifySlug(pageName),
1107
1194
  description: pageConfig.description || '',
1108
1195
  label: pageConfig.label || null, // Short label for navigation (defaults to title)
1109
1196
  lastModified: lastModified?.toISOString(),
@@ -1657,8 +1744,14 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1657
1744
  * @param {string} foundationPath - Path to foundation directory
1658
1745
  * @returns {Promise<Object>} Foundation variables or empty object
1659
1746
  */
1660
- async function loadFoundationVars(foundationPath) {
1661
- if (!foundationPath) return {}
1747
+ /**
1748
+ * Load foundation schema data needed by the content collector.
1749
+ *
1750
+ * @param {string} foundationPath - Path to foundation directory
1751
+ * @returns {Promise<{ vars: Object, layoutNames: Set<string> }>}
1752
+ */
1753
+ async function loadFoundationInfo(foundationPath) {
1754
+ if (!foundationPath) return { vars: {}, layoutNames: new Set() }
1662
1755
 
1663
1756
  // Try dist/meta/schema.json first (built foundation), then root schema.json
1664
1757
  const distSchemaPath = join(foundationPath, 'dist', 'meta', 'schema.json')
@@ -1667,17 +1760,20 @@ async function loadFoundationVars(foundationPath) {
1667
1760
  const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : rootSchemaPath
1668
1761
 
1669
1762
  if (!existsSync(schemaPath)) {
1670
- return {}
1763
+ return { vars: {}, layoutNames: new Set() }
1671
1764
  }
1672
1765
 
1673
1766
  try {
1674
1767
  const schemaContent = await readFile(schemaPath, 'utf8')
1675
1768
  const schema = JSON.parse(schemaContent)
1676
1769
  // Foundation config is in _self, support both 'vars' (new) and 'themeVars' (legacy)
1677
- return schema._self?.vars || schema._self?.themeVars || schema.themeVars || {}
1770
+ const vars = schema._self?.vars || schema._self?.themeVars || schema.themeVars || {}
1771
+ // Layout names from _layouts (keys are layout component names)
1772
+ const layoutNames = new Set(schema._layouts ? Object.keys(schema._layouts) : [])
1773
+ return { vars, layoutNames }
1678
1774
  } catch (err) {
1679
1775
  console.warn('[content-collector] Failed to load foundation schema:', err.message)
1680
- return {}
1776
+ return { vars: {}, layoutNames: new Set() }
1681
1777
  }
1682
1778
  }
1683
1779
 
@@ -1744,63 +1840,36 @@ async function collectAreasFromDir(dir, siteRoot, routePrefix = '/layout') {
1744
1840
  return result
1745
1841
  }
1746
1842
 
1747
- /**
1748
- * Check if a directory looks like a named layout (contains area-like .md files or area subdirs)
1749
- * vs an area in folder form (contains section content processed by processPage).
1750
- *
1751
- * Heuristic: if a directory contains .md files at the top level AND no page.yml,
1752
- * it's a named layout (those .md files are its area definitions).
1753
- * If it has page.yml or looks like a page directory, it's an area folder.
1754
- *
1755
- * @param {string} dirPath - Path to the directory
1756
- * @returns {Promise<boolean>} True if this looks like a named layout directory
1757
- */
1758
- async function isNamedLayoutDir(dirPath) {
1759
- // If it has page.yml, it's an area folder (processPage will handle it)
1760
- if (existsSync(join(dirPath, 'page.yml'))) return false
1761
-
1762
- const entries = await readdir(dirPath)
1763
- // If directory contains .md files but no page.yml, it's a named layout
1764
- return entries.some(e => e.endsWith('.md') && !e.startsWith('_') && !e.startsWith('.'))
1765
- }
1766
-
1767
1843
  /**
1768
1844
  * Collect layout areas from the layout/ directory, including named layout subdirectories.
1769
1845
  *
1770
1846
  * Root-level .md files and area directories form the "default" layout's areas.
1771
- * Subdirectories that themselves contain .md files (without page.yml) are named layouts,
1772
- * each with its own set of areas.
1847
+ * Subdirectories that match a foundation-declared layout name are named layouts,
1848
+ * each with its own set of areas. All other subdirectories are folder-form areas
1849
+ * for the default layout (multi-section areas).
1773
1850
  *
1774
1851
  * @param {string} layoutDir - Path to layout directory
1775
1852
  * @param {string} siteRoot - Path to site root
1853
+ * @param {Set<string>} layoutNames - Layout names declared in the foundation schema
1776
1854
  * @returns {Promise<Object>} { layouts }
1777
1855
  */
1778
- async function collectLayouts(layoutDir, siteRoot) {
1856
+ async function collectLayouts(layoutDir, siteRoot, layoutNames = new Set()) {
1779
1857
  if (!existsSync(layoutDir)) {
1780
1858
  return { layouts: null }
1781
1859
  }
1782
1860
 
1783
1861
  const entries = await readdir(layoutDir, { withFileTypes: true })
1784
1862
 
1785
- // Separate root-level entries into:
1786
- // 1. Area .md files (for default layout)
1787
- // 2. Area directories (for default layout) vs named layout directories
1788
- const defaultAreaFiles = []
1789
- const defaultAreaDirs = []
1863
+ // Identify named layout directories: only subdirectories whose name matches
1864
+ // a layout component declared in the foundation. All others are folder-form
1865
+ // areas for the default layout (e.g., layout/header/ with multiple sections).
1790
1866
  const namedLayoutDirs = []
1791
1867
 
1792
1868
  for (const entry of entries) {
1793
1869
  if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
1794
1870
 
1795
- if (entry.isFile() && entry.name.endsWith('.md')) {
1796
- defaultAreaFiles.push(entry)
1797
- } else if (entry.isDirectory()) {
1798
- const dirPath = join(layoutDir, entry.name)
1799
- if (await isNamedLayoutDir(dirPath)) {
1800
- namedLayoutDirs.push(entry)
1801
- } else {
1802
- defaultAreaDirs.push(entry)
1803
- }
1871
+ if (entry.isDirectory() && layoutNames.has(entry.name)) {
1872
+ namedLayoutDirs.push(entry)
1804
1873
  }
1805
1874
  }
1806
1875
 
@@ -1861,8 +1930,8 @@ export async function collectSiteContent(sitePath, options = {}) {
1861
1930
  : join(sitePath, 'layout')
1862
1931
  const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
1863
1932
 
1864
- // Load foundation vars and process theme
1865
- const foundationVars = await loadFoundationVars(foundationPath)
1933
+ // Load foundation info (vars + layout names) and process theme
1934
+ const { vars: foundationVars, layoutNames: layoutNames } = await loadFoundationInfo(foundationPath)
1866
1935
  const { config: processedTheme, css: themeCSS, warnings } = buildTheme(rawThemeConfig, { foundationVars })
1867
1936
 
1868
1937
  // Log theme warnings
@@ -1893,7 +1962,7 @@ export async function collectSiteContent(sitePath, options = {}) {
1893
1962
  const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
1894
1963
 
1895
1964
  // Collect layout areas from layout/ directory (including named layout subdirectories)
1896
- const { layouts } = await collectLayouts(layoutPath, sitePath)
1965
+ const { layouts } = await collectLayouts(layoutPath, sitePath, layoutNames)
1897
1966
 
1898
1967
  // Site-level layout name (from site.yml layout: field)
1899
1968
  const siteLayoutName = typeof siteConfig.layout === 'string' ? siteConfig.layout
@@ -39,6 +39,9 @@ import { processAdvancedAssets } from './advanced-processors.js'
39
39
  import { processCollections, writeCollectionFiles } from './collection-processor.js'
40
40
  import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
41
41
 
42
+ // BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
43
+ const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
44
+
42
45
  /**
43
46
  * Execute all fetches for site content (used in dev mode)
44
47
  * Collects fetchedData for DataStore pre-population at runtime
@@ -772,7 +775,7 @@ export function siteContentPlugin(options = {}) {
772
775
  }
773
776
 
774
777
  // Handle locale-prefixed site-content request (e.g., /es/site-content.json)
775
- const localeContentMatch = req.url.match(/^\/([a-z]{2})\/site-content\.json$/)
778
+ const localeContentMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/site-content\\.json$`))
776
779
  if (localeContentMatch) {
777
780
  const locale = localeContentMatch[1]
778
781
  const translatedContent = await getTranslatedContent(locale)
@@ -808,7 +811,7 @@ export function siteContentPlugin(options = {}) {
808
811
  }
809
812
 
810
813
  // Serve search-index.json in dev mode (supports locale prefixes)
811
- const searchIndexMatch = req.url.match(/^(?:\/([a-z]{2}))?\/search-index\.json$/)
814
+ const searchIndexMatch = req.url.match(new RegExp(`^(?:\\/(${LOCALE_RE}))?\\/search-index\\.json$`))
812
815
  if (searchIndexMatch && siteContent) {
813
816
  const searchEnabled = searchPluginConfig.enabled !== false && isSearchEnabled(siteContent)
814
817
  if (searchEnabled) {
@@ -830,7 +833,7 @@ export function siteContentPlugin(options = {}) {
830
833
  }
831
834
 
832
835
  // Handle localized collection data (e.g., /fr/data/articles.json)
833
- const localeDataMatch = req.url.match(/^\/([a-z]{2})\/data\/(.+\.json)$/)
836
+ const localeDataMatch = req.url.match(new RegExp(`^\\/(${LOCALE_RE})\\/data\\/(.+\\.json)$`))
834
837
  if (localeDataMatch) {
835
838
  const locale = localeDataMatch[1]
836
839
  const filename = localeDataMatch[2]
@@ -883,7 +886,7 @@ export function siteContentPlugin(options = {}) {
883
886
  let activeLocale = null
884
887
 
885
888
  if (ctx?.originalUrl) {
886
- const localeMatch = ctx.originalUrl.match(/^\/([a-z]{2})(\/|$)/)
889
+ const localeMatch = ctx.originalUrl.match(new RegExp(`^\\/(${LOCALE_RE})(\\/|$)`))
887
890
  if (localeMatch) {
888
891
  activeLocale = localeMatch[1]
889
892
  const translatedContent = await getTranslatedContent(activeLocale)