@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.6.4",
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.13",
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.2"
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
- // Build using Vite with the foundation's own config file
70
- await build({
71
- root: resolvedFoundationPath,
72
- configFile: existsSync(configPath) ? configPath : false,
73
- logLevel: 'warn',
74
- build: {
75
- outDir: 'dist',
76
- emptyOutDir: true,
77
- watch: null // Don't use Vite's watch, we handle it ourselves
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 non-source files
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') ||
@@ -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 || `/@${layoutKey}`
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 || `/@${layoutKey}`
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 (extract uses /@header, etc.)
136
- if (!layoutPage.route) layoutPage.route = `/@${layoutKey}`
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) {
@@ -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 collectionDir = join(siteDir, config.path)
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
  }
@@ -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 or URL-based foundation)
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: true,
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
- // Set up #foundation alias for bundled mode
295
- if (!isRuntimeMode && foundationInfo.type !== 'url') {
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
- allow: ['..']
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 pages/ directory structure:
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 (pageName.startsWith('@')) {
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
- * @returns {Promise<Object>} { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes }
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 order values
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 get order and child page config
666
- const pageConfig = await readYamlFile(join(entryPath, 'page.yml'))
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: pageConfig.order,
671
- pageConfig,
778
+ order: numericOrder,
779
+ dirConfig,
780
+ dirMode,
672
781
  childOrderConfig: {
673
- pages: pageConfig.pages,
674
- index: pageConfig.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 = pageFolders.map(f => f.name)
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
- // Process version folders
702
- for (const folder of pageFolders) {
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
- versionRoute,
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 if (!entry.startsWith('@')) {
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
- // Return early - we've handled all children
758
- return { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes }
846
+ return { pages, assetCollection, iconCollection, notFound, versionedScopes }
759
847
  }
760
848
 
761
- // Determine which page is the index for this level
762
- // A directory with its own .md content is a real page, not a container —
763
- // never promote a child as index, even if explicit config says so
764
- // (that config is likely a leftover from before the directory had content)
765
- const regularFolders = pageFolders.filter(f => !f.name.startsWith('@'))
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
- // Second pass: process each page folder
771
- for (const folder of pageFolders) {
772
- const { name: entry, path: entryPath, childOrderConfig } = folder
773
- const isIndex = entry === indexPageName
774
- const isSpecial = entry.startsWith('@')
775
-
776
- // Process this directory as a page
777
- // Pass parentFetch so dynamic routes can inherit parent's data schema
778
- const result = await processPage(entryPath, entry, siteRoot, {
779
- isIndex: isIndex && !isSpecial,
780
- parentRoute,
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 (result) {
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 special pages (layout areas and 404) - only at root level
791
- if (parentRoute === '/') {
792
- if (entry === '@header') {
793
- header = page
794
- } else if (entry === '@footer') {
795
- footer = page
796
- } else if (entry === '@left') {
797
- left = page
798
- } else if (entry === '@right') {
799
- right = page
800
- } else if (entry === '404') {
801
- notFound = page
802
- } else {
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.push(page)
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
- // Recursively process subdirectories (but not special @ directories)
810
- if (!isSpecial) {
811
- // The child route depends on whether this page is the index
812
- // For explicit index (from site.yml `index:` or `pages:`), children use parentRoute
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, header, footer, left, right, notFound, versionedScopes }
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, header, footer, left, right, notFound, versionedScopes } =
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)
@@ -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
- try {
590
- watchers.push(watch(pagesPath, { recursive: true }, scheduleRebuild))
591
- console.log(`[site-content] Watching ${pagesPath}`)
592
- } catch (err) {
593
- console.warn('[site-content] Could not watch pages directory:', err.message)
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(resolvedSitePath, collectionPath))
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`