@uniweb/build 0.8.42 → 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 +5 -5
- package/src/prerender.js +68 -4
- package/src/site/config.js +22 -0
- package/src/site/content-collector.js +8 -0
- package/src/site/plugin.js +35 -0
- package/src/site/split-content.js +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
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/runtime": "0.
|
|
58
|
-
"@uniweb/
|
|
59
|
-
"@uniweb/
|
|
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.
|
|
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
|
-
|
|
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
|
package/src/site/config.js
CHANGED
|
@@ -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,
|
package/src/site/plugin.js
CHANGED
|
@@ -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
|
+
}
|