@uniweb/build 0.8.41 → 0.9.0

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.41",
3
+ "version": "0.9.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -54,9 +54,9 @@
54
54
  "@uniweb/theming": "0.1.3"
55
55
  },
56
56
  "optionalDependencies": {
57
- "@uniweb/content-reader": "1.1.4",
58
- "@uniweb/runtime": "0.6.38",
59
- "@uniweb/schemas": "0.2.1"
57
+ "@uniweb/runtime": "0.7.0",
58
+ "@uniweb/schemas": "0.2.1",
59
+ "@uniweb/content-reader": "1.1.4"
60
60
  },
61
61
  "peerDependencies": {
62
62
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -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.22"
68
+ "@uniweb/core": "0.6.0"
69
69
  },
70
70
  "peerDependenciesMeta": {
71
71
  "vite": {
package/src/prerender.js CHANGED
@@ -12,6 +12,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
12
12
  import { join, dirname, resolve } from 'node:path'
13
13
  import { pathToFileURL } from 'node:url'
14
14
  import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
15
+ import { shouldSplitContent } from './site/split-content.js'
15
16
 
16
17
  /**
17
18
  * Resolve an extension URL to a filesystem path for prerender.
@@ -302,9 +303,12 @@ async function discoverLocaleContents(distDir, defaultContent) {
302
303
  *
303
304
  * @param {string} html - HTML with prerendered content already injected
304
305
  * @param {Object} siteContent - Site content JSON
306
+ * @param {Object} [options]
307
+ * @param {boolean} [options.splitContent=false] - Whether split content mode is active
308
+ * @param {string|null} [options.currentRoute=null] - Route of the page this HTML is for
305
309
  * @returns {string} HTML with build-specific data injected
306
310
  */
307
- function injectBuildData(html, siteContent) {
311
+ function injectBuildData(html, siteContent, { splitContent = false, currentRoute = null } = {}) {
308
312
  let result = html
309
313
 
310
314
  // Inject theme CSS if not already present
@@ -317,11 +321,26 @@ function injectBuildData(html, siteContent) {
317
321
 
318
322
  // Inject site content as JSON for hydration
319
323
  // Strip CSS from theme (it's already in a <style> tag)
320
- const contentForJson = { ...siteContent }
324
+ let contentForJson = { ...siteContent }
321
325
  if (contentForJson.theme?.css) {
322
326
  contentForJson.theme = { ...contentForJson.theme }
323
327
  delete contentForJson.theme.css
324
328
  }
329
+
330
+ // Split mode: strip sections from all pages except the current one.
331
+ // Dynamic templates (isDynamic) keep their sections — needed by _createDynamicPage().
332
+ if (splitContent) {
333
+ contentForJson = {
334
+ ...contentForJson,
335
+ pages: contentForJson.pages.map(page => {
336
+ if (page.route === currentRoute) return page
337
+ if (page.isDynamic) return page
338
+ const { sections, ...metadata } = page
339
+ return metadata
340
+ })
341
+ }
342
+ }
343
+
325
344
  const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(contentForJson).replace(/</g, '\\u003c')}</script>`
326
345
  if (result.includes('__SITE_CONTENT__')) {
327
346
  // Replace existing site content with updated version (includes expanded dynamic routes)
@@ -462,6 +481,30 @@ export async function prerenderSite(siteDir, options = {}) {
462
481
  siteContent.pages = expandDynamicPages(siteContent.pages, pageFetchedData, onProgress)
463
482
  }
464
483
 
484
+ // Determine whether to split content (after dynamic expansion, after data fetches)
485
+ const splitContent = shouldSplitContent(
486
+ siteContent.config?.build?.splitContent,
487
+ siteContent.pages
488
+ )
489
+
490
+ // Emit per-page content files (after dynamic expansion so expanded pages get their own files)
491
+ if (splitContent) {
492
+ onProgress('Writing per-page content files...')
493
+ const pagesBaseDir = routePrefix
494
+ ? join(distDir, routePrefix.replace(/^\//, ''), '_pages')
495
+ : join(distDir, '_pages')
496
+
497
+ for (const page of siteContent.pages) {
498
+ if (!page.sections?.length) continue // Skip content-less pages
499
+ if (page.isDynamic) continue // Templates stay inline
500
+ const routePath = page.route === '/' ? '/index' : page.route
501
+ const outputPath = join(pagesBaseDir, `${routePath.replace(/^\//, '')}.json`)
502
+ await mkdir(dirname(outputPath), { recursive: true })
503
+ await writeFile(outputPath, JSON.stringify({ sections: page.sections }))
504
+ onProgress(` → _pages${routePath}.json`)
505
+ }
506
+ }
507
+
465
508
  // Load the HTML shell for this locale
466
509
  const shellPath = existsSync(htmlPath) ? htmlPath : join(distDir, 'index.html')
467
510
  const htmlShell = await readFile(shellPath, 'utf8')
@@ -571,7 +614,10 @@ export async function prerenderSite(siteDir, options = {}) {
571
614
  })
572
615
 
573
616
  // Build-specific: theme CSS, __SITE_CONTENT__, icon cache
574
- html = injectBuildData(html, siteContent)
617
+ html = injectBuildData(html, siteContent, {
618
+ splitContent,
619
+ currentRoute: page.route,
620
+ })
575
621
 
576
622
  // Output to the locale-prefixed route
577
623
  const outputPath = getOutputPath(distDir, outputRoute)
@@ -583,7 +629,10 @@ export async function prerenderSite(siteDir, options = {}) {
583
629
  }
584
630
 
585
631
  // Write 404.html — shared logic from @uniweb/runtime/ssr
586
- const fallbackBaseHtml = injectBuildData(htmlShell, siteContent)
632
+ const fallbackBaseHtml = injectBuildData(htmlShell, siteContent, {
633
+ splitContent,
634
+ currentRoute: null, // 404 has no current page — manifest only
635
+ })
587
636
  const { html: notFoundHtml, hasNotFoundPage } = generate404Html({
588
637
  baseHtml: fallbackBaseHtml,
589
638
  website,
@@ -595,6 +644,21 @@ export async function prerenderSite(siteDir, options = {}) {
595
644
  await writeFile(join(fallbackDir, '404.html'), notFoundHtml)
596
645
  const fallbackNote = hasNotFoundPage ? '404 page + SPA fallback' : 'SPA fallback'
597
646
  onProgress(` → ${routePrefix || ''}404.html (${fallbackNote})`)
647
+
648
+ // Rewrite site-content.json as lightweight manifest (for shell/CF mode)
649
+ // Must happen after all HTML files are written since some code re-reads it.
650
+ if (splitContent) {
651
+ const manifest = {
652
+ ...siteContent,
653
+ pages: siteContent.pages.map(page => {
654
+ if (page.isDynamic) return page
655
+ const { sections, ...metadata } = page
656
+ return metadata
657
+ })
658
+ }
659
+ await writeFile(localeContentPath, JSON.stringify(manifest))
660
+ onProgress('Rewrote site-content.json as lightweight manifest')
661
+ }
598
662
  }
599
663
 
600
664
  // Generate _redirects file for Cloudflare Pages / Netlify
@@ -153,6 +153,28 @@ export function readSiteConfig(siteRoot) {
153
153
  }
154
154
  }
155
155
 
156
+ /**
157
+ * Read and parse intelligence.yml configuration (AI knowledge page settings).
158
+ *
159
+ * Returns the non-secret fields. The `apiKey` field (if present) uses the
160
+ * `env:VAR_NAME` syntax for CLI/local dev — the actual key is never stored
161
+ * in artifacts or published content.
162
+ *
163
+ * @param {string} siteRoot - Path to site directory
164
+ * @returns {Object|null} Parsed intelligence config, or null if no file exists
165
+ */
166
+ export function readIntelligenceConfig(siteRoot) {
167
+ const configPath = resolve(siteRoot, 'intelligence.yml')
168
+ if (!existsSync(configPath)) return null
169
+
170
+ try {
171
+ return yaml.load(readFileSync(configPath, 'utf8')) || {}
172
+ } catch (err) {
173
+ console.warn('[site-config] Failed to read intelligence.yml:', err.message)
174
+ return null
175
+ }
176
+ }
177
+
156
178
  /**
157
179
  * Create a complete Vite configuration for a Uniweb site
158
180
  *
@@ -1210,6 +1210,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1210
1210
  hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
1211
1211
  hideInFooter: pageConfig.hideInFooter || false, // Hide from footer nav
1212
1212
 
1213
+ // Knowledge page — content feeds AI pipeline instead of (or in addition to) rendering
1214
+ ...(pageConfig.knowledge != null ? { knowledge: pageConfig.knowledge } : {}),
1215
+
1213
1216
  // Layout options (named layout + per-page overrides)
1214
1217
  layout: {
1215
1218
  ...(resolvedLayoutName ? { name: resolvedLayoutName } : {}),
@@ -1233,6 +1236,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1233
1236
  : undefined)
1234
1237
  ),
1235
1238
 
1239
+ hasContent: hierarchicalSections.length > 0,
1236
1240
  sections: hierarchicalSections
1237
1241
  },
1238
1242
  assetCollection: pageAssetCollection,
@@ -1565,6 +1569,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1565
1569
  hidden: dirConfig.hidden || false,
1566
1570
  hideInHeader: dirConfig.hideInHeader || false,
1567
1571
  hideInFooter: dirConfig.hideInFooter || false,
1572
+ ...(dirConfig.knowledge != null ? { knowledge: dirConfig.knowledge } : {}),
1568
1573
  layout: {
1569
1574
  ...(effectiveLayout ? { name: effectiveLayout } : {}),
1570
1575
  ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
@@ -1577,6 +1582,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1577
1582
  priority: dirConfig.seo?.priority || null
1578
1583
  },
1579
1584
  fetch: parseFetchConfig(dirConfig.fetch) || null,
1585
+ hasContent: false,
1580
1586
  sections: [],
1581
1587
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1582
1588
  }
@@ -1657,6 +1663,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1657
1663
  hidden: dirConfig.hidden || false,
1658
1664
  hideInHeader: dirConfig.hideInHeader || false,
1659
1665
  hideInFooter: dirConfig.hideInFooter || false,
1666
+ ...(dirConfig.knowledge != null ? { knowledge: dirConfig.knowledge } : {}),
1660
1667
  layout: {
1661
1668
  ...(effectiveLayout ? { name: effectiveLayout } : {}),
1662
1669
  ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
@@ -1669,6 +1676,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1669
1676
  priority: dirConfig.seo?.priority || null
1670
1677
  },
1671
1678
  fetch: null,
1679
+ hasContent: false,
1672
1680
  sections: [],
1673
1681
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1674
1682
  }
@@ -2051,10 +2059,15 @@ export async function collectSiteContent(sitePath, options = {}) {
2051
2059
  // Convert versionedScopes Map to plain object for JSON serialization
2052
2060
  const versionedScopesObj = Object.fromEntries(versionedScopes)
2053
2061
 
2062
+ // Read intelligence.yml if it exists (AI knowledge page settings)
2063
+ const intelligenceConfig = await readYamlFile(join(sitePath, 'intelligence.yml'))
2064
+ const hasIntelligence = intelligenceConfig && Object.keys(intelligenceConfig).length > 0
2065
+
2054
2066
  return {
2055
2067
  config: {
2056
2068
  ...siteConfig,
2057
2069
  fetch: parseFetchConfig(siteConfig.fetch),
2070
+ ...(hasIntelligence && { intelligence: intelligenceConfig }),
2058
2071
  },
2059
2072
  theme: {
2060
2073
  ...processedTheme,
@@ -38,10 +38,41 @@ import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
38
38
  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
+ import { shouldSplitContent } from './split-content.js'
41
42
 
42
43
  // BCP 47 locale code pattern: en, zh-CN, zh-Hant, pt-BR, fr-CA, sr-Latn, etc.
43
44
  const LOCALE_RE = '[a-z]{2,3}(?:-[A-Za-z]{2,4})?'
44
45
 
46
+ const FAVICON_AUTODETECT = ['favicon.svg', 'favicon.ico', 'favicon.png']
47
+
48
+ function faviconTypeFor(href) {
49
+ const ext = href.split('?')[0].split('.').pop()?.toLowerCase()
50
+ if (ext === 'svg') return 'image/svg+xml'
51
+ if (ext === 'ico') return 'image/x-icon'
52
+ if (ext === 'png') return 'image/png'
53
+ return null
54
+ }
55
+
56
+ /**
57
+ * Resolve a favicon href for index.html injection.
58
+ * - If config.favicon is a string, use it as-is.
59
+ * - Otherwise, scan the public dir for favicon.{svg,ico,png} and link the first match.
60
+ * - Returns null if nothing found.
61
+ */
62
+ function resolveFaviconHref(configFavicon, publicDir, basePath) {
63
+ if (typeof configFavicon === 'string' && configFavicon.trim()) {
64
+ return configFavicon.trim()
65
+ }
66
+ if (!publicDir) return null
67
+ for (const name of FAVICON_AUTODETECT) {
68
+ if (existsSync(resolve(publicDir, name))) {
69
+ const base = (basePath || '/').replace(/\/$/, '')
70
+ return `${base}/${name}`
71
+ }
72
+ }
73
+ return null
74
+ }
75
+
45
76
  /**
46
77
  * Execute all fetches for site content (used in dev mode)
47
78
  * Collects fetchedData for DataStore pre-population at runtime
@@ -372,6 +403,7 @@ export function siteContentPlugin(options = {}) {
372
403
 
373
404
  let siteContent = null
374
405
  let resolvedSitePath = null
406
+ let resolvedPublicDir = null
375
407
  let resolvedOutDir = null
376
408
  let isProduction = false
377
409
  let watcher = null
@@ -496,6 +528,7 @@ export function siteContentPlugin(options = {}) {
496
528
 
497
529
  async configResolved(config) {
498
530
  resolvedSitePath = resolve(config.root, sitePath)
531
+ resolvedPublicDir = config.publicDir || resolve(config.root, 'public')
499
532
  resolvedOutDir = resolve(config.root, config.build.outDir)
500
533
  isProduction = config.command === 'build'
501
534
  basePath = config.base || '/'
@@ -871,6 +904,18 @@ export function siteContentPlugin(options = {}) {
871
904
  }
872
905
  }
873
906
 
907
+ // Serve per-page content on demand (dev mode, split content)
908
+ if (siteContent && req.url.startsWith('/_pages/')) {
909
+ const routePath = req.url.replace(/^\/_pages/, '').replace(/\.json$/, '')
910
+ const route = routePath === '/index' ? '/' : routePath
911
+ const page = siteContent.pages.find(p => p.route === route)
912
+ if (page?.sections) {
913
+ res.setHeader('Content-Type', 'application/json')
914
+ res.end(JSON.stringify({ sections: page.sections }))
915
+ return
916
+ }
917
+ }
918
+
874
919
  next()
875
920
  })
876
921
  },
@@ -940,6 +985,16 @@ export function siteContentPlugin(options = {}) {
940
985
  }
941
986
  }
942
987
 
988
+ // Inject favicon link
989
+ // Source 1: contentToInject.config.favicon (matches Cloudflare publish schema)
990
+ // Source 2: auto-detect public/favicon.{ico,svg,png}
991
+ const faviconHref = resolveFaviconHref(contentToInject?.config?.favicon, resolvedPublicDir, basePath)
992
+ if (faviconHref) {
993
+ const type = faviconTypeFor(faviconHref)
994
+ const typeAttr = type ? ` type="${type}"` : ''
995
+ headInjection += ` <link rel="icon"${typeAttr} href="${faviconHref}">\n`
996
+ }
997
+
943
998
  // Inject content as JSON script tag
944
999
  if (inject) {
945
1000
  headInjection += ` <script type="application/json" id="${variableName}">${JSON.stringify(contentToInject).replace(/</g, '\\u003c')}</script>\n`
@@ -1048,7 +1103,29 @@ export function siteContentPlugin(options = {}) {
1048
1103
  // Note: theme.css is kept here so prerender can inject it into HTML
1049
1104
  // Prerender will strip it from the JSON it injects into each page
1050
1105
 
1106
+ // Check if split content mode is active
1107
+ const splitContent = shouldSplitContent(
1108
+ finalContent.config?.build?.splitContent,
1109
+ finalContent.pages
1110
+ )
1111
+
1112
+ if (splitContent) {
1113
+ // Emit per-page content files as baseline (prerender overwrites with post-fetch versions)
1114
+ for (const page of finalContent.pages) {
1115
+ if (!page.sections?.length) continue
1116
+ if (page.isDynamic) continue
1117
+ const routePath = page.route === '/' ? 'index' : page.route.replace(/^\//, '')
1118
+ this.emitFile({
1119
+ type: 'asset',
1120
+ fileName: `_pages/${routePath}.json`,
1121
+ source: JSON.stringify({ sections: page.sections })
1122
+ })
1123
+ }
1124
+ }
1125
+
1051
1126
  // Emit content as JSON file in production build
1127
+ // Always emit full content — prerender needs sections to render pages.
1128
+ // Prerender will rewrite this as a lightweight manifest when split mode is active.
1052
1129
  this.emitFile({
1053
1130
  type: 'asset',
1054
1131
  fileName: filename,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Split Content Helper
3
+ *
4
+ * Shared utility to determine whether a site should use split page content.
5
+ * Used by the site plugin, prerender, and unicloud.
6
+ *
7
+ * @module @uniweb/build/site
8
+ */
9
+
10
+ const THRESHOLD = 100 * 1024 // 100KB uncompressed JSON
11
+
12
+ /**
13
+ * Determine whether site content should be split into per-page files.
14
+ *
15
+ * @param {boolean|string|undefined} splitConfig - Value from site.yml build.splitContent
16
+ * - true: always split
17
+ * - false: never split
18
+ * - 'auto' or undefined: split when total sections payload > 100KB
19
+ * @param {Array} pages - Array of page objects with sections arrays
20
+ * @returns {boolean} Whether to split content
21
+ */
22
+ export function shouldSplitContent(splitConfig, pages) {
23
+ if (splitConfig === true) return true
24
+ if (splitConfig === false) return false
25
+
26
+ // auto (default): measure total sections payload
27
+ if (!pages?.length) return false
28
+
29
+ let totalSize = 0
30
+ for (const page of pages) {
31
+ if (!page.sections?.length) continue
32
+ totalSize += JSON.stringify(page.sections).length
33
+ if (totalSize > THRESHOLD) return true // Early exit
34
+ }
35
+ return false
36
+ }