@uniweb/build 0.6.21 → 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.21",
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.8"
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,13 +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.)
111
- // Use namespace import to merge named exports (Layout, layouts) into capabilities.
112
- // This ensures both `export const layouts = {...}` (named) and
113
- // `export default { layouts: {...} }` (default) work correctly.
115
+ // Foundation capabilities import (for props, vars, etc.)
116
+ // Note: Layout/layouts no longer merged from foundation.js layouts come from src/layouts/ discovery
114
117
  if (foundationExports) {
115
118
  lines.push(`import * as _foundationModule from '${foundationExports.path}'`)
116
- lines.push(`const capabilities = { ..._foundationModule.default, ...(_foundationModule.Layout && { Layout: _foundationModule.Layout }), ...(_foundationModule.layouts && { layouts: _foundationModule.layouts }) }`)
117
119
  }
118
120
 
119
121
  // Component imports
@@ -122,6 +124,12 @@ function generateEntrySource(components, options = {}) {
122
124
  lines.push(`import ${name} from './${path}/${entryFile}'`)
123
125
  }
124
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
+
125
133
  lines.push('')
126
134
 
127
135
  // Export components object
@@ -133,9 +141,17 @@ function generateEntrySource(components, options = {}) {
133
141
  lines.push('export const components = {}')
134
142
  }
135
143
 
136
- // Foundation capabilities (Layout, props, etc.)
144
+ // Foundation capabilities (props, vars, etc. + discovered layouts)
137
145
  lines.push('')
