@uniweb/build 0.6.20 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.6.20",
3
+ "version": "0.7.0",
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/content-reader": "1.1.2",
54
- "@uniweb/runtime": "0.5.22",
54
+ "@uniweb/runtime": "0.5.23",
55
55
  "@uniweb/schemas": "0.2.1"
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.7"
64
+ "@uniweb/core": "0.5.0"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "vite": {
@@ -22,20 +22,22 @@
22
22
  import { writeFile, mkdir } from 'node:fs/promises'
23
23
  import { existsSync } from 'node:fs'
24
24
  import { join, dirname } from 'node:path'
25
- import { discoverComponents } from './schema.js'
26
- import { extractAllRuntimeSchemas } from './runtime-schema.js'
25
+ import { discoverComponents, discoverLayoutsInPath } from './schema.js'
26
+ import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas } from './runtime-schema.js'
27
27
 
28
28
  /**
29
- * Detect foundation config/exports file (for custom Layout, props, vars, etc.)
29
+ * Detect foundation config/exports file (for props, vars, etc.)
30
30
  *
31
31
  * Looks for (in order of preference):
32
32
  * 1. foundation.js - New consolidated format
33
33
  * 2. exports.js - Legacy format (for backward compatibility)
34
34
  *
35
35
  * The file should export:
36
- * - Layout (optional) - Custom page layout component
37
36
  * - props (optional) - Foundation-wide props
38
37
  * - vars (optional) - CSS custom properties (also read by schema builder)
38
+ * - defaultLayout (optional) - Default layout name
39
+ *
40
+ * Note: Layout components are now discovered from src/layouts/
39
41
  */
