@uniweb/build 0.6.20 → 0.6.21

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.6.21",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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.4.8"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "vite": {
@@ -108,8 +108,12 @@ function generateEntrySource(components, options = {}) {
108
108
  }
109
109
 
110
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.
111
114
  if (foundationExports) {
112
- lines.push(`import capabilities from '${foundationExports.path}'`)
115
+ 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 }) }`)
113
117
  }
114
118
 
115
119
  // Component imports
package/src/prerender.js CHANGED
@@ -569,7 +569,7 @@ 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 RemoteLayout = website.getRemoteLayout(page.getLayoutName())
573
573
 
574
574
  const headerBlocks = page.getHeaderBlocks()
575
575
  const bodyBlocks = page.getBodyBlocks()
package/src/schema.js CHANGED
@@ -111,6 +111,8 @@ export async function loadFoundationConfig(srcDir) {
111
111
  ...module.default,
112
112
  vars: module.vars || module.default?.vars,
113
113
  Layout: module.Layout || module.default?.Layout,
114
+ layouts: module.layouts || module.default?.layouts,
115
+ defaultLayout: module.default?.defaultLayout,
114
116
  }
115
117
  } catch (error) {
116
118
  console.warn(`Warning: Failed to load foundation config ${filePath}:`, error.message)
@@ -312,13 +312,28 @@ export function rewriteSiteContentPaths(siteContent, pathMapping) {
312
312
  result.pages.forEach(processPage)
313
313
  }
314
314
 
315
- // Process header and footer
315
+ // Process named layout section sets
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])
320
+ }
321
+ }
322
+ }
323
+
324
+ // Process flat header/footer/left/right (backward compat)
316
325
  if (result.header) {
317
326
  processPage(result.header)
318
327
  }
319
328
  if (result.footer) {
320
329
  processPage(result.footer)
321
330
  }
331
+ if (result.left) {
332
+ processPage(result.left)
333
+ }
334
+ if (result.right) {
335
+ processPage(result.right)
336
+ }
322
337
 
323
338
  // Remove the assets manifest from output (no longer needed at runtime)
324
339
  delete result.assets
@@ -765,7 +765,7 @@ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, paren
765
765
  * @param {Object} options.versionContext - Version context from parent { version, versionMeta, scope }
766
766
  * @returns {Object} Page data with assets manifest
767
767
  */
768
- async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null } = {}) {
768
+ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, parentRoute = '/', parentFetch = null, versionContext = null, layoutName = null } = {}) {
769
769
  const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
770
770
 
771
771
  // Note: We no longer skip hidden pages here - they still exist as valid pages,
@@ -925,7 +925,15 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
925
925
  const sourcePath = isIndex ? folderRoute : null
926
926
 
927
927
  // Extract configuration
928
- const { seo = {}, layout = {}, ...restConfig } = pageConfig
928
+ const { seo = {}, layout: layoutConfig, ...restConfig } = pageConfig
929
+
930
+ // Resolve layout name: page.yml layout (string or object.name) > inherited from parent > null
931
+ const pageLayoutName = typeof layoutConfig === 'string' ? layoutConfig
932
+ : layoutConfig?.name || null
933
+ const resolvedLayoutName = pageLayoutName || layoutName || null
934
+
935
+ // Layout panel visibility (from object form of layout config)
936
+ const layoutObj = typeof layoutConfig === 'object' && layoutConfig !== null ? layoutConfig : {}
929
937
 
930
938
  // For dynamic routes, determine the parent's data schema
931
939
  // This tells prerender which data array to iterate over
@@ -960,12 +968,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
960
968
  hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
961
969
  hideInFooter: pageConfig.hideInFooter || false, // Hide from footer nav
962
970
 
963
- // Layout options (per-page overrides)
971
+ // Layout options (named layout + per-page overrides)
964
972
  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)
973
+ ...(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
978
  },
970
979
 
971
980
  seo: {
@@ -1045,7 +1054,7 @@ function determineIndexPage(orderConfig, availableFolders) {
1045
1054
  * @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
1046
1055
  * @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
1047
1056
  */
1048
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null) {
1057
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections', mounts = null, parentLayoutName = null) {
1049
1058
  const entries = await readdir(dirPath)
1050
1059
  const pages = []
1051
1060
  let assetCollection = {
@@ -1072,6 +1081,10 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1072
1081
  const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
1073
1082
  const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1074
1083
 
1084
+ // Extract layout name from folder config (folder.yml layout: or page.yml layout:)
1085
+ const folderLayout = typeof dirConfig.layout === 'string' ? dirConfig.layout
1086
+ : dirConfig.layout?.name || null
1087
+
1075
1088
  pageFolders.push({
1076
1089
  name: entry,
1077
1090
  path: entryPath,
@@ -1081,7 +1094,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1081
1094
  childOrderConfig: {
1082
1095
  pages: dirConfig.pages,
1083
1096
  index: dirConfig.index
1084
- }
1097
+ },
1098
+ childLayoutName: folderLayout
1085
1099
  })
1086
1100
  }
1087
1101
 
@@ -1090,6 +1104,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1090
1104
  for (const [routeSegment, mountPath] of mounts) {
1091
1105
  if (!pageFolders.some(f => f.name === routeSegment)) {
1092
1106
  const { config: mountConfig } = await readFolderConfig(mountPath, 'pages')
1107
+ const mountLayout = typeof mountConfig.layout === 'string' ? mountConfig.layout
1108
+ : mountConfig.layout?.name || null
1093
1109
  pageFolders.push({
1094
1110
  name: routeSegment,
1095
1111
  path: mountPath,
@@ -1099,7 +1115,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1099
1115
  childOrderConfig: {
1100
1116
  pages: mountConfig.pages,
1101
1117
  index: mountConfig.index
1102
- }
1118
+ },
1119
+ childLayoutName: mountLayout
1103
1120
  })
1104
1121
  }
1105
1122
  }
@@ -1140,7 +1157,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1140
1157
  versionedScopes.set(parentRoute, versionMeta)
1141
1158
 
1142
1159
  for (const folder of orderedFolders) {
1143
- const { name: entry, path: entryPath, childOrderConfig } = folder
1160
+ const { name: entry, path: entryPath, childOrderConfig, childLayoutName } = folder
1144
1161
 
1145
1162
  if (isVersionFolder(entry)) {
1146
1163
  const versionInfo = versionMeta.versions.find(v => v.id === entry)
@@ -1153,7 +1170,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1153
1170
 
1154
1171
  const subResult = await collectPagesRecursive(
1155
1172
  entryPath, versionRoute, siteRoot, childOrderConfig, parentFetch,
1156
- { version: versionInfo, versionMeta, scope: parentRoute }
1173
+ { version: versionInfo, versionMeta, scope: parentRoute },
1174
+ 'sections', null, childLayoutName || parentLayoutName
1157
1175
  )
1158
1176
 
1159
1177
  pages.push(...subResult.pages)
@@ -1164,7 +1182,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1164
1182
  }
1165
1183
  } else {
1166
1184
  const result = await processPage(entryPath, entry, siteRoot, {
1167
- isIndex: false, parentRoute, parentFetch
1185
+ isIndex: false, parentRoute, parentFetch,
1186
+ layoutName: childLayoutName || parentLayoutName
1168
1187
  })
1169
1188
  if (result) {
1170
1189
  pages.push(result.page)
@@ -1228,18 +1247,25 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1228
1247
  page.route = parentRoute
1229
1248
  }
1230
1249
 
1250
+ // Inherit layout name from parent (folder.yml or site.yml cascade)
1251
+ if (parentLayoutName && !page.layout.name) {
1252
+ page.layout.name = parentLayoutName
1253
+ }
1254
+
1231
1255
  pages.push(page)
1232
1256
  }
1233
1257
 
1234
1258
  // Process subdirectories
1235
1259
  for (const folder of orderedFolders) {
1236
- const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
1260
+ const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig, childLayoutName } = folder
1237
1261
  const isIndex = entry === indexName
1262
+ const effectiveLayout = childLayoutName || parentLayoutName
1238
1263
 
1239
1264
  if (dirMode === 'sections') {
1240
1265
  // Subdirectory overrides to page mode — process normally
1241
1266
  const result = await processPage(entryPath, entry, siteRoot, {
1242
- isIndex, parentRoute, parentFetch, versionContext
1267
+ isIndex, parentRoute, parentFetch, versionContext,
1268
+ layoutName: effectiveLayout
1243
1269
  })
1244
1270
 
1245
1271
  if (result) {
@@ -1252,7 +1278,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1252
1278
  const childDirPath = mounts?.get(entry) || entryPath
1253
1279
  const childParentRoute = isIndex ? parentRoute : page.route
1254
1280
  const childFetch = page.fetch || parentFetch
1255
- const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections', null)
1281
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections', null, effectiveLayout)
1256
1282
  pages.push(...subResult.pages)
1257
1283
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1258
1284
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1266,6 +1292,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1266
1292
  ? parentRoute
1267
1293
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
1268
1294
 
1295
+ // Resolve layout for container page
1296
+ const containerLayoutObj = typeof dirConfig.layout === 'object' && dirConfig.layout !== null ? dirConfig.layout : {}
1297
+
1269
1298
  const containerPage = {
1270
1299
  route: containerRoute,
1271
1300
  sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
@@ -1285,10 +1314,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1285
1314
  hideInHeader: dirConfig.hideInHeader || false,
1286
1315
  hideInFooter: dirConfig.hideInFooter || false,
1287
1316
  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
1317
+ ...(effectiveLayout ? { name: effectiveLayout } : {}),
1318
+ header: containerLayoutObj.header !== false,
1319
+ footer: containerLayoutObj.footer !== false,
1320
+ leftPanel: containerLayoutObj.leftPanel !== false,
1321
+ rightPanel: containerLayoutObj.rightPanel !== false
1292
1322
  },
1293
1323
  seo: {
1294
1324
  noindex: dirConfig.seo?.noindex || false,
@@ -1305,7 +1335,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1305
1335
 
1306
1336
  // Recurse in folder mode
1307
1337
  const childDirPath = mounts?.get(entry) || entryPath
1308
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null)
1338
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
1309
1339
  pages.push(...subResult.pages)
1310
1340
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1311
1341
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1340,8 +1370,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1340
1370
 
1341
1371
  // Second pass: process each page folder
1342
1372
  for (const folder of orderedFolders) {
1343
- const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
1373
+ const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig, childLayoutName } = folder
1344
1374
  const isIndex = entry === indexPageName
1375
+ const effectiveLayout = childLayoutName || parentLayoutName
1345
1376
 
1346
1377
  if (dirMode === 'pages') {
1347
1378
  // Child directory switches to folder mode (has folder.yml) —
@@ -1350,6 +1381,9 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1350
1381
  ? parentRoute
1351
1382
  : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
1352
1383
 
1384
+ // Resolve layout for container page
1385
+ const containerLayoutObj = typeof dirConfig.layout === 'object' && dirConfig.layout !== null ? dirConfig.layout : {}
1386
+
1353
1387
  const containerPage = {
1354
1388
  route: containerRoute,
1355
1389
  sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
@@ -1369,10 +1403,11 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1369
1403
  hideInHeader: dirConfig.hideInHeader || false,
1370
1404
  hideInFooter: dirConfig.hideInFooter || false,
1371
1405
  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
1406
+ ...(effectiveLayout ? { name: effectiveLayout } : {}),
1407
+ header: containerLayoutObj.header !== false,
1408
+ footer: containerLayoutObj.footer !== false,
1409
+ leftPanel: containerLayoutObj.leftPanel !== false,
1410
+ rightPanel: containerLayoutObj.rightPanel !== false
1376
1411
  },
1377
1412
  seo: {
1378
1413
  noindex: dirConfig.seo?.noindex || false,
@@ -1392,7 +1427,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1392
1427
  }
1393
1428
 
1394
1429
  const childDirPath = mounts?.get(entry) || entryPath
1395
- const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null)
1430
+ const subResult = await collectPagesRecursive(childDirPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages', null, effectiveLayout)
1396
1431
  pages.push(...subResult.pages)
1397
1432
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1398
1433
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1402,7 +1437,8 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1402
1437
  } else {
1403
1438
  // Sections mode — process directory as a page (existing behavior)
1404
1439
  const result = await processPage(entryPath, entry, siteRoot, {
1405
- isIndex, parentRoute, parentFetch, versionContext
1440
+ isIndex, parentRoute, parentFetch, versionContext,
1441
+ layoutName: effectiveLayout
1406
1442
  })
1407
1443
 
1408
1444
  if (result) {
@@ -1424,7 +1460,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1424
1460
  ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
1425
1461
  : page.route
1426
1462
  const childFetch = page.fetch || parentFetch
1427
- const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode, null)
1463
+ const subResult = await collectPagesRecursive(childDirPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode, null, effectiveLayout)
1428
1464
  pages.push(...subResult.pages)
1429
1465
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1430
1466
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
@@ -1481,37 +1517,35 @@ async function loadFoundationVars(foundationPath) {
1481
1517
  }
1482
1518
 
1483
1519
  /**
1484
- * Collect layout panels from the layout/ directory
1485
- *
1486
- * Layout panels (header, footer, left, right) are persistent regions
1487
- * that appear on every page. They live in layout/ parallel to pages/.
1520
+ * Collect panel data (header, footer, left, right) from a single directory.
1488
1521
  *
1489
- * Supports two forms:
1490
- * - Folder: layout/header/ (directory with .md files, like a page)
1491
- * - File shorthand: layout/header.md (single markdown file)
1522
+ * Supports two forms per panel:
1523
+ * - Folder: dir/header/ (directory with .md files, like a page)
1524
+ * - File shorthand: dir/header.md (single markdown file)
1492
1525
  * Folder takes priority when both exist.
1493
1526
  *
1494
- * @param {string} layoutDir - Path to layout directory
1527
+ * @param {string} dir - Directory to scan for panel files
1495
1528
  * @param {string} siteRoot - Path to site root
1529
+ * @param {string} routePrefix - Route prefix for panel pages (e.g., '/layout' or '/layout/marketing')
1496
1530
  * @returns {Promise<Object>} { header, footer, left, right }
1497
1531
  */
1498
- async function collectLayoutPanels(layoutDir, siteRoot) {
1532
+ async function collectPanelsFromDir(dir, siteRoot, routePrefix = '/layout') {
1499
1533
  const result = { header: null, footer: null, left: null, right: null }
1500
1534
 
1501
- if (!existsSync(layoutDir)) return result
1535
+ if (!existsSync(dir)) return result
1502
1536
 
1503
1537
  const knownPanels = ['header', 'footer', 'left', 'right']
1504
- const entries = await readdir(layoutDir)
1538
+ const entries = await readdir(dir)
1505
1539
 
1506
1540
  for (const panel of knownPanels) {
1507
1541
  // Folder form (higher priority)
1508
1542
  if (entries.includes(panel)) {
1509
- const entryPath = join(layoutDir, panel)
1543
+ const entryPath = join(dir, panel)
1510
1544
  const stats = await stat(entryPath)
1511
1545
  if (stats.isDirectory()) {
1512
1546
  const pageResult = await processPage(entryPath, panel, siteRoot, {
1513
1547
  isIndex: false,
1514
- parentRoute: '/layout'
1548
+ parentRoute: routePrefix
1515
1549
  })
1516
1550
  if (pageResult) {
1517
1551
  result[panel] = pageResult.page
@@ -1520,13 +1554,13 @@ async function collectLayoutPanels(layoutDir, siteRoot) {
1520
1554
  }
1521
1555
  }
1522
1556
 
1523
- // File shorthand: layout/header.md
1557
+ // File shorthand: header.md
1524
1558
  const mdFile = `${panel}.md`
1525
1559
  if (entries.includes(mdFile)) {
1526
- const filePath = join(layoutDir, mdFile)
1560
+ const filePath = join(dir, mdFile)
1527
1561
  const { section } = await processMarkdownFile(filePath, '1', siteRoot, panel)
1528
1562
  result[panel] = {
1529
- route: `/layout/${panel}`,
1563
+ route: `${routePrefix}/${panel}`,
1530
1564
  title: panel.charAt(0).toUpperCase() + panel.slice(1),
1531
1565
  description: '',
1532
1566
  layout: { header: true, footer: true, leftPanel: true, rightPanel: true },
@@ -1538,6 +1572,50 @@ async function collectLayoutPanels(layoutDir, siteRoot) {
1538
1572
  return result
1539
1573
  }
1540
1574
 
1575
+ /**
1576
+ * Collect layout panels from the layout/ directory, including named layout subdirectories.
1577
+ *
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.
1581
+ *
1582
+ * @param {string} layoutDir - Path to layout directory
1583
+ * @param {string} siteRoot - Path to site root
1584
+ * @returns {Promise<Object>} { layouts, header, footer, left, right }
1585
+ */
1586
+ async function collectLayouts(layoutDir, siteRoot) {
1587
+ if (!existsSync(layoutDir)) {
1588
+ return { layouts: null, header: null, footer: null, left: null, right: null }
1589
+ }
1590
+
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
+ const entries = await readdir(layoutDir, { withFileTypes: true })
1596
+ const knownPanels = new Set(['header', 'footer', 'left', 'right'])
1597
+ const namedLayouts = {}
1598
+
1599
+ for (const entry of entries) {
1600
+ if (!entry.isDirectory()) continue
1601
+ if (knownPanels.has(entry.name)) continue // Skip panel folders (belong to default)
1602
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue
1603
+
1604
+ // This is a named layout subdirectory
1605
+ const subdir = join(layoutDir, entry.name)
1606
+ namedLayouts[entry.name] = await collectPanelsFromDir(subdir, siteRoot, `/layout/${entry.name}`)
1607
+ }
1608
+
1609
+ if (Object.keys(namedLayouts).length === 0) {
1610
+ // No named layouts — backward compatible mode
1611
+ return { layouts: null, ...defaultPanels }
1612
+ }
1613
+
1614
+ // Named layouts mode: root panels become "default", subdirs are named
1615
+ const layouts = { default: defaultPanels, ...namedLayouts }
1616
+ return { layouts, ...defaultPanels }
1617
+ }
1618
+
1541
1619
  /**
1542
1620
  * Collect all site content
1543
1621
  *
@@ -1595,12 +1673,16 @@ export async function collectSiteContent(sitePath, options = {}) {
1595
1673
  // Determine root content mode from folder.yml/page.yml presence in pages directory
1596
1674
  const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
1597
1675
 
1598
- // Collect layout panels from layout/ directory
1599
- const { header, footer, left, right } = await collectLayoutPanels(layoutPath, sitePath)
1676
+ // Collect layout panels from layout/ directory (including named layout subdirectories)
1677
+ const { layouts, header, footer, left, right } = await collectLayouts(layoutPath, sitePath)
1678
+
1679
+ // Site-level layout name (from site.yml layout: field)
1680
+ const siteLayoutName = typeof siteConfig.layout === 'string' ? siteConfig.layout
1681
+ : siteConfig.layout?.name || null
1600
1682
 
1601
1683
  // Recursively collect all pages
1602
1684
  const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
1603
- await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts)
1685
+ await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode, mounts, siteLayoutName)
1604
1686
 
1605
1687
  // Deduplicate: remove content-less container pages whose route duplicates
1606
1688
  // a content-bearing page (e.g., a promoted index page)
@@ -1679,6 +1761,9 @@ export async function collectSiteContent(sitePath, options = {}) {
1679
1761
  css: themeCSS
1680
1762
  },
1681
1763
  pages,
1764
+ // Named layout section sets (null if no named layouts — backward compat)
1765
+ layouts,
1766
+ // Flat panel fields (always present for backward compat)
1682
1767
  header,
1683
1768
  footer,
1684
1769
  left,