138
- 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(', ')} }`)
139
155
  lines.push('export { capabilities }')
140
156
  } else {
141
157
  lines.push('export const capabilities = null')
@@ -151,6 +167,16 @@ function generateEntrySource(components, options = {}) {
151
167
  lines.push('export const meta = {}')
152
168
  }
153
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
+
154
180
  lines.push('')
155
181
 
156
182
  return lines.join('\n')
@@ -205,6 +231,10 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
205
231
  console.warn('Warning: No section types found')
206
232
  }
207
233
 
234
+ // Discover layouts from src/layouts/
235
+ const layouts = await discoverLayoutsInPath(srcDir)
236
+ const layoutNames = Object.keys(layouts).sort()
237
+
208
238
  // Detect entry files for each component
209
239
  // Bare files discovered in sections/ already have entryFile set — skip detection for those
210
240
  for (const name of componentNames) {
@@ -216,20 +246,35 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
216
246
  }
217
247
  }
218
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
+
219
259
  // Check for CSS file
220
260
  const cssPath = detectCssFile(srcDir)
221
261
 
222
- // Check for foundation exports (custom Layout, props, etc.)
262
+ // Check for foundation exports (props, vars, etc.)
223
263
  const foundationExports = detectFoundationExports(srcDir)
224
264
 
225
265
  // Extract per-component runtime metadata from meta.js files
226
266
  const meta = extractAllRuntimeSchemas(components)
227
267
 
268
+ // Extract per-layout runtime metadata from meta.js files
269
+ const layoutMeta = extractAllLayoutRuntimeSchemas(layouts)
270
+
228
271
  // Generate source
229
272
  const source = generateEntrySource(components, {
230
273
  cssPath,
231
274
  foundationExports,
232
275
  meta,
276
+ layouts,
277
+ layoutMeta,
233
278
  })
234
279
 
235
280
  // Write to file
@@ -239,6 +284,9 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
239
284
 
240
285
  console.log(`Generated entry point: ${output}`)
241
286
  console.log(` - ${componentNames.length} components: ${componentNames.join(', ')}`)
287
+ if (layoutNames.length > 0) {
288
+ console.log(` - ${layoutNames.length} layouts: ${layoutNames.join(', ')}`)
289
+ }
242
290
  if (foundationExports) {
243
291
  console.log(` - Foundation exports found: ${foundationExports.path}`)
244
292
  }
@@ -246,8 +294,10 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
246
294
  return {
247
295
  outputPath: output,
248
296
  componentNames,
297
+ layoutNames,
249
298
  foundationExports,
250
299
  meta,
300
+ layoutMeta,
251
301
  }
252
302
  }
253
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(page.getLayoutName())
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,11 +110,10 @@ 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,
114
- layouts: module.layouts || module.default?.layouts,
115
117
  defaultLayout: module.default?.defaultLayout,
116
118
  }
117
119
  } catch (error) {
@@ -254,6 +256,87 @@ async function discoverSectionsInPath(srcDir, sectionsRelPath) {
254
256
  return components
255
257
  }
256
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
+
257
340
  /**
258
341
  * Recursively discover nested section types that have meta.js
259
342
  *
@@ -378,12 +461,20 @@ export async function buildSchema(srcDir, componentPaths) {
378
461
  // Discover components
379
462
  const components = await discoverComponents(srcDir, componentPaths)
380
463
 
464
+ // Discover layouts from src/layouts/
465
+ const layouts = await discoverLayoutsInPath(srcDir)
466
+
381
467
  return {
382
- // Merge identity and config - identity fields take precedence
383
468
  _self: {
384
469
  ...foundationConfig,
385
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 }),
386
475
  },
476
+ // Layout metadata (full, for editor)
477
+ ...(Object.keys(layouts).length > 0 && { _layouts: layouts }),
387
478
  ...components,
388
479
  }
389
480
  }
@@ -312,29 +312,15 @@ export function rewriteSiteContentPaths(siteContent, pathMapping) {
312
312
  result.pages.forEach(processPage)
313
313
  }
314
314
 
315
- // Process named layout section sets
315
+ // Process layout area sets
316
316
  if (result.layouts) {
317
- for (const [name, panels] of Object.entries(result.layouts)) {
318
- for (const panel of ['header', 'footer', 'left', 'right']) {
319
- if (panels[panel]) processPage(panels[panel])
317
+ for (const [name, areas] of Object.entries(result.layouts)) {
318
+ for (const areaPage of Object.values(areas)) {
319
+ if (areaPage) processPage(areaPage)
320
320
  }
321
321
  }
322
322
  }
323
323
 
324
- // Process flat header/footer/left/right (backward compat)
325
- if (result.header) {
326
- processPage(result.header)
327
- }
328
- if (result.footer) {
329
- processPage(result.footer)
330
- }
331
- if (result.left) {
332
- processPage(result.left)
333
- }
334
- if (result.right) {
335
- processPage(result.right)
336
- }
337
-
338
324
  // Remove the assets manifest from output (no longer needed at runtime)
339
325
  delete result.assets
340
326
 
@@ -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,
@@ -971,10 +966,8 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
971
966
  // Layout options (named layout + per-page overrides)
972
967
  layout: {
973
968
  ...(resolvedLayoutName ? { name: resolvedLayoutName } : {}),
974
- header: layoutObj.header !== false, // Show header (default true)
975
- footer: layoutObj.footer !== false, // Show footer (default true)
976
- leftPanel: layoutObj.leftPanel !== false, // Show left panel (default true)
977
- rightPanel: layoutObj.rightPanel !== false // Show right panel (default true)
969
+ ...(layoutObj.hide ? { hide: layoutObj.hide } : {}),
970
+ ...(layoutObj.params ? { params: layoutObj.params } : {}),
978
971
  },
979
972
 
980
973
  seo: {
@@ -1315,10 +1308,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1315
1308
  hideInFooter: dirConfig.hideInFooter || false,
1316
1309
  layout: {
1317
1310
  ...(effectiveLayout ? { name: effectiveLayout } : {}),
1318
- header: containerLayoutObj.header !== false,
1319
- footer: containerLayoutObj.footer !== false,
1320
- leftPanel: containerLayoutObj.leftPanel !== false,
1321
- rightPanel: containerLayoutObj.rightPanel !== false
1311
+ ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
1312
+ ...(containerLayoutObj.params ? { params: containerLayoutObj.params } : {}),
1322
1313
  },
1323
1314
  seo: {
1324
1315
  noindex: dirConfig.seo?.noindex || false,
@@ -1404,10 +1395,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1404
1395
  hideInFooter: dirConfig.hideInFooter || false,
1405
1396
  layout: {
1406
1397
  ...(effectiveLayout ? { name: effectiveLayout } : {}),
1407
- header: containerLayoutObj.header !== false,
1408
- footer: containerLayoutObj.footer !== false,
1409
- leftPanel: containerLayoutObj.leftPanel !== false,
1410
- rightPanel: containerLayoutObj.rightPanel !== false
1398
+ ...(containerLayoutObj.hide ? { hide: containerLayoutObj.hide } : {}),
1399
+ ...(containerLayoutObj.params ? { params: containerLayoutObj.params } : {}),
1411
1400
  },
1412
1401
  seo: {
1413
1402
  noindex: dirConfig.seo?.noindex || false,
@@ -1517,55 +1506,62 @@ async function loadFoundationVars(foundationPath) {
1517
1506
  }
1518
1507
 
1519
1508
  /**
1520
- * Collect panel data (header, footer, left, right) from a single directory.
1509
+ * Collect areas from a single directory (general named areas, not hardcoded).
1521
1510
  *
1522
- * Supports two forms per panel:
1511
+ * Supports two forms per area:
1523
1512
  * - Folder: dir/header/ (directory with .md files, like a page)
1524
1513
  * - File shorthand: dir/header.md (single markdown file)
1525
1514
  * Folder takes priority when both exist.
1526
1515
  *
1527
- * @param {string} dir - Directory to scan for panel files
1516
+ * @param {string} dir - Directory to scan for area files
1528
1517
  * @param {string} siteRoot - Path to site root
1529
- * @param {string} routePrefix - Route prefix for panel pages (e.g., '/layout' or '/layout/marketing')
1530
- * @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
1531
1520
  */
1532
- async function collectPanelsFromDir(dir, siteRoot, routePrefix = '/layout') {
1533
- const result = { header: null, footer: null, left: null, right: null }
1521
+ async function collectAreasFromDir(dir, siteRoot, routePrefix = '/layout') {
1522
+ const result = {}
1534
1523
 
1535
1524
  if (!existsSync(dir)) return result
1536
1525
 
1537
- const knownPanels = ['header', 'footer', 'left', 'right']
1538
- const entries = await readdir(dir)
1539
-
1540
- for (const panel of knownPanels) {
1541
- // Folder form (higher priority)
1542
- if (entries.includes(panel)) {
1543
- const entryPath = join(dir, panel)
1544
- const stats = await stat(entryPath)
1545
- if (stats.isDirectory()) {
1546
- const pageResult = await processPage(entryPath, panel, siteRoot, {
1547
- isIndex: false,
1548
- parentRoute: routePrefix
1549
- })
1550
- if (pageResult) {
1551
- result[panel] = pageResult.page
1552
- }
1553
- continue
1554
- }
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)
1555
1545
  }
1546
+ }
1556
1547
 
1557
- // File shorthand: header.md
1558
- const mdFile = `${panel}.md`
1559
- if (entries.includes(mdFile)) {
1560
- const filePath = join(dir, mdFile)
1561
- const { section } = await processMarkdownFile(filePath, '1', siteRoot, panel)
1562
- result[panel] = {
1563
- route: `${routePrefix}/${panel}`,
1564
- title: panel.charAt(0).toUpperCase() + panel.slice(1),
1565
- description: '',
1566
- layout: { header: true, footer: true, leftPanel: true, rightPanel: true },
1567
- sections: [section]
1568
- }
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]
1569
1565
  }
1570
1566
  }
1571
1567
 
@@ -1573,47 +1569,94 @@ async function collectPanelsFromDir(dir, siteRoot, routePrefix = '/layout') {
1573
1569
  }
1574
1570
 
1575
1571
  /**
1576
- * Collect layout panels from the layout/ directory, including named layout subdirectories.
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.
1577
1593
  *
1578
- * Root-level files/folders are the "default" layout's panels.
1579
- * Subdirectories (other than the four known panel names) are named layouts,
1580
- * each self-contained with its own panel set.
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.
1581
1597
  *
1582
1598
  * @param {string} layoutDir - Path to layout directory
1583
1599
  * @param {string} siteRoot - Path to site root
1584
- * @returns {Promise<Object>} { layouts, header, footer, left, right }
1600
+ * @returns {Promise<Object>} { layouts }
1585
1601
  */
1586
1602
  async function collectLayouts(layoutDir, siteRoot) {
1587
1603
  if (!existsSync(layoutDir)) {
1588
- return { layouts: null, header: null, footer: null, left: null, right: null }
1604
+ return { layouts: null }
1589
1605
  }
1590
1606
 
1591
- // Collect root-level panels (= "default" layout, backward compat)
1592
- const defaultPanels = await collectPanelsFromDir(layoutDir, siteRoot, '/layout')
1593
-
1594
- // Scan for named layout subdirectories
1595
1607
  const entries = await readdir(layoutDir, { withFileTypes: true })
1596
- const knownPanels = new Set(['header', 'footer', 'left', 'right'])
1597
- const namedLayouts = {}
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 = []
1598
1615
 
1599
1616
  for (const entry of entries) {
1600
- if (!entry.isDirectory()) continue
1601
- if (knownPanels.has(entry.name)) continue // Skip panel folders (belong to default)
1602
1617
  if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
1603
1618
 
1604
- // This is a named layout subdirectory
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) {
1605
1641
  const subdir = join(layoutDir, entry.name)
1606
- namedLayouts[entry.name] = await collectPanelsFromDir(subdir, siteRoot, `/layout/${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 }
1607
1650
  }
