@uniweb/build 0.8.42 → 0.9.1

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.42",
3
+ "version": "0.9.1",
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/runtime": "0.6.39",
58
- "@uniweb/content-reader": "1.1.4",
59
- "@uniweb/schemas": "0.2.1"
57
+ "@uniweb/runtime": "0.7.1",
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.1"
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
  *
@@ -1236,6 +1236,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1236
1236
  : undefined)
1237
1237
  ),
1238
1238
 
1239
+ hasContent: hierarchicalSections.length > 0,
1239
1240
  sections: hierarchicalSections
1240
1241
  },
1241
1242
  assetCollection: pageAssetCollection,
@@ -1581,6 +1582,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1581
1582
  priority: dirConfig.seo?.priority || null
1582
1583
  },
1583
1584
  fetch: parseFetchConfig(dirConfig.fetch) || null,
1585
+ hasContent: false,
1584
1586
  sections: [],
1585
1587
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1586
1588
  }
@@ -1674,6 +1676,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1674
1676
  priority: dirConfig.seo?.priority || null
1675
1677
  },
1676
1678
  fetch: null,
1679
+ hasContent: false,
1677
1680
  sections: [],
1678
1681
  order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1679
1682
  }
@@ -2056,10 +2059,15 @@ export async function collectSiteContent(sitePath, options = {}) {
2056
2059
  // Convert versionedScopes Map to plain object for JSON serialization
2057
2060
  const versionedScopesObj = Object.fromEntries(versionedScopes)
2058
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
+
2059
2066
  return {
2060
2067
  config: {
2061
2068
  ...siteConfig,
2062
2069
  fetch: parseFetchConfig(siteConfig.fetch),
2070
+ ...(hasIntelligence && { intelligence: intelligenceConfig }),
2063
2071
  },
2064
2072
  theme: {
2065
2073
  ...processedTheme,
@@ -38,6 +38,7 @@ 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})?'
@@ -903,6 +904,18 @@ export function siteContentPlugin(options = {}) {
903
904
  }
904
905
  }
905
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
+
906
919
  next()
907
920
  })
908
921
  },
@@ -1090,7 +1103,29 @@ export function siteContentPlugin(options = {}) {
1090
1103
  // Note: theme.css is kept here so prerender can inject it into HTML
1091
1104
  // Prerender will strip it from the JSON it injects into each page
1092
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
+
1093
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.
1094
1129
  this.emitFile({
1095
1130
  type: 'asset',
1096
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
+ }