@uniweb/build 0.6.5 → 0.6.7
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 +3 -3
- package/src/dev/plugin.js +24 -13
- package/src/prerender.js +49 -0
- package/src/site/config.js +121 -6
- package/src/site/content-collector.js +351 -84
- package/src/site/plugin.js +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
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.
|
|
54
|
+
"@uniweb/runtime": "0.5.16",
|
|
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.
|
|
64
|
+
"@uniweb/core": "0.4.4"
|
|
65
65
|
},
|
|
66
66
|
"peerDependenciesMeta": {
|
|
67
67
|
"vite": {
|
package/src/dev/plugin.js
CHANGED
|
@@ -63,20 +63,28 @@ export function foundationDevPlugin(options = {}) {
|
|
|
63
63
|
console.log(`[foundation] Building ${name}...`)
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
// Use Vite's native config loading by specifying configFile
|
|
67
66
|
const configPath = join(resolvedFoundationPath, 'vite.config.js')
|
|
68
67
|
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
68
|
+
// Temporarily change cwd to foundation directory so that
|
|
69
|
+
// defineFoundationConfig() resolves the entry path correctly
|
|
70
|
+
// (it uses process.cwd() as the foundation root)
|
|
71
|
+
const originalCwd = process.cwd()
|
|
72
|
+
process.chdir(resolvedFoundationPath)
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
await build({
|
|
76
|
+
root: resolvedFoundationPath,
|
|
77
|
+
configFile: existsSync(configPath) ? configPath : false,
|
|
78
|
+
logLevel: 'warn',
|
|
79
|
+
build: {
|
|
80
|
+
outDir: 'dist',
|
|
81
|
+
emptyOutDir: true,
|
|
82
|
+
watch: null // Don't use Vite's watch, we handle it ourselves
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
} finally {
|
|
86
|
+
process.chdir(originalCwd)
|
|
87
|
+
}
|
|
80
88
|
|
|
81
89
|
lastBuildTime = Date.now()
|
|
82
90
|
console.log(`[foundation] Built ${name} in ${lastBuildTime - startTime}ms`)
|
|
@@ -173,7 +181,10 @@ export function foundationDevPlugin(options = {}) {
|
|
|
173
181
|
|
|
174
182
|
try {
|
|
175
183
|
watcher = watch(srcPath, { recursive: true }, (eventType, filename) => {
|
|
176
|
-
// Ignore
|
|
184
|
+
// Ignore generated files (build output triggers entry regeneration)
|
|
185
|
+
if (filename && filename.includes('_entry.generated')) return
|
|
186
|
+
|
|
187
|
+
// Only rebuild for source file changes
|
|
177
188
|
if (
|
|
178
189
|
filename &&
|
|
179
190
|
(filename.endsWith('.js') ||
|
package/src/prerender.js
CHANGED
|
@@ -15,6 +15,37 @@ import { createRequire } from 'node:module'
|
|
|
15
15
|
import { pathToFileURL } from 'node:url'
|
|
16
16
|
import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Resolve an extension URL to a filesystem path for prerender.
|
|
20
|
+
* Browser URLs like "/effects/foundation.js" need mapping to local files.
|
|
21
|
+
*
|
|
22
|
+
* Resolution order:
|
|
23
|
+
* 1. dist directory (post-build copy target, e.g., site/dist/effects/foundation.js)
|
|
24
|
+
* 2. Project root with dist subdir (dev layout, e.g., project/effects/dist/foundation.js)
|
|
25
|
+
* 3. Original URL (absolute or remote — let import() handle it)
|
|
26
|
+
*/
|
|
27
|
+
function resolveExtensionPath(url, distDir, projectRoot) {
|
|
28
|
+
// Only resolve URLs that look like root-relative paths
|
|
29
|
+
if (url.startsWith('/')) {
|
|
30
|
+
// Try dist directory first (production: files copied to site/dist/)
|
|
31
|
+
const distPath = join(distDir, url)
|
|
32
|
+
if (existsSync(distPath)) return distPath
|
|
33
|
+
|
|
34
|
+
// Try project root with dist subdir (dev layout: effects/dist/foundation.js)
|
|
35
|
+
// "/effects/foundation.js" → "effects/dist/foundation.js"
|
|
36
|
+
const parts = url.slice(1).split('/')
|
|
37
|
+
if (parts.length >= 2) {
|
|
38
|
+
const pkgName = parts[0]
|
|
39
|
+
const rest = parts.slice(1).join('/')
|
|
40
|
+
const devPath = join(projectRoot, pkgName, 'dist', rest)
|
|
41
|
+
if (existsSync(devPath)) return devPath
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Return as-is for absolute paths or remote URLs
|
|
46
|
+
return url
|
|
47
|
+
}
|
|
48
|
+
|
|
18
49
|
// Lazily loaded dependencies
|
|
19
50
|
let React, renderToString, createUniweb
|
|
20
51
|
let preparePropsSSR, getComponentMetaSSR
|
|
@@ -714,6 +745,24 @@ export async function prerenderSite(siteDir, options = {}) {
|
|
|
714
745
|
|
|
715
746
|
uniweb.setFoundation(foundation)
|
|
716
747
|
|
|
748
|
+
// Load extensions (secondary foundations via URL)
|
|
749
|
+
const extensions = siteContent.config?.extensions
|
|
750
|
+
if (extensions?.length) {
|
|
751
|
+
onProgress(`Loading ${extensions.length} extension(s)...`)
|
|
752
|
+
const projectRoot = join(siteDir, '..')
|
|
753
|
+
for (const ext of extensions) {
|
|
754
|
+
try {
|
|
755
|
+
const url = typeof ext === 'string' ? ext : ext.url
|
|
756
|
+
const extPath = resolveExtensionPath(url, distDir, projectRoot)
|
|
757
|
+
const extModule = await import(pathToFileURL(extPath).href)
|
|
758
|
+
uniweb.registerExtension(extModule)
|
|
759
|
+
onProgress(` Extension loaded: ${url}`)
|
|
760
|
+
} catch (err) {
|
|
761
|
+
onProgress(` Warning: Extension failed to load: ${ext} (${err.message})`)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
717
766
|
// Set base path from site config so components can access it during SSR
|
|
718
767
|
// (e.g., <Link reload> needs basePath to prefix hrefs for subdirectory deployments)
|
|
719
768
|
if (siteContent.config?.base && uniweb.activeWebsite?.setBasePath) {
|
package/src/site/config.js
CHANGED
|
@@ -175,12 +175,15 @@ export async function defineSiteConfig(options = {}) {
|
|
|
175
175
|
const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
|
|
176
176
|
const base = rawBase ? normalizeBasePath(String(rawBase)) : undefined
|
|
177
177
|
|
|
178
|
+
// Check for shell mode (no embedded content, for dynamic backend)
|
|
179
|
+
const isShellMode = process.env.UNIWEB_SHELL === 'true'
|
|
180
|
+
|
|
178
181
|
// Detect foundation type
|
|
179
182
|
const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
|
|
180
183
|
|
|
181
|
-
// Check for runtime mode (env variable
|
|
184
|
+
// Check for runtime mode (env variable, URL-based foundation, or shell mode)
|
|
182
185
|
const isRuntimeMode =
|
|
183
|
-
process.env.VITE_FOUNDATION_MODE === 'runtime' || foundationInfo.type === 'url'
|
|
186
|
+
isShellMode || process.env.VITE_FOUNDATION_MODE === 'runtime' || foundationInfo.type === 'url'
|
|
184
187
|
|
|
185
188
|
// Dynamic imports for optional peer dependencies
|
|
186
189
|
// These are imported dynamically to avoid requiring them when not needed
|
|
@@ -267,7 +270,8 @@ export async function defineSiteConfig(options = {}) {
|
|
|
267
270
|
// Site content collection and injection
|
|
268
271
|
siteContentPlugin({
|
|
269
272
|
sitePath: './',
|
|
270
|
-
inject:
|
|
273
|
+
inject: !isShellMode,
|
|
274
|
+
shell: isShellMode,
|
|
271
275
|
seo,
|
|
272
276
|
assets,
|
|
273
277
|
search,
|
|
@@ -291,11 +295,122 @@ export async function defineSiteConfig(options = {}) {
|
|
|
291
295
|
// Build resolve.alias configuration
|
|
292
296
|
const alias = {}
|
|
293
297
|
|
|
294
|
-
|
|
295
|
-
|
|
298
|
+
if (isRuntimeMode) {
|
|
299
|
+
// In runtime mode, foundation is loaded via URL at runtime.
|
|
300
|
+
// main.js still imports #foundation so Vite can resolve it,
|
|
301
|
+
// but start() ignores the import and uses the URL instead.
|
|
302
|
+
// Point #foundation at a virtual noop module.
|
|
303
|
+
alias['#foundation'] = '\0__foundation-noop__'
|
|
304
|
+
} else if (foundationInfo.type !== 'url') {
|
|
305
|
+
// Bundled mode: #foundation points to the actual package
|
|
296
306
|
alias['#foundation'] = foundationInfo.name
|
|
297
307
|
}
|
|
298
308
|
|
|
309
|
+
// Virtual module plugin for the noop foundation stub
|
|
310
|
+
const noopFoundationPlugin = isRuntimeMode ? {
|
|
311
|
+
name: 'uniweb:foundation-noop',
|
|
312
|
+
resolveId(id) {
|
|
313
|
+
if (id === '\0__foundation-noop__' || id.startsWith('\0__foundation-noop__')) return id
|
|
314
|
+
},
|
|
315
|
+
load(id) {
|
|
316
|
+
if (id === '\0__foundation-noop__') return 'export default {}'
|
|
317
|
+
// Handle #foundation/styles → noop CSS
|
|
318
|
+
if (id.startsWith('\0__foundation-noop__')) return ''
|
|
319
|
+
}
|
|
320
|
+
} : null
|
|
321
|
+
|
|
322
|
+
if (noopFoundationPlugin) plugins.push(noopFoundationPlugin)
|
|
323
|
+
|
|
324
|
+
// Import map plugin for runtime mode production builds
|
|
325
|
+
// Emits re-export modules for each externalized package (react, @uniweb/core, etc.)
|
|
326
|
+
// so the browser can resolve bare specifiers in the dynamically-imported foundation
|
|
327
|
+
const IMPORT_MAP_EXTERNALS = [
|
|
328
|
+
'react',
|
|
329
|
+
'react-dom',
|
|
330
|
+
'react/jsx-runtime',
|
|
331
|
+
'react/jsx-dev-runtime',
|
|
332
|
+
'@uniweb/core'
|
|
333
|
+
]
|
|
334
|
+
const IMPORT_MAP_PREFIX = '\0importmap:'
|
|
335
|
+
|
|
336
|
+
const importMapPlugin = isRuntimeMode ? (() => {
|
|
337
|
+
let isBuild = false
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
name: 'uniweb:import-map',
|
|
341
|
+
|
|
342
|
+
configResolved(config) {
|
|
343
|
+
isBuild = config.command === 'build'
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
resolveId(id) {
|
|
347
|
+
if (id.startsWith(IMPORT_MAP_PREFIX)) return id
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async load(id) {
|
|
351
|
+
if (!id.startsWith(IMPORT_MAP_PREFIX)) return
|
|
352
|
+
const pkg = id.slice(IMPORT_MAP_PREFIX.length)
|
|
353
|
+
// Dynamically discover exports at build time by importing the package.
|
|
354
|
+
// We generate explicit named re-exports (not `export *`) because CJS
|
|
355
|
+
// packages like React only expose a default via `export *`, losing
|
|
356
|
+
// individual named exports (useState, jsx, etc.) that foundations need.
|
|
357
|
+
try {
|
|
358
|
+
const mod = await import(pkg)
|
|
359
|
+
const names = Object.keys(mod).filter(k => k !== '__esModule')
|
|
360
|
+
const hasDefault = 'default' in mod
|
|
361
|
+
const named = names.filter(k => k !== 'default')
|
|
362
|
+
const lines = []
|
|
363
|
+
if (named.length) {
|
|
364
|
+
lines.push(`export { ${named.join(', ')} } from '${pkg}'`)
|
|
365
|
+
}
|
|
366
|
+
if (hasDefault) {
|
|
367
|
+
lines.push(`export { default } from '${pkg}'`)
|
|
368
|
+
}
|
|
369
|
+
return lines.join('\n') || `export {}`
|
|
370
|
+
} catch {
|
|
371
|
+
// Fallback: generic re-export (may not preserve named exports for CJS)
|
|
372
|
+
return `export * from '${pkg}'`
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
// Emit deterministic chunks for each external (production only).
|
|
377
|
+
// preserveSignature: 'exports-only' tells Rollup to preserve the original
|
|
378
|
+
// export names (useState, jsx, etc.) instead of mangling them.
|
|
379
|
+
// In dev mode, Vite's transformRequest() resolves bare specifiers instead.
|
|
380
|
+
buildStart() {
|
|
381
|
+
if (!isBuild) return
|
|
382
|
+
for (const ext of IMPORT_MAP_EXTERNALS) {
|
|
383
|
+
this.emitFile({
|
|
384
|
+
type: 'chunk',
|
|
385
|
+
id: `${IMPORT_MAP_PREFIX}${ext}`,
|
|
386
|
+
fileName: `_importmap/${ext.replace(/\//g, '-')}.js`,
|
|
387
|
+
preserveSignature: 'exports-only'
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
// Inject the import map into the HTML (production only).
|
|
393
|
+
// In dev mode, Vite's transformRequest() handles bare specifier resolution.
|
|
394
|
+
transformIndexHtml: {
|
|
395
|
+
order: 'pre',
|
|
396
|
+
handler(html) {
|
|
397
|
+
if (!isBuild) return html
|
|
398
|
+
const basePath = base || '/'
|
|
399
|
+
const imports = {}
|
|
400
|
+
for (const ext of IMPORT_MAP_EXTERNALS) {
|
|
401
|
+
imports[ext] = `${basePath}_importmap/${ext.replace(/\//g, '-')}.js`
|
|
402
|
+
}
|
|
403
|
+
const importMap = JSON.stringify({ imports }, null, 2)
|
|
404
|
+
const script = ` <script type="importmap">\n${importMap}\n </script>\n`
|
|
405
|
+
// Import map must appear before any module scripts
|
|
406
|
+
return html.replace('<head>', '<head>\n' + script)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
})() : null
|
|
411
|
+
|
|
412
|
+
if (importMapPlugin) plugins.push(importMapPlugin)
|
|
413
|
+
|
|
299
414
|
// Build foundation config for runtime
|
|
300
415
|
const foundationConfig = {
|
|
301
416
|
mode: isRuntimeMode ? 'runtime' : 'bundled',
|
|
@@ -311,7 +426,7 @@ export async function defineSiteConfig(options = {}) {
|
|
|
311
426
|
plugins,
|
|
312
427
|
|
|
313
428
|
define: {
|
|
314
|
-
__FOUNDATION_CONFIG__: JSON.stringify(foundationConfig)
|
|
429
|
+
__FOUNDATION_CONFIG__: isShellMode ? 'null' : JSON.stringify(foundationConfig)
|
|
315
430
|
},
|
|
316
431
|
|
|
317
432
|
resolve: {
|
|
@@ -185,6 +185,33 @@ function isMarkdownFile(filename) {
|
|
|
185
185
|
return filename.endsWith('.md') && !filename.startsWith('_')
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Read folder configuration, determining content mode from config file presence.
|
|
190
|
+
*
|
|
191
|
+
* - folder.yml present → pages mode (md files are child pages)
|
|
192
|
+
* - page.yml present → sections mode (md files are sections of this page)
|
|
193
|
+
* - Neither → inherit mode from parent
|
|
194
|
+
*
|
|
195
|
+
* @param {string} dirPath - Directory path
|
|
196
|
+
* @param {string} inheritedMode - Mode inherited from parent ('sections' or 'pages')
|
|
197
|
+
* @returns {Promise<{config: Object, mode: string, source: string}>}
|
|
198
|
+
*/
|
|
199
|
+
async function readFolderConfig(dirPath, inheritedMode) {
|
|
200
|
+
const folderYml = await readYamlFile(join(dirPath, 'folder.yml'))
|
|
201
|
+
if (Object.keys(folderYml).length > 0) {
|
|
202
|
+
return { config: folderYml, mode: 'pages', source: 'folder.yml' }
|
|
203
|
+
}
|
|
204
|
+
const pageYml = await readYamlFile(join(dirPath, 'page.yml'))
|
|
205
|
+
if (Object.keys(pageYml).length > 0) {
|
|
206
|
+
return { config: pageYml, mode: 'sections', source: 'page.yml' }
|
|
207
|
+
}
|
|
208
|
+
// Check for empty folder.yml (presence signals pages mode even if empty)
|
|
209
|
+
if (existsSync(join(dirPath, 'folder.yml'))) {
|
|
210
|
+
return { config: {}, mode: 'pages', source: 'folder.yml' }
|
|
211
|
+
}
|
|
212
|
+
return { config: {}, mode: inheritedMode, source: 'inherited' }
|
|
213
|
+
}
|
|
214
|
+
|
|
188
215
|
/**
|
|
189
216
|
* Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
|
|
190
217
|
* Supports:
|
|
@@ -226,6 +253,89 @@ function compareFilenames(a, b) {
|
|
|
226
253
|
return 0
|
|
227
254
|
}
|
|
228
255
|
|
|
256
|
+
/**
|
|
257
|
+
* Apply non-strict ordering to a list of items.
|
|
258
|
+
* Listed items appear first in array order, then unlisted items in their existing order.
|
|
259
|
+
*
|
|
260
|
+
* Unlike strict arrays (pages: [...], sections: [...]) which hide unlisted items,
|
|
261
|
+
* this preserves all items — it only affects order.
|
|
262
|
+
*
|
|
263
|
+
* @param {Array} items - Items with a .name property
|
|
264
|
+
* @param {Array<string>} orderArray - Names in desired order
|
|
265
|
+
* @returns {Array} Reordered items (all items preserved)
|
|
266
|
+
*/
|
|
267
|
+
function applyNonStrictOrder(items, orderArray) {
|
|
268
|
+
if (!Array.isArray(orderArray) || orderArray.length === 0) return items
|
|
269
|
+
const orderMap = new Map(orderArray.map((name, i) => [name, i]))
|
|
270
|
+
const listed = items.filter(i => orderMap.has(i.name))
|
|
271
|
+
.sort((a, b) => orderMap.get(a.name) - orderMap.get(b.name))
|
|
272
|
+
const unlisted = items.filter(i => !orderMap.has(i.name))
|
|
273
|
+
return [...listed, ...unlisted]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Process a markdown file as a standalone page (pages mode).
|
|
278
|
+
* Creates a page with a single section from the markdown content.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} filePath - Path to markdown file
|
|
281
|
+
* @param {string} fileName - Filename (e.g., "getting-started.md")
|
|
282
|
+
* @param {string} siteRoot - Site root directory for asset resolution
|
|
283
|
+
* @param {string} parentRoute - Parent route (e.g., '/docs')
|
|
284
|
+
* @returns {Promise<Object>} Page data with assets manifest
|
|
285
|
+
*/
|
|
286
|
+
async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
|
|
287
|
+
const { name } = parse(fileName)
|
|
288
|
+
const { prefix, name: stableName } = parseNumericPrefix(name)
|
|
289
|
+
const pageName = stableName || name
|
|
290
|
+
const route = parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
|
|
291
|
+
|
|
292
|
+
// Process the markdown as a single section
|
|
293
|
+
const { section, assetCollection, iconCollection } = await processMarkdownFile(
|
|
294
|
+
filePath, '1', siteRoot, pageName
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
const fileStat = await stat(filePath)
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
page: {
|
|
301
|
+
route,
|
|
302
|
+
sourcePath: null,
|
|
303
|
+
id: null,
|
|
304
|
+
isIndex: false,
|
|
305
|
+
title: pageName,
|
|
306
|
+
description: '',
|
|
307
|
+
label: null,
|
|
308
|
+
lastModified: fileStat.mtime?.toISOString() || null,
|
|
309
|
+
isDynamic: false,
|
|
310
|
+
paramName: null,
|
|
311
|
+
parentSchema: null,
|
|
312
|
+
version: null,
|
|
313
|
+
versionMeta: null,
|
|
314
|
+
versionScope: null,
|
|
315
|
+
hidden: false,
|
|
316
|
+
hideInHeader: false,
|
|
317
|
+
hideInFooter: false,
|
|
318
|
+
layout: {
|
|
319
|
+
header: true,
|
|
320
|
+
footer: true,
|
|
321
|
+
leftPanel: true,
|
|
322
|
+
rightPanel: true
|
|
323
|
+
},
|
|
324
|
+
seo: {
|
|
325
|
+
noindex: false,
|
|
326
|
+
image: null,
|
|
327
|
+
changefreq: null,
|
|
328
|
+
priority: null
|
|
329
|
+
},
|
|
330
|
+
fetch: null,
|
|
331
|
+
sections: [section],
|
|
332
|
+
order: prefix ? parseFloat(prefix) : undefined
|
|
333
|
+
},
|
|
334
|
+
assetCollection,
|
|
335
|
+
iconCollection
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
229
339
|
/**
|
|
230
340
|
* Process a markdown file into a section
|
|
231
341
|
*
|
|
@@ -629,12 +739,13 @@ function determineIndexPage(orderConfig, availableFolders) {
|
|
|
629
739
|
* @param {string} dirPath - Directory to scan
|
|
630
740
|
* @param {string} parentRoute - Parent route (e.g., '/' or '/docs')
|
|
631
741
|
* @param {string} siteRoot - Site root directory for asset resolution
|
|
632
|
-
* @param {Object} orderConfig - { pages: [...], index: 'name' } from parent's config
|
|
742
|
+
* @param {Object} orderConfig - { pages: [...], index: 'name', order: [...] } from parent's config
|
|
633
743
|
* @param {Object} parentFetch - Parent page's fetch config (for dynamic child routes)
|
|
634
744
|
* @param {Object} versionContext - Version context from parent { version, versionMeta }
|
|
635
|
-
* @
|
|
745
|
+
* @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
|
|
746
|
+
* @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
636
747
|
*/
|
|
637
|
-
async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null) {
|
|
748
|
+
async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections') {
|
|
638
749
|
const entries = await readdir(dirPath)
|
|
639
750
|
const pages = []
|
|
640
751
|
let assetCollection = {
|
|
@@ -649,23 +760,28 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
649
760
|
let notFound = null
|
|
650
761
|
const versionedScopes = new Map() // scope route → versionMeta
|
|
651
762
|
|
|
652
|
-
// First pass: discover all page folders and read their
|
|
763
|
+
// First pass: discover all page folders and read their config
|
|
653
764
|
const pageFolders = []
|
|
654
765
|
for (const entry of entries) {
|
|
655
766
|
const entryPath = join(dirPath, entry)
|
|
656
767
|
const stats = await stat(entryPath)
|
|
657
768
|
if (!stats.isDirectory()) continue
|
|
658
769
|
|
|
659
|
-
// Read page.yml to
|
|
660
|
-
const
|
|
770
|
+
// Read folder.yml or page.yml to determine mode and get config
|
|
771
|
+
const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
|
|
772
|
+
const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
773
|
+
const childOrderArray = Array.isArray(dirConfig.order) ? dirConfig.order : undefined
|
|
774
|
+
|
|
661
775
|
pageFolders.push({
|
|
662
776
|
name: entry,
|
|
663
777
|
path: entryPath,
|
|
664
|
-
order:
|
|
665
|
-
|
|
778
|
+
order: numericOrder,
|
|
779
|
+
dirConfig,
|
|
780
|
+
dirMode,
|
|
666
781
|
childOrderConfig: {
|
|
667
|
-
pages:
|
|
668
|
-
index:
|
|
782
|
+
pages: dirConfig.pages,
|
|
783
|
+
index: dirConfig.index,
|
|
784
|
+
order: childOrderArray
|
|
669
785
|
}
|
|
670
786
|
})
|
|
671
787
|
}
|
|
@@ -679,67 +795,46 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
679
795
|
return a.name.localeCompare(b.name)
|
|
680
796
|
})
|
|
681
797
|
|
|
798
|
+
// Apply non-strict order from parent config (if present)
|
|
799
|
+
const orderedFolders = applyNonStrictOrder(pageFolders, orderConfig?.order)
|
|
800
|
+
|
|
682
801
|
// Check if this directory contains version folders (versioned section)
|
|
683
|
-
const folderNames =
|
|
802
|
+
const folderNames = orderedFolders.map(f => f.name)
|
|
684
803
|
const detectedVersions = detectVersions(folderNames)
|
|
685
804
|
|
|
686
|
-
// If versioned section, handle version folders specially
|
|
805
|
+
// If versioned section, handle version folders specially (always sections mode)
|
|
687
806
|
if (detectedVersions && !versionContext) {
|
|
688
|
-
// Read parent page.yml for version metadata
|
|
689
807
|
const parentConfig = await readYamlFile(join(dirPath, 'page.yml'))
|
|
690
808
|
const versionMeta = buildVersionMetadata(detectedVersions, parentConfig)
|
|
691
|
-
|
|
692
|
-
// Record this versioned scope
|
|
693
809
|
versionedScopes.set(parentRoute, versionMeta)
|
|
694
810
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
const { name: entry, path: entryPath, childOrderConfig, pageConfig } = folder
|
|
811
|
+
for (const folder of orderedFolders) {
|
|
812
|
+
const { name: entry, path: entryPath, childOrderConfig } = folder
|
|
698
813
|
|
|
699
814
|
if (isVersionFolder(entry)) {
|
|
700
|
-
// This is a version folder
|
|
701
815
|
const versionInfo = versionMeta.versions.find(v => v.id === entry)
|
|
702
816
|
const isLatest = versionInfo?.latest || false
|
|
703
|
-
|
|
704
|
-
// For latest version, use parent route directly
|
|
705
|
-
// For other versions, add version prefix to route
|
|
706
|
-
// Handle root scope specially to avoid double slash (//v1 → /v1)
|
|
707
817
|
const versionRoute = isLatest
|
|
708
818
|
? parentRoute
|
|
709
819
|
: parentRoute === '/'
|
|
710
820
|
? `/${entry}`
|
|
711
821
|
: `${parentRoute}/${entry}`
|
|
712
822
|
|
|
713
|
-
// Recurse into version folder with version context
|
|
714
823
|
const subResult = await collectPagesRecursive(
|
|
715
|
-
entryPath,
|
|
716
|
-
|
|
717
|
-
siteRoot,
|
|
718
|
-
childOrderConfig,
|
|
719
|
-
parentFetch,
|
|
720
|
-
{
|
|
721
|
-
version: versionInfo,
|
|
722
|
-
versionMeta,
|
|
723
|
-
scope: parentRoute // The route where versioning is scoped
|
|
724
|
-
}
|
|
824
|
+
entryPath, versionRoute, siteRoot, childOrderConfig, parentFetch,
|
|
825
|
+
{ version: versionInfo, versionMeta, scope: parentRoute }
|
|
725
826
|
)
|
|
726
827
|
|
|
727
828
|
pages.push(...subResult.pages)
|
|
728
829
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
729
830
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
730
|
-
// Merge any nested versioned scopes (shouldn't happen often, but possible)
|
|
731
831
|
for (const [scope, meta] of subResult.versionedScopes) {
|
|
732
832
|
versionedScopes.set(scope, meta)
|
|
733
833
|
}
|
|
734
834
|
} else {
|
|
735
|
-
// Non-version folders in a versioned section
|
|
736
|
-
// These could be shared across versions - process normally
|
|
737
835
|
const result = await processPage(entryPath, entry, siteRoot, {
|
|
738
|
-
isIndex: false,
|
|
739
|
-
parentRoute,
|
|
740
|
-
parentFetch
|
|
836
|
+
isIndex: false, parentRoute, parentFetch
|
|
741
837
|
})
|
|
742
|
-
|
|
743
838
|
if (result) {
|
|
744
839
|
pages.push(result.page)
|
|
745
840
|
assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
|
|
@@ -748,67 +843,235 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
748
843
|
}
|
|
749
844
|
}
|
|
750
845
|
|
|
751
|
-
// Return early - we've handled all children
|
|
752
846
|
return { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
753
847
|
}
|
|
754
848
|
|
|
755
|
-
//
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const hasExplicitOrder = orderConfig?.index || (Array.isArray(orderConfig?.pages) && orderConfig.pages.length > 0)
|
|
761
|
-
const hasMdContent = entries.some(e => isMarkdownFile(e))
|
|
762
|
-
const indexPageName = hasMdContent ? null : determineIndexPage(orderConfig, regularFolders)
|
|
849
|
+
// --- Pages mode: .md files are child pages ---
|
|
850
|
+
if (contentMode === 'pages') {
|
|
851
|
+
// Collect and process .md files as individual pages
|
|
852
|
+
const mdFiles = entries.filter(isMarkdownFile).sort(compareFilenames)
|
|
853
|
+
const mdPageItems = []
|
|
763
854
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
855
|
+
for (const file of mdFiles) {
|
|
856
|
+
const { name } = parse(file)
|
|
857
|
+
const { name: stableName } = parseNumericPrefix(name)
|
|
858
|
+
const result = await processFileAsPage(join(dirPath, file), file, siteRoot, parentRoute)
|
|
859
|
+
if (result) {
|
|
860
|
+
mdPageItems.push({ name: stableName || name, result })
|
|
861
|
+
}
|
|
862
|
+
}
|
|
768
863
|
|
|
769
|
-
//
|
|
770
|
-
|
|
771
|
-
const result = await processPage(entryPath, entry, siteRoot, {
|
|
772
|
-
isIndex,
|
|
773
|
-
parentRoute,
|
|
774
|
-
parentFetch,
|
|
775
|
-
versionContext
|
|
776
|
-
})
|
|
864
|
+
// Apply non-strict order to md-file-pages
|
|
865
|
+
const orderedMdPages = applyNonStrictOrder(mdPageItems, orderConfig?.order)
|
|
777
866
|
|
|
778
|
-
if
|
|
867
|
+
// In pages mode, only promote an index if explicitly set via index: in folder.yml
|
|
868
|
+
// The container page itself owns the parent route — don't auto-promote children
|
|
869
|
+
const indexName = orderConfig?.index || null
|
|
870
|
+
|
|
871
|
+
// Add md-file-pages
|
|
872
|
+
for (const { name, result } of orderedMdPages) {
|
|
779
873
|
const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
|
|
780
874
|
assetCollection = mergeAssetCollections(assetCollection, pageAssets)
|
|
781
875
|
iconCollection = mergeIconCollections(iconCollection, pageIcons)
|
|
782
876
|
|
|
783
|
-
// Handle
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
877
|
+
// Handle index: promote to parent route
|
|
878
|
+
if (name === indexName) {
|
|
879
|
+
page.isIndex = true
|
|
880
|
+
page.sourcePath = page.route
|
|
881
|
+
page.route = parentRoute
|
|
788
882
|
}
|
|
789
883
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
//
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
884
|
+
pages.push(page)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Process subdirectories
|
|
888
|
+
for (const folder of orderedFolders) {
|
|
889
|
+
const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
|
|
890
|
+
const isIndex = entry === indexName
|
|
891
|
+
|
|
892
|
+
if (dirMode === 'sections') {
|
|
893
|
+
// Subdirectory overrides to sections mode — process normally
|
|
894
|
+
const result = await processPage(entryPath, entry, siteRoot, {
|
|
895
|
+
isIndex, parentRoute, parentFetch, versionContext
|
|
896
|
+
})
|
|
897
|
+
|
|
898
|
+
if (result) {
|
|
899
|
+
const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
|
|
900
|
+
assetCollection = mergeAssetCollections(assetCollection, pageAssets)
|
|
901
|
+
iconCollection = mergeIconCollections(iconCollection, pageIcons)
|
|
902
|
+
pages.push(page)
|
|
903
|
+
|
|
904
|
+
// Recurse into subdirectories (sections mode)
|
|
905
|
+
const childParentRoute = isIndex ? parentRoute : page.route
|
|
906
|
+
const childFetch = page.fetch || parentFetch
|
|
907
|
+
const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections')
|
|
908
|
+
pages.push(...subResult.pages)
|
|
909
|
+
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
910
|
+
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
911
|
+
for (const [scope, meta] of subResult.versionedScopes) {
|
|
912
|
+
versionedScopes.set(scope, meta)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
} else {
|
|
916
|
+
// Container directory in pages mode — create minimal page, recurse
|
|
917
|
+
const containerRoute = isIndex
|
|
918
|
+
? parentRoute
|
|
919
|
+
: parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
|
|
920
|
+
|
|
921
|
+
const containerPage = {
|
|
922
|
+
route: containerRoute,
|
|
923
|
+
sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
|
|
924
|
+
id: dirConfig.id || null,
|
|
925
|
+
isIndex,
|
|
926
|
+
title: dirConfig.title || entry,
|
|
927
|
+
description: dirConfig.description || '',
|
|
928
|
+
label: dirConfig.label || null,
|
|
929
|
+
lastModified: null,
|
|
930
|
+
isDynamic: false,
|
|
931
|
+
paramName: null,
|
|
932
|
+
parentSchema: null,
|
|
933
|
+
version: versionContext?.version || null,
|
|
934
|
+
versionMeta: versionContext?.versionMeta || null,
|
|
935
|
+
versionScope: versionContext?.scope || null,
|
|
936
|
+
hidden: dirConfig.hidden || false,
|
|
937
|
+
hideInHeader: dirConfig.hideInHeader || false,
|
|
938
|
+
hideInFooter: dirConfig.hideInFooter || false,
|
|
939
|
+
layout: {
|
|
940
|
+
header: dirConfig.layout?.header !== false,
|
|
941
|
+
footer: dirConfig.layout?.footer !== false,
|
|
942
|
+
leftPanel: dirConfig.layout?.leftPanel !== false,
|
|
943
|
+
rightPanel: dirConfig.layout?.rightPanel !== false
|
|
944
|
+
},
|
|
945
|
+
seo: {
|
|
946
|
+
noindex: dirConfig.seo?.noindex || false,
|
|
947
|
+
image: dirConfig.seo?.image || null,
|
|
948
|
+
changefreq: dirConfig.seo?.changefreq || null,
|
|
949
|
+
priority: dirConfig.seo?.priority || null
|
|
950
|
+
},
|
|
951
|
+
fetch: null,
|
|
952
|
+
sections: [],
|
|
953
|
+
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
pages.push(containerPage)
|
|
957
|
+
|
|
958
|
+
// Recurse in pages mode
|
|
959
|
+
const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
|
|
803
960
|
pages.push(...subResult.pages)
|
|
804
961
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
805
962
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
806
|
-
// Merge any versioned scopes from children
|
|
807
963
|
for (const [scope, meta] of subResult.versionedScopes) {
|
|
808
964
|
versionedScopes.set(scope, meta)
|
|
809
965
|
}
|
|
810
966
|
}
|
|
811
967
|
}
|
|
968
|
+
|
|
969
|
+
return { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// --- Sections mode (default): existing behavior ---
|
|
973
|
+
|
|
974
|
+
// Determine which page is the index for this level
|
|
975
|
+
// A directory with its own .md content is a real page, not a container —
|
|
976
|
+
// never promote a child as index, even if explicit config says so
|
|
977
|
+
const hasExplicitOrder = orderConfig?.index || (Array.isArray(orderConfig?.pages) && orderConfig.pages.length > 0)
|
|
978
|
+
const hasMdContent = entries.some(e => isMarkdownFile(e))
|
|
979
|
+
const indexPageName = hasMdContent ? null : determineIndexPage(orderConfig, orderedFolders)
|
|
980
|
+
|
|
981
|
+
// Second pass: process each page folder
|
|
982
|
+
for (const folder of orderedFolders) {
|
|
983
|
+
const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
|
|
984
|
+
const isIndex = entry === indexPageName
|
|
985
|
+
|
|
986
|
+
if (dirMode === 'pages') {
|
|
987
|
+
// Child directory switches to pages mode (has folder.yml) —
|
|
988
|
+
// create container page with empty sections, recurse in pages mode
|
|
989
|
+
const containerRoute = isIndex
|
|
990
|
+
? parentRoute
|
|
991
|
+
: parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
|
|
992
|
+
|
|
993
|
+
const containerPage = {
|
|
994
|
+
route: containerRoute,
|
|
995
|
+
sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
|
|
996
|
+
id: dirConfig.id || null,
|
|
997
|
+
isIndex,
|
|
998
|
+
title: dirConfig.title || entry,
|
|
999
|
+
description: dirConfig.description || '',
|
|
1000
|
+
label: dirConfig.label || null,
|
|
1001
|
+
lastModified: null,
|
|
1002
|
+
isDynamic: false,
|
|
1003
|
+
paramName: null,
|
|
1004
|
+
parentSchema: null,
|
|
1005
|
+
version: versionContext?.version || null,
|
|
1006
|
+
versionMeta: versionContext?.versionMeta || null,
|
|
1007
|
+
versionScope: versionContext?.scope || null,
|
|
1008
|
+
hidden: dirConfig.hidden || false,
|
|
1009
|
+
hideInHeader: dirConfig.hideInHeader || false,
|
|
1010
|
+
hideInFooter: dirConfig.hideInFooter || false,
|
|
1011
|
+
layout: {
|
|
1012
|
+
header: dirConfig.layout?.header !== false,
|
|
1013
|
+
footer: dirConfig.layout?.footer !== false,
|
|
1014
|
+
leftPanel: dirConfig.layout?.leftPanel !== false,
|
|
1015
|
+
rightPanel: dirConfig.layout?.rightPanel !== false
|
|
1016
|
+
},
|
|
1017
|
+
seo: {
|
|
1018
|
+
noindex: dirConfig.seo?.noindex || false,
|
|
1019
|
+
image: dirConfig.seo?.image || null,
|
|
1020
|
+
changefreq: dirConfig.seo?.changefreq || null,
|
|
1021
|
+
priority: dirConfig.seo?.priority || null
|
|
1022
|
+
},
|
|
1023
|
+
fetch: null,
|
|
1024
|
+
sections: [],
|
|
1025
|
+
order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
if (parentRoute === '/' && entry === '404') {
|
|
1029
|
+
notFound = containerPage
|
|
1030
|
+
} else {
|
|
1031
|
+
pages.push(containerPage)
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
|
|
1035
|
+
pages.push(...subResult.pages)
|
|
1036
|
+
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
1037
|
+
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
1038
|
+
for (const [scope, meta] of subResult.versionedScopes) {
|
|
1039
|
+
versionedScopes.set(scope, meta)
|
|
1040
|
+
}
|
|
1041
|
+
} else {
|
|
1042
|
+
// Sections mode — process directory as a page (existing behavior)
|
|
1043
|
+
const result = await processPage(entryPath, entry, siteRoot, {
|
|
1044
|
+
isIndex, parentRoute, parentFetch, versionContext
|
|
1045
|
+
})
|
|
1046
|
+
|
|
1047
|
+
if (result) {
|
|
1048
|
+
const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
|
|
1049
|
+
assetCollection = mergeAssetCollections(assetCollection, pageAssets)
|
|
1050
|
+
iconCollection = mergeIconCollections(iconCollection, pageIcons)
|
|
1051
|
+
|
|
1052
|
+
// Handle 404 page - only at root level
|
|
1053
|
+
if (parentRoute === '/' && entry === '404') {
|
|
1054
|
+
notFound = page
|
|
1055
|
+
} else {
|
|
1056
|
+
pages.push(page)
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Recursively process subdirectories
|
|
1060
|
+
{
|
|
1061
|
+
const childParentRoute = isIndex
|
|
1062
|
+
? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
|
|
1063
|
+
: page.route
|
|
1064
|
+
const childFetch = page.fetch || parentFetch
|
|
1065
|
+
const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode)
|
|
1066
|
+
pages.push(...subResult.pages)
|
|
1067
|
+
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
1068
|
+
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
1069
|
+
for (const [scope, meta] of subResult.versionedScopes) {
|
|
1070
|
+
versionedScopes.set(scope, meta)
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
812
1075
|
}
|
|
813
1076
|
|
|
814
1077
|
return { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
@@ -930,15 +1193,19 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
930
1193
|
// Extract page ordering config from site.yml
|
|
931
1194
|
const siteOrderConfig = {
|
|
932
1195
|
pages: siteConfig.pages,
|
|
933
|
-
index: siteConfig.index
|
|
1196
|
+
index: siteConfig.index,
|
|
1197
|
+
order: Array.isArray(siteConfig.order) ? siteConfig.order : undefined
|
|
934
1198
|
}
|
|
935
1199
|
|
|
1200
|
+
// Determine root content mode from folder.yml/page.yml presence in pages directory
|
|
1201
|
+
const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
|
|
1202
|
+
|
|
936
1203
|
// Collect layout panels from layout/ directory
|
|
937
1204
|
const { header, footer, left, right } = await collectLayoutPanels(layoutPath, sitePath)
|
|
938
1205
|
|
|
939
1206
|
// Recursively collect all pages
|
|
940
1207
|
const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
|
|
941
|
-
await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig)
|
|
1208
|
+
await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode)
|
|
942
1209
|
|
|
943
1210
|
// Deduplicate: remove content-less container pages whose route duplicates
|
|
944
1211
|
// a content-bearing page (e.g., a promoted index page)
|
package/src/site/plugin.js
CHANGED
|
@@ -338,6 +338,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
338
338
|
pagesDir = 'pages',
|
|
339
339
|
variableName = '__SITE_CONTENT__',
|
|
340
340
|
inject = true,
|
|
341
|
+
shell = false,
|
|
341
342
|
filename = 'site-content.json',
|
|
342
343
|
watch: shouldWatch = true,
|
|
343
344
|
seo = {},
|
|
@@ -379,6 +380,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
379
380
|
let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
|
|
380
381
|
let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
|
|
381
382
|
let resolvedCollectionsBase = null // Resolved from site.yml collectionsDir
|
|
383
|
+
let headHtml = '' // Contents of site/head.html for injection
|
|
382
384
|
|
|
383
385
|
/**
|
|
384
386
|
* Load translations for a specific locale
|
|
@@ -426,6 +428,18 @@ export function siteContentPlugin(options = {}) {
|
|
|
426
428
|
}
|
|
427
429
|
}
|
|
428
430
|
|
|
431
|
+
/**
|
|
432
|
+
* Read head.html from site root (if it exists)
|
|
433
|
+
*/
|
|
434
|
+
async function loadHeadHtml() {
|
|
435
|
+
const headPath = resolve(resolvedSitePath, 'head.html')
|
|
436
|
+
try {
|
|
437
|
+
return await readFile(headPath, 'utf-8')
|
|
438
|
+
} catch {
|
|
439
|
+
return ''
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
429
443
|
/**
|
|
430
444
|
* Get available locales from locales directory
|
|
431
445
|
*/
|
|
@@ -532,6 +546,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
532
546
|
// Collect content at build start
|
|
533
547
|
try {
|
|
534
548
|
siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
|
|
549
|
+
headHtml = await loadHeadHtml()
|
|
535
550
|
console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
|
|
536
551
|
|
|
537
552
|
// Process content collections if defined in site.yml
|
|
@@ -579,6 +594,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
579
594
|
console.log('[site-content] Content changed, rebuilding...')
|
|
580
595
|
try {
|
|
581
596
|
siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
|
|
597
|
+
headHtml = await loadHeadHtml()
|
|
582
598
|
// Execute fetches for the updated content
|
|
583
599
|
await executeDevFetches(siteContent, resolvedSitePath)
|
|
584
600
|
console.log(`[site-content] Rebuilt ${siteContent.pages?.length || 0} pages`)
|
|
@@ -649,6 +665,14 @@ export function siteContentPlugin(options = {}) {
|
|
|
649
665
|
// theme.yml may not exist, that's ok
|
|
650
666
|
}
|
|
651
667
|
|
|
668
|
+
// Watch head.html
|
|
669
|
+
const headHtmlPath = resolve(resolvedSitePath, 'head.html')
|
|
670
|
+
try {
|
|
671
|
+
watchers.push(watch(headHtmlPath, scheduleRebuild))
|
|
672
|
+
} catch {
|
|
673
|
+
// head.html may not exist, that's ok
|
|
674
|
+
}
|
|
675
|
+
|
|
652
676
|
// Watch content/ folder for collection changes
|
|
653
677
|
// Use collectionsConfig cached from configResolved (siteContent may be null here)
|
|
654
678
|
if (collectionsConfig) {
|
|
@@ -849,6 +873,9 @@ export function siteContentPlugin(options = {}) {
|
|
|
849
873
|
async transformIndexHtml(html, ctx) {
|
|
850
874
|
if (!siteContent) return html
|
|
851
875
|
|
|
876
|
+
// Shell mode: skip all HTML injections — backend provides __DATA__ at serve time
|
|
877
|
+
if (shell) return html
|
|
878
|
+
|
|
852
879
|
// Detect locale from URL (e.g., /es/about → 'es')
|
|
853
880
|
let contentToInject = siteContent
|
|
854
881
|
let activeLocale = null
|
|
@@ -872,6 +899,29 @@ export function siteContentPlugin(options = {}) {
|
|
|
872
899
|
|
|
873
900
|
let headInjection = ''
|
|
874
901
|
|
|
902
|
+
// Inject user's head.html (analytics, third-party scripts)
|
|
903
|
+
if (headHtml) {
|
|
904
|
+
headInjection += headHtml + '\n'
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Inject font preconnect links (before theme CSS so browser starts DNS early)
|
|
908
|
+
const fontImports = contentToInject.theme?.fonts?.import
|
|
909
|
+
if (Array.isArray(fontImports) && fontImports.length > 0) {
|
|
910
|
+
const origins = new Set()
|
|
911
|
+
for (const font of fontImports) {
|
|
912
|
+
if (font.url) {
|
|
913
|
+
try { origins.add(new URL(font.url).origin) } catch {}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
for (const origin of origins) {
|
|
917
|
+
headInjection += ` <link rel="preconnect" href="${origin}">\n`
|
|
918
|
+
}
|
|
919
|
+
// Google Fonts serves CSS from googleapis.com but font files from gstatic.com
|
|
920
|
+
if (origins.has('https://fonts.googleapis.com')) {
|
|
921
|
+
headInjection += ` <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n`
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
875
925
|
// Inject theme CSS
|
|
876
926
|
if (contentToInject.theme?.css) {
|
|
877
927
|
headInjection += ` <style id="uniweb-theme">\n${contentToInject.theme.css}\n </style>\n`
|