1608
1651
 
1609
- if (Object.keys(namedLayouts).length === 0) {
1610
- // No named layouts backward compatible mode
1611
- return { layouts: null, ...defaultPanels }
1652
+ // Always use the layouts object format (general areas)
1653
+ const layouts = {}
1654
+ if (hasDefaultAreas) {
1655
+ layouts.default = defaultAreas
1612
1656
  }
1657
+ Object.assign(layouts, namedLayouts)
1613
1658
 
1614
- // Named layouts mode: root panels become "default", subdirs are named
1615
- const layouts = { default: defaultPanels, ...namedLayouts }
1616
- return { layouts, ...defaultPanels }
1659
+ return { layouts }
1617
1660
  }
1618
1661
 
1619
1662
  /**
@@ -1673,8 +1716,8 @@ export async function collectSiteContent(sitePath, options = {}) {
1673
1716
  // Determine root content mode from folder.yml/page.yml presence in pages directory
1674
1717
  const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
1675
1718
 
1676
- // Collect layout panels from layout/ directory (including named layout subdirectories)
1677
- const { layouts, header, footer, left, right } = await collectLayouts(layoutPath, sitePath)
1719
+ // Collect layout areas from layout/ directory (including named layout subdirectories)
1720
+ const { layouts } = await collectLayouts(layoutPath, sitePath)
1678
1721
 
1679
1722
  // Site-level layout name (from site.yml layout: field)
1680
1723
  const siteLayoutName = typeof siteConfig.layout === 'string' ? siteConfig.layout
@@ -1761,13 +1804,8 @@ export async function collectSiteContent(sitePath, options = {}) {
1761
1804
  css: themeCSS
1762
1805
  },
1763
1806
  pages,
1764
- // Named layout section sets (null if no named layouts backward compat)
1807
+ // Layout area sets: { default: { header: page, footer: page, ... }, marketing: { ... } }
1765
1808
  layouts,
1766
- // Flat panel fields (always present for backward compat)
1767
- header,
1768
- footer,
1769
- left,
1770
- right,
1771
1809
  notFound,
1772
1810
  // Versioned scopes: route → { versions, latestId }
1773
1811
  versionedScopes: versionedScopesObj,