40
42
  function detectFoundationExports(srcDir) {
41
43
  // Prefer foundation.js (new consolidated format)
@@ -92,9 +94,12 @@ function generateEntrySource(components, options = {}) {
92
94
  cssPath = null,
93
95
  foundationExports = null,
94
96
  meta = {},
97
+ layouts = {},
98
+ layoutMeta = {},
95
99
  } = options
96
100
 
97
101
  const componentNames = Object.keys(components).sort()
102
+ const layoutNames = Object.keys(layouts).sort()
98
103
 
99
104
  const lines = [
100
105
  '// Auto-generated foundation entry point',
@@ -107,9 +112,10 @@ function generateEntrySource(components, options = {}) {
107
112
  lines.push(`import '${cssPath}'`)
108
113
  }
109
114
 
110
- // Foundation capabilities import (for custom Layout, props, etc.)
115
+ // Foundation capabilities import (for props, vars, etc.)
116
+ // Note: Layout/layouts no longer merged from foundation.js — layouts come from src/layouts/ discovery
111
117
  if (foundationExports) {
112
- lines.push(`import capabilities from '${foundationExports.path}'`)
118
+ lines.push(`import * as _foundationModule from '${foundationExports.path}'`)
113
119
  }
114
120
 
115
121
  // Component imports
@@ -118,6 +124,12 @@ function generateEntrySource(components, options = {}) {
118
124
  lines.push(`import ${name} from './${path}/${entryFile}'`)
119
125
  }
120
126
 
127
+ // Layout imports
128
+ for (const name of layoutNames) {
129
+ const { path, entryFile = `index.js` } = layouts[name]
130
+ lines.push(`import ${name} from './${path}/${entryFile}'`)
131
+ }
132
+
121
133
  lines.push('')
122
134
 
123
135
  // Export components object
@@ -129,9 +141,17 @@ function generateEntrySource(components, options = {}) {
129
141
  lines.push('export const components = {}')
130
142
  }
131
143
 
132
- // Foundation capabilities (Layout, props, etc.)
144
+ // Foundation capabilities (props, vars, etc. + discovered layouts)
133
145
  lines.push('')
134
- if (foundationExports) {
146
+ if (foundationExports || layoutNames.length > 0) {
147
+ const capParts = []
148
+ if (foundationExports) {
149
+ capParts.push('..._foundationModule.default')
150
+ }
151
+ if (layoutNames.length > 0) {
152
+ capParts.push(`layouts: { ${layoutNames.join(', ')} }`)
153
+ }
154
+ lines.push(`const capabilities = { ${capParts.join(', ')} }`)
135
155
  lines.push('export { capabilities }')
136
156
  } else {
137
157
  lines.push('export const capabilities = null')
@@ -147,6 +167,16 @@ function generateEntrySource(components, options = {}) {
147
167
  lines.push('export const meta = {}')
148
168
  }
149
169
 
170
+ // Per-layout runtime metadata (areas, transitions, defaults)
171
+ lines.push('')
172
+ if (Object.keys(layoutMeta).length > 0) {
173
+ const layoutMetaJson = JSON.stringify(layoutMeta, null, 2)
174
+ lines.push(`// Per-layout runtime metadata (from meta.js)`)
175
+ lines.push(`export const layoutMeta = ${layoutMetaJson}`)
176
+ } else {
177
+ lines.push('export const layoutMeta = {}')
178
+ }
179
+
150
180
  lines.push('')
151
181
 
152
182
  return lines.join('\n')
@@ -201,6 +231,10 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
201
231
  console.warn('Warning: No section types found')
202
232
  }
203
233
 
234
+ // Discover layouts from src/layouts/
235
+ const layouts = await discoverLayoutsInPath(srcDir)
236
+ const layoutNames = Object.keys(layouts).sort()
237
+
204
238
  // Detect entry files for each component
205
239
  // Bare files discovered in sections/ already have entryFile set — skip detection for those
206
240
  for (const name of componentNames) {
@@ -212,20 +246,35 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
212
246
  }
213
247
  }
214
248
 
249
+ // Detect entry files for each layout (same logic as components)
250
+ for (const name of layoutNames) {
251
+ const layout = layouts[name]
252
+ if (!layout.entryFile) {
253
+ const entry = detectComponentEntry(srcDir, layout.path, layout.name)
254
+ layout.ext = entry.ext
255
+ layout.entryFile = entry.file
256
+ }
257
+ }
258
+
215
259
  // Check for CSS file
216
260
  const cssPath = detectCssFile(srcDir)
217
261
 
218
- // Check for foundation exports (custom Layout, props, etc.)
262
+ // Check for foundation exports (props, vars, etc.)
219
263
  const foundationExports = detectFoundationExports(srcDir)
220
264
 
221
265
  // Extract per-component runtime metadata from meta.js files
222
266
  const meta = extractAllRuntimeSchemas(components)
223
267
 
268
+ // Extract per-layout runtime metadata from meta.js files
269
+ const layoutMeta = extractAllLayoutRuntimeSchemas(layouts)
270
+
224
271
  // Generate source
225
272
  const source = generateEntrySource(components, {
226
273
  cssPath,
227
274
  foundationExports,
228
275
  meta,
276
+ layouts,
277
+ layoutMeta,
229
278
  })
230
279
 
231
280
  // Write to file
@@ -235,6 +284,9 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
235
284
 
236
285
  console.log(`Generated entry point: ${output}`)
237
286
  console.log(` - ${componentNames.length} components: ${componentNames.join(', ')}`)
287
+ if (layoutNames.length > 0) {
288
+ console.log(` - ${layoutNames.length} layouts: ${layoutNames.join(', ')}`)
289
+ }
238
290
  if (foundationExports) {
239
291
  console.log(` - Foundation exports found: ${foundationExports.path}`)
240
292
  }
@@ -242,8 +294,10 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
242
294
  return {
243
295
  outputPath: output,
244
296
  componentNames,
297
+ layoutNames,
245
298
  foundationExports,
246
299
  meta,
300
+ layoutMeta,
247
301
  }
248
302
  }
249
303
 
package/src/prerender.js CHANGED
@@ -569,33 +569,33 @@ function renderBlocks(blocks) {
569
569
  * Render page layout for SSR
570
570
  */
571
571
  function renderLayout(page, website) {
572
- const RemoteLayout = website.getRemoteLayout()
572
+ const layoutName = page.getLayoutName()
573
+ const RemoteLayout = website.getRemoteLayout(layoutName)
574
+ const layoutMeta = website.getLayoutMeta(layoutName)
573
575
 
574
- const headerBlocks = page.getHeaderBlocks()
575
576
  const bodyBlocks = page.getBodyBlocks()
576
- const footerBlocks = page.getFooterBlocks()
577
- const leftBlocks = page.getLeftBlocks()
578
- const rightBlocks = page.getRightBlocks()
577
+ const areas = page.getLayoutAreas()
579
578
 
580
- const headerElement = headerBlocks ? renderBlocks(headerBlocks) : null
581
579
  const bodyElement = bodyBlocks ? renderBlocks(bodyBlocks) : null
582
- const footerElement = footerBlocks ? renderBlocks(footerBlocks) : null
583
- const leftElement = leftBlocks ? renderBlocks(leftBlocks) : null
584
- const rightElement = rightBlocks ? renderBlocks(rightBlocks) : null
580
+ const areaElements = {}
581
+ for (const [name, blocks] of Object.entries(areas)) {
582
+ areaElements[name] = renderBlocks(blocks)
583
+ }
585
584
 
586
585
  if (RemoteLayout) {
586
+ const params = { ...(layoutMeta?.defaults || {}), ...(page.getLayoutParams() || {}) }
587
+
587
588
  return React.createElement(RemoteLayout, {
588
- page, website,
589
- header: headerElement, body: bodyElement, footer: footerElement,
590
- left: leftElement, right: rightElement,
591
- leftPanel: leftElement, rightPanel: rightElement
589
+ page, website, params,
590
+ body: bodyElement,
591
+ ...areaElements,
592
592
  })
593
593
  }
594
594
 
595
595
  return React.createElement(React.Fragment, null,
596
- headerElement && React.createElement('header', null, headerElement),
596
+ areaElements.header && React.createElement('header', null, areaElements.header),
597
597
  bodyElement && React.createElement('main', null, bodyElement),
598
- footerElement && React.createElement('footer', null, footerElement)
598
+ areaElements.footer && React.createElement('footer', null, areaElements.footer)
599
599
  )
600
600
  }
601
601
 
@@ -288,3 +288,56 @@ export function extractAllRuntimeSchemas(componentsMeta) {
288
288
 
289
289
  return schemas
290
290
  }
291
+
292
+ /**
293
+ * Extract lean runtime schema for a layout from its full meta.js
294
+ *
295
+ * Layout runtime metadata:
296
+ * - areas: Array of area names this layout supports
297
+ * - transitions: View transition name mapping (stored but not acted on yet)
298
+ * - defaults: Param default values
299
+ *
300
+ * @param {Object} fullMeta - The full meta.js default export for a layout
301
+ * @returns {Object|null} - Lean layout runtime schema or null if empty
302
+ */
303
+ export function extractLayoutRuntimeSchema(fullMeta) {
304
+ if (!fullMeta || typeof fullMeta !== 'object') {
305
+ return null
306
+ }
307
+
308
+ const runtime = {}
309
+
310
+ if (fullMeta.areas && Array.isArray(fullMeta.areas)) {
311
+ runtime.areas = fullMeta.areas
312
+ }
313
+
314
+ if (fullMeta.transitions && typeof fullMeta.transitions === 'object') {
315
+ runtime.transitions = fullMeta.transitions
316
+ }
317
+
318
+ const defaults = extractParamDefaults(fullMeta.params)
319
+ if (defaults) {
320
+ runtime.defaults = defaults
321
+ }
322
+
323
+ return Object.keys(runtime).length > 0 ? runtime : null
324
+ }
325
+
326
+ /**
327
+ * Extract runtime schemas for all layouts
328
+ *
329
+ * @param {Object} layoutsMeta - Map of layoutName -> meta.js content
330
+ * @returns {Object} - Map of layoutName -> layout runtime schema (excludes null entries)
331
+ */
332
+ export function extractAllLayoutRuntimeSchemas(layoutsMeta) {
333
+ const schemas = {}
334
+
335
+ for (const [name, meta] of Object.entries(layoutsMeta)) {
336
+ const schema = extractLayoutRuntimeSchema(meta)
337
+ if (schema) {
338
+ schemas[name] = schema
339
+ }
340
+ }
341
+
342
+ return schemas
343
+ }
package/src/schema.js CHANGED
@@ -32,6 +32,9 @@ const COMPONENT_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts'])
32
32
  // The primary sections path where relaxed discovery applies
33
33
  const SECTIONS_PATH = 'sections'
34
34
 
35
+ // The layouts path where layout components are discovered
36
+ const LAYOUTS_PATH = 'layouts'
37
+
35
38
  /**
36
39
  * Load a meta.js file via dynamic import
37
40
  */
@@ -107,10 +110,11 @@ export async function loadFoundationConfig(srcDir) {
107
110
  try {
108
111
  const module = await import(pathToFileURL(filePath).href)
109
112
  // Support both default export and named exports
113
+ // Note: Layout/layouts no longer read from foundation.js — layouts come from src/layouts/ discovery
110
114
  return {
111
115
  ...module.default,
112
116
  vars: module.vars || module.default?.vars,
113
- Layout: module.Layout || module.default?.Layout,
117
+ defaultLayout: module.default?.defaultLayout,
114
118
  }
115
119
  } catch (error) {
116
120
  console.warn(`Warning: Failed to load foundation config ${filePath}:`, error.message)
@@ -252,6 +256,87 @@ async function discoverSectionsInPath(srcDir, sectionsRelPath) {
252
256
  return components
253
257
  }
254
258
 
259
+ /**
260
+ * Discover layout components in src/layouts/ with relaxed rules
261
+ *
262
+ * Same discovery pattern as sections: root-level files and directories
263
+ * are addressable by default. No recursion — layouts are flat.
264
+ *
265
+ * @param {string} srcDir - Source directory (e.g., 'src')
266
+ * @param {string} layoutsRelPath - Relative path to layouts dir (e.g., 'layouts')
267
+ * @returns {Object} Map of layoutName -> { name, path, ...meta }
268
+ */
269
+ export async function discoverLayoutsInPath(srcDir, layoutsRelPath = LAYOUTS_PATH) {
270
+ const fullPath = join(srcDir, layoutsRelPath)
271
+
272
+ if (!existsSync(fullPath)) {
273
+ return {}
274
+ }
275
+
276
+ const entries = await readdir(fullPath, { withFileTypes: true })
277
+ const layouts = {}
278
+
279
+ // Collect names from both files and directories to detect collisions
280
+ const fileNames = new Set()
281
+ const dirNames = new Set()
282
+
283
+ for (const entry of entries) {
284
+ const ext = extname(entry.name)
285
+ if (entry.isFile() && COMPONENT_EXTENSIONS.has(ext)) {
286
+ const name = basename(entry.name, ext)
287
+ if (isComponentFileName(name)) {
288
+ fileNames.add(name)
289
+ }
290
+ } else if (entry.isDirectory()) {
291
+ dirNames.add(entry.name)
292
+ }
293
+ }
294
+
295
+ // Check for name collisions (e.g., DocsLayout.jsx AND DocsLayout/)
296
+ for (const name of fileNames) {
297
+ if (dirNames.has(name)) {
298
+ throw new Error(
299
+ `Name collision in ${layoutsRelPath}/: both "${name}.jsx" (or similar) and "${name}/" exist. ` +
300
+ `Use one or the other, not both.`
301
+ )
302
+ }
303
+ }
304
+
305
+ // Discover bare files at root
306
+ for (const entry of entries) {
307
+ if (!entry.isFile()) continue
308
+ const ext = extname(entry.name)
309
+ if (!COMPONENT_EXTENSIONS.has(ext)) continue
310
+ const name = basename(entry.name, ext)
311
+ if (!isComponentFileName(name)) continue
312
+
313
+ const meta = createImplicitMeta(name)
314
+ layouts[name] = {
315
+ ...buildComponentEntry(name, layoutsRelPath, meta),
316
+ entryFile: entry.name,
317
+ }
318
+ }
319
+
320
+ // Discover directories at root (no recursion for layouts)
321
+ for (const entry of entries) {
322
+ if (!entry.isDirectory()) continue
323
+ if (!isComponentFileName(entry.name)) continue
324
+
325
+ const dirPath = join(fullPath, entry.name)
326
+ const relativePath = join(layoutsRelPath, entry.name)
327
+ const result = await loadComponentMeta(dirPath)
328
+
329
+ if (result && result.meta) {
330
+ if (result.meta.exposed === false) continue
331
+ layouts[entry.name] = buildComponentEntry(entry.name, relativePath, result.meta)
332
+ } else if (hasEntryFile(dirPath, entry.name)) {
333
+ layouts[entry.name] = buildComponentEntry(entry.name, relativePath, createImplicitMeta(entry.name))
334
+ }
335
+ }
336
+
337
+ return layouts
338
+ }
339
+
255
340
  /**
256
341
  * Recursively discover nested section types that have meta.js
257
342
  *
@@ -376,12 +461,20 @@ export async function buildSchema(srcDir, componentPaths) {
376
461
  // Discover components
377
462
  const components = await discoverComponents(srcDir, componentPaths)
378
463
 
464
+ // Discover layouts from src/layouts/
465
+ const layouts = await discoverLayoutsInPath(srcDir)
466
+
379
467
  return {
380
- // Merge identity and config - identity fields take precedence
381
468
  _self: {
382
469
  ...foundationConfig,
383
470
  ...identity,
471
+ // foundation.js overrides package.json for editor-facing identity
472
+ ...(foundationConfig.name && { name: foundationConfig.name }),
473
+ ...(foundationConfig.description && { description: foundationConfig.description }),
474
+ ...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
384
475
  },
476
+ // Layout metadata (full, for editor)
477
+ ...(Object.keys(layouts).length > 0 && { _layouts: layouts }),
385
478
  ...components,
386
479
  }
387
480
  }
@@ -312,12 +312,13 @@ export function rewriteSiteContentPaths(siteContent, pathMapping) {
312
312
  result.pages.forEach(processPage)
313
313
  }
314
314
 
315
- // Process header and footer
316
- if (result.header) {
317
- processPage(result.header)
318
- }
319
- if (result.footer) {
320
- processPage(result.footer)
315
+ // Process layout area sets
316
+ if (result.layouts) {
317
+ for (const [name, areas] of Object.entries(result.layouts)) {
318
+ for (const areaPage of Object.values(areas)) {
319
+ if (areaPage) processPage(areaPage)
320
+ }
321
+ }
321
322
  }
322
323
 
323
324
  // Remove the assets manifest from output (no longer needed at runtime)
@@ -535,12 +535,7 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
535
535
  hidden: false,
536
536
  hideInHeader: false,
537
537
  hideInFooter: false,
538
- layout: {
539
- header: true,
540
- footer: true,
541
- leftPanel: true,
542
- rightPanel: true
543
- },
538
+ layout: {},
544
539
  seo: {
545
540
  noindex: false,
546
541
  image: null,
@@ -765,7 +760,7 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
765
760
  * @param {Object} options.versionContext - Version context from parent { version, versionMeta, scope }
766
761
  * @returns {Object} Page data with assets manifest
767
762
  */
768
- async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null } = {}) {
763
+ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null, layoutName = null } = {}) {
769
764
  const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
770
765
 
771
766
  // Note: We no longer skip hidden pages here - they still exist as valid pages,
@@ -925,7 +920,15 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
925
920
  const sourcePath = isIndex ? folderRoute : null
926
921
 
927
922
  // Extract configuration
928
- const { seo = {}, layout = {}, ...restConfig } = pageConfig
923
+ const { seo = {}, layout: layoutConfig, ...restConfig } = pageConfig
924
+
925
+ // Resolve layout name: page.yml layout (string or object.name) > inherited from parent > null
926
+ const pageLayoutName = typeof layoutConfig === 'string' ? layoutConfig
927
+ : layoutConfig?.name || null
928
+ const resolvedLayoutName = pageLayoutName || layoutName || null
929
+
930
+ // Layout panel visibility (from object form of layout config)
931
+ const layoutObj = typeof layoutConfig === 'object' && layoutConfig !== null ? layoutConfig : {}
929
932
 
930
933
  // For dynamic routes, determine the parent's data schema
931
934
  // This tells prerender which data array to iterate over
@@ -960,12 +963,11 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
960
963
  hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
961
964
  hideInFooter: pageConfig.hideInFooter || false, // Hide from footer nav
962
965
 
963
- // Layout options (per-page overrides)
966
+ // Layout options (named layout + per-page overrides)
964
967
  layout: {
965
- header: layout.header !== false, // Show header (default true)
966
- footer: layout.footer !== false, // Show footer (default true)
967
- leftPanel: layout.leftPanel !== false, // Show left panel (default true)
968
- rightPanel: layout.rightPanel !== false // Show right panel (default true)
968
+ ...(resolvedLayoutName ? { name: resolvedLayoutName } : {}),
969
+ ...(layoutObj.hide ? { hide: layoutObj.hide } : {}),
970
+ ...(layoutObj.params ? { params: layoutObj.params } : {}),
969
971
  },
970
972
 
971
973
  seo: {
@@ -1045,7 +1047,7 @@ function determineIndexPage(orderConfig, availableFolders) {
1045
1047
  * @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
1046
1048
  * @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
1047
1049
  */
1048
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null) {
1050
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null, parentLayoutName = null) {
1049
1051
  const entries = await readdir(dirPath)
1050
1052
  const pages = []
1051
1053
  let assetCollection = {
@@ -1072,6 +1074,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1072
1074
  const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
1073
1075
  const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1074
1076
 
1077
+ // Extract layout name from folder config (folder.yml layout: or page.yml layout:)
1078
+ const folderLayout = typeof dirConfig.layout === 'string' ? dirConfig.layout
1079
+ : dirConfig.layout?.name || null
1080
+
1075
1081
  pageFolders.push({
1076
1082
  name: entry,
1077
1083
  path: entryPath,
@@ -1081,7 +1087,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1081
1087
  childOrderConfig: {
1082
1088
  pages: dirConfig.pages,
1083
1089
  index: dirConfig.index
1084
- }
1090
+ },
1091
+ childLayoutName: folderLayout
1085
1092
  })
1086
1093
  }
1087
1094
 
@@ -1090,6 +1097,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1090
1097
  for (const [routeSegment, mountPath] of mounts) {
1091
1098
  if (!pageFolders.some(f => f.name === routeSegment)) {
1092
1099
  const { config: mountConfig } = await readFolderConfig(mountPath, 'pages')
1100
+ const mountLayout = typeof mountConfig.layout === 'string' ? mountConfig.layout
1101
+ : mountConfig.layout?.name || null
1093
1102
  pageFolders.push({
1094
1103
  name: routeSegment,
1095
1104
  path: mountPath,
@@ -1099,7 +1108,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1099
1108
  childOrderConfig: {
1100
1109
  pages: mountConfig.pages,
1101
1110
  index: mountConfig.index
1102
- }
1111
+ },
1112
+ childLayoutName: mountLayout
1103
1113
  })
1104
1114
  }
1105
1115
  }
@@ -1140,7 +1150,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1140
1150
  versionedScopes.set(parentRoute, versionMeta)
1141
1151
 
1142
1152
  for (const folder of orderedFolders) {
1143
- const { name: entry, path: entryPath, childOrderConfig } = folder
1153
+ const { name: entry, path: entryPath, childOrderConfig, childLayoutName } = folder
1144
1154
 
1145
1155
  if (isVersionFolder(entry)) {
1146
1156
  const versionInfo = versionMeta.versions.find(v => v.id === entry)
@@ -1153,7 +1163,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1153
1163
 
1154
1164
  const subResult = await collectPagesRecursive(
1155
1165
  entryPath, versionRoute, siteRoot, childOrderConfig, parentFetch,
1156
- { version: versionInfo, versionMeta, scope: parentRoute }
1166
+ { version: versionInfo, versionMeta, scope: parentRoute },
1167
+ 'sections', null, childLayoutName || parentLayoutName
1157
1168
  )
1158
1169
 
1159
1170
  pages.push(...subResult.pages)
@@ -1164,7 +1175,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1164
1175
  }
1165
1176
  } else {
1166
1177
  const result = await processPage(entryPath, entry, siteRoot, {
1167
- isIndex: false, parentRoute, parentFetch
1178
+ isIndex: false, parentRoute, parentFetch,
1179
+ layoutName: childLayoutName || parentLayoutName
1168
1180
  })
1169
1181
  if (result) {
1170
1182
  pages.push(result.page)
@@ -1228,18 +1240,25 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1228
1240
  page.route = parentRoute
1229
1241
  }
1230
1242
 
1243
+ // Inherit layout name from parent (folder.yml or site.yml cascade)
1244
+ if (parentLayoutName && !page.layout.name) {
1245
+ page.layout.name = parentLayoutName
1246
+ }
1247
+
1231
1248
  pages.push(page)
1232
1249
  }
1233
1250
 
1234
1251
  // Process subdirectories
1235
1252
  for (const folder of orderedFolders) {
1236
- const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
1253
+ const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig, childLayoutName } = folder
1237
1254
  const isIndex = entry === indexName
1255
+ const effectiveLayout = childLayoutName || parentLayoutName
1238
1256
 
1239
1257
  if (dirMode === 'sections') {
1240
1258
  // Subdirectory overrides to page mode — process normally
1241
1259
  const result = await processPage(entryPath, entry, siteRoot, {
1242
- isIndex, parentRoute, parentFetch, versionContext
1260
+ isIndex, parentRoute, parentFetch, versionContext,
1261
+ layoutName: effectiveLayout
1243
1262
  })
1244
1263
 
1245
1264
  if (result) {
@@ -1252,7 +1271,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1252
1271
  const childDirPath = mounts?.get(entry) || entryPath
1253
1272
  const childParentRoute = isIndex ? parentRoute : page.route
1254
1273
  const childFetch = page.fetch || parentFetch
1255
- const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections', null)
1274
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections', null, effectiveLayout)
1256
1275
  pages.push(...subResult.pages)
1257
1276
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1258
1277
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1266,6 +1285,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1266
1285
  ? parentRoute
1267
1286
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
1268
1287
 
1288
+ // Resolve layout for container page
1289
+ const containerLayoutObj = typeof dirConfig.layout === 'object' && dirConfig.layout !== null ? dirConfig.layout : {}
1290
+
1269
1291
  const containerPage = {
1270
1292
  route: containerRoute,
1271
1293
  sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
@@ -1285,10 +1307,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1285
1307
  hideInHeader: dirConfig.hideInHeader || false,
1286
1308
  hideInFooter: dirConfig.hideInFooter || false,
1287
1309
  layout: {
1288
- header: dirConfig.layout?.header !== false,
1289
- footer: dirConfig.layout?.footer !== false,
1290
- leftPanel: dirConfig.layout?.leftPanel !== false,
1291
- rightPanel: dirConfig.layout?.rightPanel !== false
1310
+ ...(effectiveLayout ? { name: effectiveLayout } : {}),
1311
+ ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
1312
+ ...(containerLayoutObj.params ? { params: containerLayoutObj.params } : {}),
1292
1313
  },
1293
1314
  seo: {
1294
1315
  noindex: dirConfig.seo?.noindex || false,
@@ -1305,7 +1326,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1305
1326
 
1306
1327
  // Recurse in folder mode
1307
1328
  const childDirPath = mounts?.get(entry) || entryPath
1308
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null)
1329
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
1309
1330
  pages.push(...subResult.pages)
1310
1331
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1311
1332
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1340,8 +1361,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1340
1361
 
1341
1362
  // Second pass: process each page folder
1342
1363
  for (const folder of orderedFolders) {
1343
- const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
1364
+ const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig, childLayoutName } = folder
1344
1365
  const isIndex = entry === indexPageName
1366
+ const effectiveLayout = childLayoutName || parentLayoutName
1345
1367
 
1346
1368
  if (dirMode === 'pages') {
1347
1369
  // Child directory switches to folder mode (has folder.yml) —
@@ -1350,6 +1372,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1350
1372
  ? parentRoute
1351
1373
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
1352
1374
 
1375
+ // Resolve layout for container page
1376
+ const containerLayoutObj = typeof dirConfig.layout === 'object' && dirConfig.layout !== null ? dirConfig.layout : {}
1377
+
1353
1378
  const containerPage = {
1354
1379
  route: containerRoute,
1355
1380
  sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
@@ -1369,10 +1394,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1369
1394
  hideInHeader: dirConfig.hideInHeader || false,
1370
1395
  hideInFooter: dirConfig.hideInFooter || false,
1371
1396
  layout: {
1372
- header: dirConfig.layout?.header !== false,
1373
- footer: dirConfig.layout?.footer !== false,
1374
- leftPanel: dirConfig.layout?.leftPanel !== false,
1375
- rightPanel: dirConfig.layout?.rightPanel !== false
1397
+ ...(effectiveLayout ? { name: effectiveLayout } : {}),
1398
+ ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
1399
+ ...(containerLayoutObj.params ? { params: containerLayoutObj.params } : {}),
1376
1400
  },
1377
1401
  seo: {
1378
1402
  noindex: dirConfig.seo?.noindex || false,
@@ -1392,7 +1416,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1392
1416
  }
1393
1417
 
1394
1418
  const childDirPath = mounts?.get(entry) || entryPath
1395
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null)
1419
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
1396
1420
  pages.push(...subResult.pages)
1397
1421
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1398
1422
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1402,7 +1426,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1402
1426
  } else {
1403
1427
  // Sections mode — process directory as a page (existing behavior)
1404
1428
  const result = await processPage(entryPath, entry, siteRoot, {
1405
- isIndex, parentRoute, parentFetch, versionContext
1429
+ isIndex, parentRoute, parentFetch, versionContext,
1430
+ layoutName: effectiveLayout
1406
1431
  })
1407
1432
 
1408
1433
  if (result) {
@@ -1424,7 +1449,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1424
1449
  ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
1425
1450
  : page.route
1426
1451
  const childFetch = page.fetch || parentFetch
1427
- const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode, null)
1452
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode, null, effectiveLayout)
1428
1453
  pages.push(...subResult.pages)
1429
1454
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1430
1455
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1481,63 +1506,159 @@ async function loadFoundationVars(foundationPath) {
1481
1506
  }
1482
1507
 
1483
1508
  /**
1484
- * Collect layout panels from the layout/ directory
1509
+ * Collect areas from a single directory (general named areas, not hardcoded).
1485
1510
  *
1486
- * Layout panels (header, footer, left, right) are persistent regions
1487
- * that appear on every page. They live in layout/ parallel to pages/.
1488
- *
1489
- * Supports two forms:
1490
- * - Folder: layout/header/ (directory with .md files, like a page)
1491
- * - File shorthand: layout/header.md (single markdown file)
1511
+ * Supports two forms per area:
1512
+ * - Folder: dir/header/ (directory with .md files, like a page)
1513
+ * - File shorthand: dir/header.md (single markdown file)
1492
1514
  * Folder takes priority when both exist.
1493
1515
  *
1494
- * @param {string} layoutDir - Path to layout directory
1516
+ * @param {string} dir - Directory to scan for area files
1495
1517
  * @param {string} siteRoot - Path to site root
1496
- * @returns {Promise<Object>} { header, footer, left, right }
1518
+ * @param {string} routePrefix - Route prefix for area pages (e.g., '/layout' or '/layout/marketing')
1519
+ * @returns {Promise<Object>} Map of areaName -> page data
1497
1520
  */
1498
- async function collectLayoutPanels(layoutDir, siteRoot) {
1499
- const result = { header: null, footer: null, left: null, right: null }
1500
-
1501
- if (!existsSync(layoutDir)) return result
1502
-
1503
- const knownPanels = ['header', 'footer', 'left', 'right']
1504
- const entries = await readdir(layoutDir)
1505
-
1506
- for (const panel of knownPanels) {
1507
- // Folder form (higher priority)
1508
- if (entries.includes(panel)) {
1509
- const entryPath = join(layoutDir, panel)
1510
- const stats = await stat(entryPath)
1511
- if (stats.isDirectory()) {
1512
- const pageResult = await processPage(entryPath, panel, siteRoot, {
1513
- isIndex: false,
1514
- parentRoute: '/layout'
1515
- })
1516
- if (pageResult) {
1517
- result[panel] = pageResult.page
1518
- }
1519
- continue
1520
- }
1521
+ async function collectAreasFromDir(dir, siteRoot, routePrefix = '/layout') {
1522
+ const result = {}
1523
+
1524
+ if (!existsSync(dir)) return result
1525
+
1526
+ const entries = await readdir(dir, { withFileTypes: true })
1527
+
1528
+ // Track which area names we've already processed (folder form takes priority)
1529
+ const processed = new Set()
1530
+
1531
+ // First pass: directories (folder form, higher priority)
1532
+ for (const entry of entries) {
1533
+ if (!entry.isDirectory()) continue
1534
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
1535
+
1536
+ const areaName = entry.name
1537
+ const entryPath = join(dir, areaName)
1538
+ const pageResult = await processPage(entryPath, areaName, siteRoot, {
1539
+ isIndex: false,
1540
+ parentRoute: routePrefix
1541
+ })
1542
+ if (pageResult) {
1543
+ result[areaName] = pageResult.page
1544
+ processed.add(areaName)
1521
1545
  }
1546
+ }
1522
1547
 
1523
- // File shorthand: layout/header.md
1524
- const mdFile = `${panel}.md`
1525
- if (entries.includes(mdFile)) {
1526
- const filePath = join(layoutDir, mdFile)
1527
- const { section } = await processMarkdownFile(filePath, '1', siteRoot, panel)
1528
- result[panel] = {
1529
- route: `/layout/${panel}`,
1530
- title: panel.charAt(0).toUpperCase() + panel.slice(1),
1531
- description: '',
1532
- layout: { header: true, footer: true, leftPanel: true, rightPanel: true },
1533
- sections: [section]
1534
- }
1548
+ // Second pass: markdown file shorthand (only if not already processed as folder)
1549
+ for (const entry of entries) {
1550
+ if (!entry.isFile()) continue
1551
+ if (!entry.name.endsWith('.md')) continue
1552
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
1553
+
1554
+ const areaName = entry.name.replace('.md', '')
1555
+ if (processed.has(areaName)) continue
1556
+
1557
+ const filePath = join(dir, entry.name)
1558
+ const { section } = await processMarkdownFile(filePath, '1', siteRoot, areaName)
1559
+ result[areaName] = {
1560
+ route: `${routePrefix}/${areaName}`,
1561
+ title: areaName.charAt(0).toUpperCase() + areaName.slice(1),
1562
+ description: '',
1563
+ layout: {},
1564
+ sections: [section]
1535
1565
  }
1536
1566
  }
1537
1567
 
1538
1568
  return result
1539
1569
  }
1540
1570
 
1571
+ /**
1572
+ * Check if a directory looks like a named layout (contains area-like .md files or area subdirs)
1573
+ * vs an area in folder form (contains section content processed by processPage).
1574
+ *
1575
+ * Heuristic: if a directory contains .md files at the top level AND no page.yml,
1576
+ * it's a named layout (those .md files are its area definitions).
1577
+ * If it has page.yml or looks like a page directory, it's an area folder.
1578
+ *
1579
+ * @param {string} dirPath - Path to the directory
1580
+ * @returns {Promise<boolean>} True if this looks like a named layout directory
1581
+ */
1582
+ async function isNamedLayoutDir(dirPath) {
1583
+ // If it has page.yml, it's an area folder (processPage will handle it)
1584
+ if (existsSync(join(dirPath, 'page.yml'))) return false
1585
+
1586
+ const entries = await readdir(dirPath)
1587
+ // If directory contains .md files but no page.yml, it's a named layout
1588
+ return entries.some(e => e.endsWith('.md') && !e.startsWith('_') && !e.startsWith('.'))
1589
+ }
1590
+
1591
+ /**
1592
+ * Collect layout areas from the layout/ directory, including named layout subdirectories.
1593
+ *
1594
+ * Root-level .md files and area directories form the "default" layout's areas.
1595
+ * Subdirectories that themselves contain .md files (without page.yml) are named layouts,
1596
+ * each with its own set of areas.
1597
+ *
1598
+ * @param {string} layoutDir - Path to layout directory
1599
+ * @param {string} siteRoot - Path to site root
1600
+ * @returns {Promise<Object>} { layouts }
1601
+ */
1602
+ async function collectLayouts(layoutDir, siteRoot) {
1603
+ if (!existsSync(layoutDir)) {
1604
+ return { layouts: null }
1605
+ }
1606
+
1607
+ const entries = await readdir(layoutDir, { withFileTypes: true })
1608
+
1609
+ // Separate root-level entries into:
1610
+ // 1. Area .md files (for default layout)
1611
+ // 2. Area directories (for default layout) vs named layout directories
1612
+ const defaultAreaFiles = []
1613
+ const defaultAreaDirs = []
1614
+ const namedLayoutDirs = []
1615
+
1616
+ for (const entry of entries) {
1617
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
1618
+
1619
+ if (entry.isFile() && entry.name.endsWith('.md')) {
1620
+ defaultAreaFiles.push(entry)
1621
+ } else if (entry.isDirectory()) {
1622
+ const dirPath = join(layoutDir, entry.name)
1623
+ if (await isNamedLayoutDir(dirPath)) {
1624
+ namedLayoutDirs.push(entry)
1625
+ } else {
1626
+ defaultAreaDirs.push(entry)
1627
+ }
1628
+ }
1629
+ }
1630
+
1631
+ // Collect default layout areas
1632
+ const defaultAreas = await collectAreasFromDir(layoutDir, siteRoot, '/layout')
1633
+ // Remove any named layout directories that got collected as areas
1634
+ for (const dir of namedLayoutDirs) {
1635
+ delete defaultAreas[dir.name]
1636
+ }
1637
+
1638
+ // Collect named layout areas
1639
+ const namedLayouts = {}
1640
+ for (const entry of namedLayoutDirs) {
1641
+ const subdir = join(layoutDir, entry.name)
1642
+ namedLayouts[entry.name] = await collectAreasFromDir(subdir, siteRoot, `/layout/${entry.name}`)
1643
+ }
1644
+
1645
+ const hasDefaultAreas = Object.keys(defaultAreas).length > 0
1646
+ const hasNamedLayouts = Object.keys(namedLayouts).length > 0
1647
+
1648
+ if (!hasDefaultAreas && !hasNamedLayouts) {
1649
+ return { layouts: null }
1650
+ }
1651
+
1652
+ // Always use the layouts object format (general areas)
1653
+ const layouts = {}
1654
+ if (hasDefaultAreas) {
1655
+ layouts.default = defaultAreas
1656
+ }
1657
+ Object.assign(layouts, namedLayouts)
1658
+
1659
+ return { layouts }
1660
+ }
1661
+
1541
1662
  /**
1542
1663
  * Collect all site content
1543
1664
  *
@@ -1595,12 +1716,16 @@ export async function collectSiteContent(sitePath, options = {}) {
1595
1716
  // Determine root content mode from folder.yml/page.yml presence in pages directory
1596
1717
  const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
1597
1718
 
1598
- // Collect layout panels from layout/ directory
1599
- const { header, footer, left, right } = await collectLayoutPanels(layoutPath, sitePath)
1719
+ // Collect layout areas from layout/ directory (including named layout subdirectories)
1720
+ const { layouts } = await collectLayouts(layoutPath, sitePath)
1721
+
1722
+ // Site-level layout name (from site.yml layout: field)
1723
+ const siteLayoutName = typeof siteConfig.layout === 'string' ? siteConfig.layout
1724
+ : siteConfig.layout?.name || null
1600
1725
 
1601
1726
  // Recursively collect all pages
1602
1727
  const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
1603
- await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts)
1728
+ await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayoutName)
1604
1729
 
1605
1730
  // Deduplicate: remove content-less container pages whose route duplicates
1606
1731
  // a content-bearing page (e.g., a promoted index page)
@@ -1679,10 +1804,8 @@ export async function collectSiteContent(sitePath, options = {}) {
1679
1804
  css: themeCSS
1680
1805
  },
1681
1806
  pages,
1682
- header,
1683
- footer,
1684
- left,
1685
- right,
1807
+ // Layout area sets: { default: { header: page, footer: page, ... }, marketing: { ... } }
1808
+ layouts,
1686
1809
  notFound,
1687
1810
  // Versioned scopes: route → { versions, latestId }
1688
1811
  versionedScopes: versionedScopesObj,