@uniweb/build 0.6.4 → 0.6.6
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/i18n/extract.js +1 -1
- package/src/i18n/merge.js +3 -3
- package/src/prerender.js +49 -0
- package/src/search/extract.js +0 -5
- package/src/site/collection-processor.js +6 -5
- package/src/site/config.js +136 -7
- package/src/site/content-collector.js +407 -111
- package/src/site/plugin.js +104 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.6",
|
|
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.15",
|
|
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/i18n/extract.js
CHANGED
|
@@ -41,7 +41,7 @@ export function extractTranslatableContent(siteContent) {
|
|
|
41
41
|
for (const layoutKey of ['header', 'footer', 'left', 'right']) {
|
|
42
42
|
const layoutPage = siteContent[layoutKey]
|
|
43
43
|
if (layoutPage?.sections) {
|
|
44
|
-
const pageRoute = layoutPage.route ||
|
|
44
|
+
const pageRoute = layoutPage.route || `/layout/${layoutKey}`
|
|
45
45
|
for (const section of layoutPage.sections) {
|
|
46
46
|
extractFromSection(section, pageRoute, units)
|
|
47
47
|
}
|
package/src/i18n/merge.js
CHANGED
|
@@ -80,7 +80,7 @@ function mergeTranslationsSync(siteContent, translations, fallbackToSource) {
|
|
|
80
80
|
for (const layoutKey of ['header', 'footer', 'left', 'right']) {
|
|
81
81
|
const layoutPage = translated[layoutKey]
|
|
82
82
|
if (layoutPage?.sections) {
|
|
83
|
-
const pageRoute = layoutPage.route ||
|
|
83
|
+
const pageRoute = layoutPage.route || `/layout/${layoutKey}`
|
|
84
84
|
for (const section of layoutPage.sections) {
|
|
85
85
|
translateSectionSync(section, pageRoute, translations, fallbackToSource)
|
|
86
86
|
}
|
|
@@ -132,8 +132,8 @@ async function mergeTranslationsAsync(siteContent, translations, options) {
|
|
|
132
132
|
for (const layoutKey of ['header', 'footer', 'left', 'right']) {
|
|
133
133
|
const layoutPage = translated[layoutKey]
|
|
134
134
|
if (layoutPage?.sections) {
|
|
135
|
-
// Ensure route is set for context matching
|
|
136
|
-
if (!layoutPage.route) layoutPage.route =
|
|
135
|
+
// Ensure route is set for context matching
|
|
136
|
+
if (!layoutPage.route) layoutPage.route = `/layout/${layoutKey}`
|
|
137
137
|
for (const section of layoutPage.sections) {
|
|
138
138
|
await translateSectionAsync(section, layoutPage, translations, {
|
|
139
139
|
fallbackToSource,
|
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/search/extract.js
CHANGED
|
@@ -41,11 +41,6 @@ export function extractSearchContent(siteContent, options = {}) {
|
|
|
41
41
|
continue
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
// Skip special pages (header, footer, etc.)
|
|
45
|
-
if (pageRoute.startsWith('/@')) {
|
|
46
|
-
continue
|
|
47
|
-
}
|
|
48
|
-
|
|
49
44
|
// Skip pages marked as noindex
|
|
50
45
|
if (page.seo?.noindex) {
|
|
51
46
|
continue
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import { readFile, readdir, stat, writeFile, mkdir, copyFile } from 'node:fs/promises'
|
|
29
|
-
import { join, basename, extname, dirname, relative } from 'node:path'
|
|
29
|
+
import { join, basename, extname, dirname, relative, resolve } from 'node:path'
|
|
30
30
|
import { existsSync } from 'node:fs'
|
|
31
31
|
import yaml from 'js-yaml'
|
|
32
32
|
import { applyFilter, applySort } from './data-fetcher.js'
|
|
@@ -421,8 +421,9 @@ async function processContentItem(dir, filename, config, siteRoot) {
|
|
|
421
421
|
* @param {Object} config - Parsed collection config
|
|
422
422
|
* @returns {Promise<Array>} Array of processed items
|
|
423
423
|
*/
|
|
424
|
-
async function collectItems(siteDir, config) {
|
|
425
|
-
const
|
|
424
|
+
async function collectItems(siteDir, config, collectionsBase) {
|
|
425
|
+
const base = collectionsBase || siteDir
|
|
426
|
+
const collectionDir = resolve(base, config.path)
|
|
426
427
|
|
|
427
428
|
// Check if collection directory exists
|
|
428
429
|
if (!existsSync(collectionDir)) {
|
|
@@ -496,7 +497,7 @@ async function collectItems(siteDir, config) {
|
|
|
496
497
|
* })
|
|
497
498
|
* // { articles: [...], products: [...] }
|
|
498
499
|
*/
|
|
499
|
-
export async function processCollections(siteDir, collectionsConfig) {
|
|
500
|
+
export async function processCollections(siteDir, collectionsConfig, collectionsBase) {
|
|
500
501
|
if (!collectionsConfig || typeof collectionsConfig !== 'object') {
|
|
501
502
|
return {}
|
|
502
503
|
}
|
|
@@ -505,7 +506,7 @@ export async function processCollections(siteDir, collectionsConfig) {
|
|
|
505
506
|
|
|
506
507
|
for (const [name, config] of Object.entries(collectionsConfig)) {
|
|
507
508
|
const parsed = parseCollectionConfig(name, config)
|
|
508
|
-
const items = await collectItems(siteDir, parsed)
|
|
509
|
+
const items = await collectItems(siteDir, parsed, collectionsBase)
|
|
509
510
|
results[name] = items
|
|
510
511
|
console.log(`[collection-processor] Processed ${name}: ${items.length} items`)
|
|
511
512
|
}
|
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: {
|
|
@@ -328,7 +443,21 @@ export async function defineSiteConfig(options = {}) {
|
|
|
328
443
|
server: {
|
|
329
444
|
fs: {
|
|
330
445
|
// Allow parent directory for foundation sibling access
|
|
331
|
-
|
|
446
|
+
// Plus any external content paths from site.yml paths: group
|
|
447
|
+
allow: (() => {
|
|
448
|
+
const allowed = ['..']
|
|
449
|
+
const parentDir = resolve(siteRoot, '..')
|
|
450
|
+
const paths = siteConfig.paths || {}
|
|
451
|
+
for (const key of ['pages', 'layout', 'collections']) {
|
|
452
|
+
if (paths[key]) {
|
|
453
|
+
const resolved = resolve(siteRoot, paths[key])
|
|
454
|
+
if (!resolved.startsWith(parentDir)) {
|
|
455
|
+
allowed.push(resolved)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return allowed
|
|
460
|
+
})()
|
|
332
461
|
},
|
|
333
462
|
...(siteConfig.build?.port && { port: siteConfig.build.port }),
|
|
334
463
|
...serverOverrides
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Content Collector
|
|
3
3
|
*
|
|
4
|
-
* Collects site content from a
|
|
4
|
+
* Collects site content from a site directory structure:
|
|
5
5
|
* - site.yml: Site configuration
|
|
6
6
|
* - pages/: Directory of page folders
|
|
7
7
|
* - page.yml: Page metadata
|
|
8
8
|
* - *.md: Section content with YAML frontmatter
|
|
9
|
+
* - layout/: Layout panel folders (header, footer, left, right)
|
|
9
10
|
*
|
|
10
11
|
* Section frontmatter reserved properties:
|
|
11
12
|
* - type: Component type (e.g., "Hero", "Features")
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
26
|
-
import { join, parse } from 'node:path'
|
|
27
|
+
import { join, parse, resolve } from 'node:path'
|
|
27
28
|
import { existsSync } from 'node:fs'
|
|
28
29
|
import yaml from 'js-yaml'
|
|
29
30
|
import { collectSectionAssets, mergeAssetCollections } from './assets.js'
|
|
@@ -184,6 +185,33 @@ function isMarkdownFile(filename) {
|
|
|
184
185
|
return filename.endsWith('.md') && !filename.startsWith('_')
|
|
185
186
|
}
|
|
186
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
|
+
|
|
187
215
|
/**
|
|
188
216
|
* Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
|
|
189
217
|
* Supports:
|
|
@@ -225,6 +253,89 @@ function compareFilenames(a, b) {
|
|
|
225
253
|
return 0
|
|
226
254
|
}
|
|
227
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
|
+
|
|
228
339
|
/**
|
|
229
340
|
* Process a markdown file into a section
|
|
230
341
|
*
|
|
@@ -502,10 +613,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
502
613
|
|
|
503
614
|
// First, calculate the folder-based route (what the route would be without index handling)
|
|
504
615
|
let folderRoute
|
|
505
|
-
if (
|
|
506
|
-
// Special pages (layout areas) keep their @ prefix
|
|
507
|
-
folderRoute = parentRoute === '/' ? `/@${pageName.slice(1)}` : `${parentRoute}/@${pageName.slice(1)}`
|
|
508
|
-
} else if (isDynamic) {
|
|
616
|
+
if (isDynamic) {
|
|
509
617
|
// Dynamic routes: /blog/[slug] → /blog/:slug (for route matching)
|
|
510
618
|
folderRoute = parentRoute === '/' ? `/:${paramName}` : `${parentRoute}/:${paramName}`
|
|
511
619
|
} else {
|
|
@@ -631,12 +739,13 @@ function determineIndexPage(orderConfig, availableFolders) {
|
|
|
631
739
|
* @param {string} dirPath - Directory to scan
|
|
632
740
|
* @param {string} parentRoute - Parent route (e.g., '/' or '/docs')
|
|
633
741
|
* @param {string} siteRoot - Site root directory for asset resolution
|
|
634
|
-
* @param {Object} orderConfig - { pages: [...], index: 'name' } from parent's config
|
|
742
|
+
* @param {Object} orderConfig - { pages: [...], index: 'name', order: [...] } from parent's config
|
|
635
743
|
* @param {Object} parentFetch - Parent page's fetch config (for dynamic child routes)
|
|
636
744
|
* @param {Object} versionContext - Version context from parent { version, versionMeta }
|
|
637
|
-
* @
|
|
745
|
+
* @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
|
|
746
|
+
* @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
638
747
|
*/
|
|
639
|
-
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') {
|
|
640
749
|
const entries = await readdir(dirPath)
|
|
641
750
|
const pages = []
|
|
642
751
|
let assetCollection = {
|
|
@@ -648,30 +757,31 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
648
757
|
icons: new Set(),
|
|
649
758
|
bySource: new Map()
|
|
650
759
|
}
|
|
651
|
-
let header = null
|
|
652
|
-
let footer = null
|
|
653
|
-
let left = null
|
|
654
|
-
let right = null
|
|
655
760
|
let notFound = null
|
|
656
761
|
const versionedScopes = new Map() // scope route → versionMeta
|
|
657
762
|
|
|
658
|
-
// First pass: discover all page folders and read their
|
|
763
|
+
// First pass: discover all page folders and read their config
|
|
659
764
|
const pageFolders = []
|
|
660
765
|
for (const entry of entries) {
|
|
661
766
|
const entryPath = join(dirPath, entry)
|
|
662
767
|
const stats = await stat(entryPath)
|
|
663
768
|
if (!stats.isDirectory()) continue
|
|
664
769
|
|
|
665
|
-
// Read page.yml to
|
|
666
|
-
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
|
+
|
|
667
775
|
pageFolders.push({
|
|
668
776
|
name: entry,
|
|
669
777
|
path: entryPath,
|
|
670
|
-
order:
|
|
671
|
-
|
|
778
|
+
order: numericOrder,
|
|
779
|
+
dirConfig,
|
|
780
|
+
dirMode,
|
|
672
781
|
childOrderConfig: {
|
|
673
|
-
pages:
|
|
674
|
-
index:
|
|
782
|
+
pages: dirConfig.pages,
|
|
783
|
+
index: dirConfig.index,
|
|
784
|
+
order: childOrderArray
|
|
675
785
|
}
|
|
676
786
|
})
|
|
677
787
|
}
|
|
@@ -685,67 +795,46 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
685
795
|
return a.name.localeCompare(b.name)
|
|
686
796
|
})
|
|
687
797
|
|
|
798
|
+
// Apply non-strict order from parent config (if present)
|
|
799
|
+
const orderedFolders = applyNonStrictOrder(pageFolders, orderConfig?.order)
|
|
800
|
+
|
|
688
801
|
// Check if this directory contains version folders (versioned section)
|
|
689
|
-
const folderNames =
|
|
802
|
+
const folderNames = orderedFolders.map(f => f.name)
|
|
690
803
|
const detectedVersions = detectVersions(folderNames)
|
|
691
804
|
|
|
692
|
-
// If versioned section, handle version folders specially
|
|
805
|
+
// If versioned section, handle version folders specially (always sections mode)
|
|
693
806
|
if (detectedVersions && !versionContext) {
|
|
694
|
-
// Read parent page.yml for version metadata
|
|
695
807
|
const parentConfig = await readYamlFile(join(dirPath, 'page.yml'))
|
|
696
808
|
const versionMeta = buildVersionMetadata(detectedVersions, parentConfig)
|
|
697
|
-
|
|
698
|
-
// Record this versioned scope
|
|
699
809
|
versionedScopes.set(parentRoute, versionMeta)
|
|
700
810
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const { name: entry, path: entryPath, childOrderConfig, pageConfig } = folder
|
|
811
|
+
for (const folder of orderedFolders) {
|
|
812
|
+
const { name: entry, path: entryPath, childOrderConfig } = folder
|
|
704
813
|
|
|
705
814
|
if (isVersionFolder(entry)) {
|
|
706
|
-
// This is a version folder
|
|
707
815
|
const versionInfo = versionMeta.versions.find(v => v.id === entry)
|
|
708
816
|
const isLatest = versionInfo?.latest || false
|
|
709
|
-
|
|
710
|
-
// For latest version, use parent route directly
|
|
711
|
-
// For other versions, add version prefix to route
|
|
712
|
-
// Handle root scope specially to avoid double slash (//v1 → /v1)
|
|
713
817
|
const versionRoute = isLatest
|
|
714
818
|
? parentRoute
|
|
715
819
|
: parentRoute === '/'
|
|
716
820
|
? `/${entry}`
|
|
717
821
|
: `${parentRoute}/${entry}`
|
|
718
822
|
|
|
719
|
-
// Recurse into version folder with version context
|
|
720
823
|
const subResult = await collectPagesRecursive(
|
|
721
|
-
entryPath,
|
|
722
|
-
|
|
723
|
-
siteRoot,
|
|
724
|
-
childOrderConfig,
|
|
725
|
-
parentFetch,
|
|
726
|
-
{
|
|
727
|
-
version: versionInfo,
|
|
728
|
-
versionMeta,
|
|
729
|
-
scope: parentRoute // The route where versioning is scoped
|
|
730
|
-
}
|
|
824
|
+
entryPath, versionRoute, siteRoot, childOrderConfig, parentFetch,
|
|
825
|
+
{ version: versionInfo, versionMeta, scope: parentRoute }
|
|
731
826
|
)
|
|
732
827
|
|
|
733
828
|
pages.push(...subResult.pages)
|
|
734
829
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
735
830
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
736
|
-
// Merge any nested versioned scopes (shouldn't happen often, but possible)
|
|
737
831
|
for (const [scope, meta] of subResult.versionedScopes) {
|
|
738
832
|
versionedScopes.set(scope, meta)
|
|
739
833
|
}
|
|
740
|
-
} else
|
|
741
|
-
// Non-version, non-special folders in a versioned section
|
|
742
|
-
// These could be shared across versions - process normally
|
|
834
|
+
} else {
|
|
743
835
|
const result = await processPage(entryPath, entry, siteRoot, {
|
|
744
|
-
isIndex: false,
|
|
745
|
-
parentRoute,
|
|
746
|
-
parentFetch
|
|
836
|
+
isIndex: false, parentRoute, parentFetch
|
|
747
837
|
})
|
|
748
|
-
|
|
749
838
|
if (result) {
|
|
750
839
|
pages.push(result.page)
|
|
751
840
|
assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
|
|
@@ -754,83 +843,238 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
|
|
|
754
843
|
}
|
|
755
844
|
}
|
|
756
845
|
|
|
757
|
-
|
|
758
|
-
return { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes }
|
|
846
|
+
return { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
759
847
|
}
|
|
760
848
|
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const hasExplicitOrder = orderConfig?.index || (Array.isArray(orderConfig?.pages) && orderConfig.pages.length > 0)
|
|
767
|
-
const hasMdContent = entries.some(e => isMarkdownFile(e))
|
|
768
|
-
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 = []
|
|
769
854
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
parentFetch,
|
|
782
|
-
versionContext
|
|
783
|
-
})
|
|
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
|
+
}
|
|
863
|
+
|
|
864
|
+
// Apply non-strict order to md-file-pages
|
|
865
|
+
const orderedMdPages = applyNonStrictOrder(mdPageItems, orderConfig?.order)
|
|
784
866
|
|
|
785
|
-
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) {
|
|
786
873
|
const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
|
|
787
874
|
assetCollection = mergeAssetCollections(assetCollection, pageAssets)
|
|
788
875
|
iconCollection = mergeIconCollections(iconCollection, pageIcons)
|
|
789
876
|
|
|
790
|
-
// Handle
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
877
|
+
// Handle index: promote to parent route
|
|
878
|
+
if (name === indexName) {
|
|
879
|
+
page.isIndex = true
|
|
880
|
+
page.sourcePath = page.route
|
|
881
|
+
page.route = parentRoute
|
|
882
|
+
}
|
|
883
|
+
|
|
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)
|
|
803
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
|
+
}
|
|
804
914
|
}
|
|
805
915
|
} else {
|
|
806
|
-
pages
|
|
807
|
-
|
|
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
|
+
}
|
|
808
955
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
//
|
|
812
|
-
|
|
813
|
-
// since that's a true structural promotion. For auto-detected index, children use
|
|
814
|
-
// the page's original folder path so they nest correctly under it.
|
|
815
|
-
const childParentRoute = isIndex
|
|
816
|
-
? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
|
|
817
|
-
: page.route
|
|
818
|
-
// Pass this page's fetch config to children (for dynamic routes that inherit parent data)
|
|
819
|
-
const childFetch = page.fetch || parentFetch
|
|
820
|
-
// Pass version context to children (maintains version scope)
|
|
821
|
-
const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext)
|
|
956
|
+
pages.push(containerPage)
|
|
957
|
+
|
|
958
|
+
// Recurse in pages mode
|
|
959
|
+
const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
|
|
822
960
|
pages.push(...subResult.pages)
|
|
823
961
|
assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
|
|
824
962
|
iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
|
|
825
|
-
// Merge any versioned scopes from children
|
|
826
963
|
for (const [scope, meta] of subResult.versionedScopes) {
|
|
827
964
|
versionedScopes.set(scope, meta)
|
|
828
965
|
}
|
|
829
966
|
}
|
|
830
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
|
+
}
|
|
831
1075
|
}
|
|
832
1076
|
|
|
833
|
-
return { pages, assetCollection, iconCollection,
|
|
1077
|
+
return { pages, assetCollection, iconCollection, notFound, versionedScopes }
|
|
834
1078
|
}
|
|
835
1079
|
|
|
836
1080
|
/**
|
|
@@ -863,6 +1107,43 @@ async function loadFoundationVars(foundationPath) {
|
|
|
863
1107
|
}
|
|
864
1108
|
}
|
|
865
1109
|
|
|
1110
|
+
/**
|
|
1111
|
+
* Collect layout panels from the layout/ directory
|
|
1112
|
+
*
|
|
1113
|
+
* Layout panels (header, footer, left, right) are persistent regions
|
|
1114
|
+
* that appear on every page. They live in layout/ parallel to pages/.
|
|
1115
|
+
*
|
|
1116
|
+
* @param {string} layoutDir - Path to layout directory
|
|
1117
|
+
* @param {string} siteRoot - Path to site root
|
|
1118
|
+
* @returns {Promise<Object>} { header, footer, left, right }
|
|
1119
|
+
*/
|
|
1120
|
+
async function collectLayoutPanels(layoutDir, siteRoot) {
|
|
1121
|
+
const result = { header: null, footer: null, left: null, right: null }
|
|
1122
|
+
|
|
1123
|
+
if (!existsSync(layoutDir)) return result
|
|
1124
|
+
|
|
1125
|
+
const knownPanels = ['header', 'footer', 'left', 'right']
|
|
1126
|
+
const entries = await readdir(layoutDir)
|
|
1127
|
+
|
|
1128
|
+
for (const entry of entries) {
|
|
1129
|
+
if (!knownPanels.includes(entry)) continue
|
|
1130
|
+
const entryPath = join(layoutDir, entry)
|
|
1131
|
+
const stats = await stat(entryPath)
|
|
1132
|
+
if (!stats.isDirectory()) continue
|
|
1133
|
+
|
|
1134
|
+
const pageResult = await processPage(entryPath, entry, siteRoot, {
|
|
1135
|
+
isIndex: false,
|
|
1136
|
+
parentRoute: '/layout'
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
if (pageResult) {
|
|
1140
|
+
result[entry] = pageResult.page
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
return result
|
|
1145
|
+
}
|
|
1146
|
+
|
|
866
1147
|
/**
|
|
867
1148
|
* Collect all site content
|
|
868
1149
|
*
|
|
@@ -873,10 +1154,18 @@ async function loadFoundationVars(foundationPath) {
|
|
|
873
1154
|
*/
|
|
874
1155
|
export async function collectSiteContent(sitePath, options = {}) {
|
|
875
1156
|
const { foundationPath } = options
|
|
876
|
-
const pagesPath = join(sitePath, 'pages')
|
|
877
1157
|
|
|
878
1158
|
// Read site config and raw theme config
|
|
879
1159
|
const siteConfig = await readYamlFile(join(sitePath, 'site.yml'))
|
|
1160
|
+
|
|
1161
|
+
// Resolve content paths from site.yml paths: group, defaulting to standard locations
|
|
1162
|
+
const pagesPath = siteConfig.paths?.pages
|
|
1163
|
+
? resolve(sitePath, siteConfig.paths.pages)
|
|
1164
|
+
: join(sitePath, 'pages')
|
|
1165
|
+
|
|
1166
|
+
const layoutPath = siteConfig.paths?.layout
|
|
1167
|
+
? resolve(sitePath, siteConfig.paths.layout)
|
|
1168
|
+
: join(sitePath, 'layout')
|
|
880
1169
|
const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
|
|
881
1170
|
|
|
882
1171
|
// Load foundation vars and process theme
|
|
@@ -904,12 +1193,19 @@ export async function collectSiteContent(sitePath, options = {}) {
|
|
|
904
1193
|
// Extract page ordering config from site.yml
|
|
905
1194
|
const siteOrderConfig = {
|
|
906
1195
|
pages: siteConfig.pages,
|
|
907
|
-
index: siteConfig.index
|
|
1196
|
+
index: siteConfig.index,
|
|
1197
|
+
order: Array.isArray(siteConfig.order) ? siteConfig.order : undefined
|
|
908
1198
|
}
|
|
909
1199
|
|
|
1200
|
+
// Determine root content mode from folder.yml/page.yml presence in pages directory
|
|
1201
|
+
const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
|
|
1202
|
+
|
|
1203
|
+
// Collect layout panels from layout/ directory
|
|
1204
|
+
const { header, footer, left, right } = await collectLayoutPanels(layoutPath, sitePath)
|
|
1205
|
+
|
|
910
1206
|
// Recursively collect all pages
|
|
911
|
-
const { pages, assetCollection, iconCollection,
|
|
912
|
-
await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig)
|
|
1207
|
+
const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
|
|
1208
|
+
await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode)
|
|
913
1209
|
|
|
914
1210
|
// Deduplicate: remove content-less container pages whose route duplicates
|
|
915
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 = {},
|
|
@@ -376,6 +377,10 @@ export function siteContentPlugin(options = {}) {
|
|
|
376
377
|
let collectionTranslations = {} // Cache: { locale: collection translations }
|
|
377
378
|
let localesDir = 'locales' // Default, updated from site config
|
|
378
379
|
let collectionsConfig = null // Cached for watcher setup
|
|
380
|
+
let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
|
|
381
|
+
let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
|
|
382
|
+
let resolvedCollectionsBase = null // Resolved from site.yml collectionsDir
|
|
383
|
+
let headHtml = '' // Contents of site/head.html for injection
|
|
379
384
|
|
|
380
385
|
/**
|
|
381
386
|
* Load translations for a specific locale
|
|
@@ -423,6 +428,18 @@ export function siteContentPlugin(options = {}) {
|
|
|
423
428
|
}
|
|
424
429
|
}
|
|
425
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
|
+
|
|
426
443
|
/**
|
|
427
444
|
* Get available locales from locales directory
|
|
428
445
|
*/
|
|
@@ -486,21 +503,50 @@ export function siteContentPlugin(options = {}) {
|
|
|
486
503
|
const earlyContent = await collectSiteContent(resolvedSitePath, { foundationPath })
|
|
487
504
|
collectionsConfig = earlyContent.config?.collections
|
|
488
505
|
|
|
506
|
+
// Resolve content directory paths from site.yml paths: group
|
|
507
|
+
const paths = earlyContent?.config?.paths || {}
|
|
508
|
+
resolvedPagesPath = paths.pages
|
|
509
|
+
? resolve(resolvedSitePath, paths.pages)
|
|
510
|
+
: resolve(resolvedSitePath, pagesDir)
|
|
511
|
+
resolvedLayoutPath = paths.layout
|
|
512
|
+
? resolve(resolvedSitePath, paths.layout)
|
|
513
|
+
: resolve(resolvedSitePath, 'layout')
|
|
514
|
+
resolvedCollectionsBase = paths.collections
|
|
515
|
+
? resolve(resolvedSitePath, paths.collections)
|
|
516
|
+
: null
|
|
517
|
+
|
|
489
518
|
if (collectionsConfig) {
|
|
490
519
|
console.log('[site-content] Processing content collections...')
|
|
491
|
-
const collections = await processCollections(resolvedSitePath, collectionsConfig)
|
|
520
|
+
const collections = await processCollections(resolvedSitePath, collectionsConfig, resolvedCollectionsBase)
|
|
492
521
|
await writeCollectionFiles(resolvedSitePath, collections)
|
|
493
522
|
}
|
|
494
523
|
} catch (err) {
|
|
495
524
|
console.warn('[site-content] Early collection processing failed:', err.message)
|
|
496
525
|
}
|
|
497
526
|
}
|
|
527
|
+
|
|
528
|
+
// In production, resolve content paths from site.yml directly
|
|
529
|
+
if (isProduction || !resolvedPagesPath) {
|
|
530
|
+
const { readSiteConfig } = await import('./config.js')
|
|
531
|
+
const cfg = readSiteConfig(resolvedSitePath)
|
|
532
|
+
const paths = cfg.paths || {}
|
|
533
|
+
resolvedPagesPath = paths.pages
|
|
534
|
+
? resolve(resolvedSitePath, paths.pages)
|
|
535
|
+
: resolve(resolvedSitePath, pagesDir)
|
|
536
|
+
resolvedLayoutPath = paths.layout
|
|
537
|
+
? resolve(resolvedSitePath, paths.layout)
|
|
538
|
+
: resolve(resolvedSitePath, 'layout')
|
|
539
|
+
resolvedCollectionsBase = paths.collections
|
|
540
|
+
? resolve(resolvedSitePath, paths.collections)
|
|
541
|
+
: null
|
|
542
|
+
}
|
|
498
543
|
},
|
|
499
544
|
|
|
500
545
|
async buildStart() {
|
|
501
546
|
// Collect content at build start
|
|
502
547
|
try {
|
|
503
548
|
siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
|
|
549
|
+
headHtml = await loadHeadHtml()
|
|
504
550
|
console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
|
|
505
551
|
|
|
506
552
|
// Process content collections if defined in site.yml
|
|
@@ -508,7 +554,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
508
554
|
// In production, do it here
|
|
509
555
|
if (isProduction && siteContent.config?.collections) {
|
|
510
556
|
console.log('[site-content] Processing content collections...')
|
|
511
|
-
const collections = await processCollections(resolvedSitePath, siteContent.config.collections)
|
|
557
|
+
const collections = await processCollections(resolvedSitePath, siteContent.config.collections, resolvedCollectionsBase)
|
|
512
558
|
await writeCollectionFiles(resolvedSitePath, collections)
|
|
513
559
|
}
|
|
514
560
|
|
|
@@ -537,7 +583,6 @@ export function siteContentPlugin(options = {}) {
|
|
|
537
583
|
|
|
538
584
|
// Watch for content changes in dev mode
|
|
539
585
|
if (shouldWatch) {
|
|
540
|
-
const pagesPath = resolve(resolvedSitePath, pagesDir)
|
|
541
586
|
const siteYmlPath = resolve(resolvedSitePath, 'site.yml')
|
|
542
587
|
const themeYmlPath = resolve(resolvedSitePath, 'theme.yml')
|
|
543
588
|
|
|
@@ -549,6 +594,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
549
594
|
console.log('[site-content] Content changed, rebuilding...')
|
|
550
595
|
try {
|
|
551
596
|
siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
|
|
597
|
+
headHtml = await loadHeadHtml()
|
|
552
598
|
// Execute fetches for the updated content
|
|
553
599
|
await executeDevFetches(siteContent, resolvedSitePath)
|
|
554
600
|
console.log(`[site-content] Rebuilt ${siteContent.pages?.length || 0} pages`)
|
|
@@ -571,7 +617,7 @@ export function siteContentPlugin(options = {}) {
|
|
|
571
617
|
// Use collectionsConfig (cached from configResolved) or siteContent
|
|
572
618
|
const collections = collectionsConfig || siteContent?.config?.collections
|
|
573
619
|
if (collections) {
|
|
574
|
-
const processed = await processCollections(resolvedSitePath, collections)
|
|
620
|
+
const processed = await processCollections(resolvedSitePath, collections, resolvedCollectionsBase)
|
|
575
621
|
await writeCollectionFiles(resolvedSitePath, processed)
|
|
576
622
|
}
|
|
577
623
|
// Send full reload to client
|
|
@@ -585,12 +631,24 @@ export function siteContentPlugin(options = {}) {
|
|
|
585
631
|
// Track all watchers for cleanup
|
|
586
632
|
const watchers = []
|
|
587
633
|
|
|
588
|
-
// Watch pages directory
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
634
|
+
// Watch pages directory (resolved from site.yml pagesDir or default)
|
|
635
|
+
if (existsSync(resolvedPagesPath)) {
|
|
636
|
+
try {
|
|
637
|
+
watchers.push(watch(resolvedPagesPath, { recursive: true }, scheduleRebuild))
|
|
638
|
+
console.log(`[site-content] Watching ${resolvedPagesPath}`)
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.warn('[site-content] Could not watch pages directory:', err.message)
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Watch layout directory (resolved from site.yml layoutDir or default)
|
|
645
|
+
if (existsSync(resolvedLayoutPath)) {
|
|
646
|
+
try {
|
|
647
|
+
watchers.push(watch(resolvedLayoutPath, { recursive: true }, scheduleRebuild))
|
|
648
|
+
console.log(`[site-content] Watching ${resolvedLayoutPath}`)
|
|
649
|
+
} catch (err) {
|
|
650
|
+
console.warn('[site-content] Could not watch layout directory:', err.message)
|
|
651
|
+
}
|
|
594
652
|
}
|
|
595
653
|
|
|
596
654
|
// Watch site.yml
|
|
@@ -607,14 +665,23 @@ export function siteContentPlugin(options = {}) {
|
|
|
607
665
|
// theme.yml may not exist, that's ok
|
|
608
666
|
}
|
|
609
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
|
+
|
|
610
676
|
// Watch content/ folder for collection changes
|
|
611
677
|
// Use collectionsConfig cached from configResolved (siteContent may be null here)
|
|
612
678
|
if (collectionsConfig) {
|
|
613
679
|
const contentPaths = new Set()
|
|
680
|
+
const collectionBase = resolvedCollectionsBase || resolvedSitePath
|
|
614
681
|
for (const config of Object.values(collectionsConfig)) {
|
|
615
682
|
const collectionPath = typeof config === 'string' ? config : config.path
|
|
616
683
|
if (collectionPath) {
|
|
617
|
-
contentPaths.add(resolve(
|
|
684
|
+
contentPaths.add(resolve(collectionBase, collectionPath))
|
|
618
685
|
}
|
|
619
686
|
}
|
|
620
687
|
|
|
@@ -806,6 +873,9 @@ export function siteContentPlugin(options = {}) {
|
|
|
806
873
|
async transformIndexHtml(html, ctx) {
|
|
807
874
|
if (!siteContent) return html
|
|
808
875
|
|
|
876
|
+
// Shell mode: skip all HTML injections — backend provides __DATA__ at serve time
|
|
877
|
+
if (shell) return html
|
|
878
|
+
|
|
809
879
|
// Detect locale from URL (e.g., /es/about → 'es')
|
|
810
880
|
let contentToInject = siteContent
|
|
811
881
|
let activeLocale = null
|
|
@@ -829,6 +899,29 @@ export function siteContentPlugin(options = {}) {
|
|
|
829
899
|
|
|
830
900
|
let headInjection = ''
|
|
831
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
|
+
|
|
832
925
|
// Inject theme CSS
|
|
833
926
|
if (contentToInject.theme?.css) {
|
|
834
927
|
headInjection += ` <style id="uniweb-theme">\n${contentToInject.theme.css}\n </style>\n`
|