@uniweb/build 0.6.5 → 0.6.7

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.5",
3
+ "version": "0.6.7",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/schemas": "0.2.1",
54
- "@uniweb/runtime": "0.5.14",
54
+ "@uniweb/runtime": "0.5.16",
55
55
  "@uniweb/content-reader": "1.1.2"
56
56
  },
57
57
  "peerDependencies": {
@@ -61,7 +61,7 @@
61
61
  "@tailwindcss/vite": "^4.0.0",
62
62
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
63
63
  "vite-plugin-svgr": "^4.0.0",
64
- "@uniweb/core": "0.4.3"
64
+ "@uniweb/core": "0.4.4"
65
65
  },
66
66
  "peerDependenciesMeta": {
67
67
  "vite": {
package/src/dev/plugin.js CHANGED
@@ -63,20 +63,28 @@ export function foundationDevPlugin(options = {}) {
63
63
  console.log(`[foundation] Building ${name}...`)
64
64
 
65
65
  try {
66
- // Use Vite's native config loading by specifying configFile
67
66
  const configPath = join(resolvedFoundationPath, 'vite.config.js')
68
67
 
69
- // Build using Vite with the foundation's own config file
70
- await build({
71
- root: resolvedFoundationPath,
72
- configFile: existsSync(configPath) ? configPath : false,
73
- logLevel: 'warn',
74
- build: {
75
- outDir: 'dist',
76
- emptyOutDir: true,
77
- watch: null // Don't use Vite's watch, we handle it ourselves
78
- }
79
- })
68
+ // Temporarily change cwd to foundation directory so that
69
+ // defineFoundationConfig() resolves the entry path correctly
70
+ // (it uses process.cwd() as the foundation root)
71
+ const originalCwd = process.cwd()
72
+ process.chdir(resolvedFoundationPath)
73
+
74
+ try {
75
+ await build({
76
+ root: resolvedFoundationPath,
77
+ configFile: existsSync(configPath) ? configPath : false,
78
+ logLevel: 'warn',
79
+ build: {
80
+ outDir: 'dist',
81
+ emptyOutDir: true,
82
+ watch: null // Don't use Vite's watch, we handle it ourselves
83
+ }
84
+ })
85
+ } finally {
86
+ process.chdir(originalCwd)
87
+ }
80
88
 
81
89
  lastBuildTime = Date.now()
82
90
  console.log(`[foundation] Built ${name} in ${lastBuildTime - startTime}ms`)
@@ -173,7 +181,10 @@ export function foundationDevPlugin(options = {}) {
173
181
 
174
182
  try {
175
183
  watcher = watch(srcPath, { recursive: true }, (eventType, filename) => {
176
- // Ignore non-source files
184
+ // Ignore generated files (build output triggers entry regeneration)
185
+ if (filename && filename.includes('_entry.generated')) return
186
+
187
+ // Only rebuild for source file changes
177
188
  if (
178
189
  filename &&
179
190
  (filename.endsWith('.js') ||
package/src/prerender.js CHANGED
@@ -15,6 +15,37 @@ import { createRequire } from 'node:module'
15
15
  import { pathToFileURL } from 'node:url'
16
16
  import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
17
17
 
18
+ /**
19
+ * Resolve an extension URL to a filesystem path for prerender.
20
+ * Browser URLs like "/effects/foundation.js" need mapping to local files.
21
+ *
22
+ * Resolution order:
23
+ * 1. dist directory (post-build copy target, e.g., site/dist/effects/foundation.js)
24
+ * 2. Project root with dist subdir (dev layout, e.g., project/effects/dist/foundation.js)
25
+ * 3. Original URL (absolute or remote — let import() handle it)
26
+ */
27
+ function resolveExtensionPath(url, distDir, projectRoot) {
28
+ // Only resolve URLs that look like root-relative paths
29
+ if (url.startsWith('/')) {
30
+ // Try dist directory first (production: files copied to site/dist/)
31
+ const distPath = join(distDir, url)
32
+ if (existsSync(distPath)) return distPath
33
+
34
+ // Try project root with dist subdir (dev layout: effects/dist/foundation.js)
35
+ // "/effects/foundation.js" → "effects/dist/foundation.js"
36
+ const parts = url.slice(1).split('/')
37
+ if (parts.length >= 2) {
38
+ const pkgName = parts[0]
39
+ const rest = parts.slice(1).join('/')
40
+ const devPath = join(projectRoot, pkgName, 'dist', rest)
41
+ if (existsSync(devPath)) return devPath
42
+ }
43
+ }
44
+
45
+ // Return as-is for absolute paths or remote URLs
46
+ return url
47
+ }
48
+
18
49
  // Lazily loaded dependencies
19
50
  let React, renderToString, createUniweb
20
51
  let preparePropsSSR, getComponentMetaSSR
@@ -714,6 +745,24 @@ export async function prerenderSite(siteDir, options = {}) {
714
745
 
715
746
  uniweb.setFoundation(foundation)
716
747
 
748
+ // Load extensions (secondary foundations via URL)
749
+ const extensions = siteContent.config?.extensions
750
+ if (extensions?.length) {
751
+ onProgress(`Loading ${extensions.length} extension(s)...`)
752
+ const projectRoot = join(siteDir, '..')
753
+ for (const ext of extensions) {
754
+ try {
755
+ const url = typeof ext === 'string' ? ext : ext.url
756
+ const extPath = resolveExtensionPath(url, distDir, projectRoot)
757
+ const extModule = await import(pathToFileURL(extPath).href)
758
+ uniweb.registerExtension(extModule)
759
+ onProgress(` Extension loaded: ${url}`)
760
+ } catch (err) {
761
+ onProgress(` Warning: Extension failed to load: ${ext} (${err.message})`)
762
+ }
763
+ }
764
+ }
765
+
717
766
  // Set base path from site config so components can access it during SSR
718
767
  // (e.g., <Link reload> needs basePath to prefix hrefs for subdirectory deployments)
719
768
  if (siteContent.config?.base && uniweb.activeWebsite?.setBasePath) {
@@ -175,12 +175,15 @@ export async function defineSiteConfig(options = {}) {
175
175
  const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
176
176
  const base = rawBase ? normalizeBasePath(String(rawBase)) : undefined
177
177
 
178
+ // Check for shell mode (no embedded content, for dynamic backend)
179
+ const isShellMode = process.env.UNIWEB_SHELL === 'true'
180
+
178
181
  // Detect foundation type
179
182
  const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
180
183
 
181
- // Check for runtime mode (env variable or URL-based foundation)
184
+ // Check for runtime mode (env variable, URL-based foundation, or shell mode)
182
185
  const isRuntimeMode =
183
- process.env.VITE_FOUNDATION_MODE === 'runtime' || foundationInfo.type === 'url'
186
+ isShellMode || process.env.VITE_FOUNDATION_MODE === 'runtime' || foundationInfo.type === 'url'
184
187
 
185
188
  // Dynamic imports for optional peer dependencies
186
189
  // These are imported dynamically to avoid requiring them when not needed
@@ -267,7 +270,8 @@ export async function defineSiteConfig(options = {}) {
267
270
  // Site content collection and injection
268
271
  siteContentPlugin({
269
272
  sitePath: './',
270
- inject: true,
273
+ inject: !isShellMode,
274
+ shell: isShellMode,
271
275
  seo,
272
276
  assets,
273
277
  search,
@@ -291,11 +295,122 @@ export async function defineSiteConfig(options = {}) {
291
295
  // Build resolve.alias configuration
292
296
  const alias = {}
293
297
 
294
- // Set up #foundation alias for bundled mode
295
- if (!isRuntimeMode && foundationInfo.type !== 'url') {
298
+ if (isRuntimeMode) {
299
+ // In runtime mode, foundation is loaded via URL at runtime.
300
+ // main.js still imports #foundation so Vite can resolve it,
301
+ // but start() ignores the import and uses the URL instead.
302
+ // Point #foundation at a virtual noop module.
303
+ alias['#foundation'] = '\0__foundation-noop__'
304
+ } else if (foundationInfo.type !== 'url') {
305
+ // Bundled mode: #foundation points to the actual package
296
306
  alias['#foundation'] = foundationInfo.name
297
307
  }
298
308
 
309
+ // Virtual module plugin for the noop foundation stub
310
+ const noopFoundationPlugin = isRuntimeMode ? {
311
+ name: 'uniweb:foundation-noop',
312
+ resolveId(id) {
313
+ if (id === '\0__foundation-noop__' || id.startsWith('\0__foundation-noop__')) return id
314
+ },
315
+ load(id) {
316
+ if (id === '\0__foundation-noop__') return 'export default {}'
317
+ // Handle #foundation/styles → noop CSS
318
+ if (id.startsWith('\0__foundation-noop__')) return ''
319
+ }
320
+ } : null
321
+
322
+ if (noopFoundationPlugin) plugins.push(noopFoundationPlugin)
323
+
324
+ // Import map plugin for runtime mode production builds
325
+ // Emits re-export modules for each externalized package (react, @uniweb/core, etc.)
326
+ // so the browser can resolve bare specifiers in the dynamically-imported foundation
327
+ const IMPORT_MAP_EXTERNALS = [
328
+ 'react',
329
+ 'react-dom',
330
+ 'react/jsx-runtime',
331
+ 'react/jsx-dev-runtime',
332
+ '@uniweb/core'
333
+ ]
334
+ const IMPORT_MAP_PREFIX = '\0importmap:'
335
+
336
+ const importMapPlugin = isRuntimeMode ? (() => {
337
+ let isBuild = false
338
+
339
+ return {
340
+ name: 'uniweb:import-map',
341
+
342
+ configResolved(config) {
343
+ isBuild = config.command === 'build'
344
+ },
345
+
346
+ resolveId(id) {
347
+ if (id.startsWith(IMPORT_MAP_PREFIX)) return id
348
+ },
349
+
350
+ async load(id) {
351
+ if (!id.startsWith(IMPORT_MAP_PREFIX)) return
352
+ const pkg = id.slice(IMPORT_MAP_PREFIX.length)
353
+ // Dynamically discover exports at build time by importing the package.
354
+ // We generate explicit named re-exports (not `export *`) because CJS
355
+ // packages like React only expose a default via `export *`, losing
356
+ // individual named exports (useState, jsx, etc.) that foundations need.
357
+ try {
358
+ const mod = await import(pkg)
359
+ const names = Object.keys(mod).filter(k => k !== '__esModule')
360
+ const hasDefault = 'default' in mod
361
+ const named = names.filter(k => k !== 'default')
362
+ const lines = []
363
+ if (named.length) {
364
+ lines.push(`export { ${named.join(', ')} } from '${pkg}'`)
365
+ }
366
+ if (hasDefault) {
367
+ lines.push(`export { default } from '${pkg}'`)
368
+ }
369
+ return lines.join('\n') || `export {}`
370
+ } catch {
371
+ // Fallback: generic re-export (may not preserve named exports for CJS)
372
+ return `export * from '${pkg}'`
373
+ }
374
+ },
375
+
376
+ // Emit deterministic chunks for each external (production only).
377
+ // preserveSignature: 'exports-only' tells Rollup to preserve the original
378
+ // export names (useState, jsx, etc.) instead of mangling them.
379
+ // In dev mode, Vite's transformRequest() resolves bare specifiers instead.
380
+ buildStart() {
381
+ if (!isBuild) return
382
+ for (const ext of IMPORT_MAP_EXTERNALS) {
383
+ this.emitFile({
384
+ type: 'chunk',
385
+ id: `${IMPORT_MAP_PREFIX}${ext}`,
386
+ fileName: `_importmap/${ext.replace(/\//g, '-')}.js`,
387
+ preserveSignature: 'exports-only'
388
+ })
389
+ }
390
+ },
391
+
392
+ // Inject the import map into the HTML (production only).
393
+ // In dev mode, Vite's transformRequest() handles bare specifier resolution.
394
+ transformIndexHtml: {
395
+ order: 'pre',
396
+ handler(html) {
397
+ if (!isBuild) return html
398
+ const basePath = base || '/'
399
+ const imports = {}
400
+ for (const ext of IMPORT_MAP_EXTERNALS) {
401
+ imports[ext] = `${basePath}_importmap/${ext.replace(/\//g, '-')}.js`
402
+ }
403
+ const importMap = JSON.stringify({ imports }, null, 2)
404
+ const script = ` <script type="importmap">\n${importMap}\n </script>\n`
405
+ // Import map must appear before any module scripts
406
+ return html.replace('<head>', '<head>\n' + script)
407
+ }
408
+ }
409
+ }
410
+ })() : null
411
+
412
+ if (importMapPlugin) plugins.push(importMapPlugin)
413
+
299
414
  // Build foundation config for runtime
300
415
  const foundationConfig = {
301
416
  mode: isRuntimeMode ? 'runtime' : 'bundled',
@@ -311,7 +426,7 @@ export async function defineSiteConfig(options = {}) {
311
426
  plugins,
312
427
 
313
428
  define: {
314
- __FOUNDATION_CONFIG__: JSON.stringify(foundationConfig)
429
+ __FOUNDATION_CONFIG__: isShellMode ? 'null' : JSON.stringify(foundationConfig)
315
430
  },
316
431
 
317
432
  resolve: {
@@ -185,6 +185,33 @@ function isMarkdownFile(filename) {
185
185
  return filename.endsWith('.md') && !filename.startsWith('_')
186
186
  }
187
187
 
188
+ /**
189
+ * Read folder configuration, determining content mode from config file presence.
190
+ *
191
+ * - folder.yml present → pages mode (md files are child pages)
192
+ * - page.yml present → sections mode (md files are sections of this page)
193
+ * - Neither → inherit mode from parent
194
+ *
195
+ * @param {string} dirPath - Directory path
196
+ * @param {string} inheritedMode - Mode inherited from parent ('sections' or 'pages')
197
+ * @returns {Promise<{config: Object, mode: string, source: string}>}
198
+ */
199
+ async function readFolderConfig(dirPath, inheritedMode) {
200
+ const folderYml = await readYamlFile(join(dirPath, 'folder.yml'))
201
+ if (Object.keys(folderYml).length > 0) {
202
+ return { config: folderYml, mode: 'pages', source: 'folder.yml' }
203
+ }
204
+ const pageYml = await readYamlFile(join(dirPath, 'page.yml'))
205
+ if (Object.keys(pageYml).length > 0) {
206
+ return { config: pageYml, mode: 'sections', source: 'page.yml' }
207
+ }
208
+ // Check for empty folder.yml (presence signals pages mode even if empty)
209
+ if (existsSync(join(dirPath, 'folder.yml'))) {
210
+ return { config: {}, mode: 'pages', source: 'folder.yml' }
211
+ }
212
+ return { config: {}, mode: inheritedMode, source: 'inherited' }
213
+ }
214
+
188
215
  /**
189
216
  * Parse numeric prefix from filename (e.g., "1-hero.md" → { prefix: "1", name: "hero" })
190
217
  * Supports:
@@ -226,6 +253,89 @@ function compareFilenames(a, b) {
226
253
  return 0
227
254
  }
228
255
 
256
+ /**
257
+ * Apply non-strict ordering to a list of items.
258
+ * Listed items appear first in array order, then unlisted items in their existing order.
259
+ *
260
+ * Unlike strict arrays (pages: [...], sections: [...]) which hide unlisted items,
261
+ * this preserves all items — it only affects order.
262
+ *
263
+ * @param {Array} items - Items with a .name property
264
+ * @param {Array<string>} orderArray - Names in desired order
265
+ * @returns {Array} Reordered items (all items preserved)
266
+ */
267
+ function applyNonStrictOrder(items, orderArray) {
268
+ if (!Array.isArray(orderArray) || orderArray.length === 0) return items
269
+ const orderMap = new Map(orderArray.map((name, i) => [name, i]))
270
+ const listed = items.filter(i => orderMap.has(i.name))
271
+ .sort((a, b) => orderMap.get(a.name) - orderMap.get(b.name))
272
+ const unlisted = items.filter(i => !orderMap.has(i.name))
273
+ return [...listed, ...unlisted]
274
+ }
275
+
276
+ /**
277
+ * Process a markdown file as a standalone page (pages mode).
278
+ * Creates a page with a single section from the markdown content.
279
+ *
280
+ * @param {string} filePath - Path to markdown file
281
+ * @param {string} fileName - Filename (e.g., "getting-started.md")
282
+ * @param {string} siteRoot - Site root directory for asset resolution
283
+ * @param {string} parentRoute - Parent route (e.g., '/docs')
284
+ * @returns {Promise<Object>} Page data with assets manifest
285
+ */
286
+ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
287
+ const { name } = parse(fileName)
288
+ const { prefix, name: stableName } = parseNumericPrefix(name)
289
+ const pageName = stableName || name
290
+ const route = parentRoute === '/' ? `/${pageName}` : `${parentRoute}/${pageName}`
291
+
292
+ // Process the markdown as a single section
293
+ const { section, assetCollection, iconCollection } = await processMarkdownFile(
294
+ filePath, '1', siteRoot, pageName
295
+ )
296
+
297
+ const fileStat = await stat(filePath)
298
+
299
+ return {
300
+ page: {
301
+ route,
302
+ sourcePath: null,
303
+ id: null,
304
+ isIndex: false,
305
+ title: pageName,
306
+ description: '',
307
+ label: null,
308
+ lastModified: fileStat.mtime?.toISOString() || null,
309
+ isDynamic: false,
310
+ paramName: null,
311
+ parentSchema: null,
312
+ version: null,
313
+ versionMeta: null,
314
+ versionScope: null,
315
+ hidden: false,
316
+ hideInHeader: false,
317
+ hideInFooter: false,
318
+ layout: {
319
+ header: true,
320
+ footer: true,
321
+ leftPanel: true,
322
+ rightPanel: true
323
+ },
324
+ seo: {
325
+ noindex: false,
326
+ image: null,
327
+ changefreq: null,
328
+ priority: null
329
+ },
330
+ fetch: null,
331
+ sections: [section],
332
+ order: prefix ? parseFloat(prefix) : undefined
333
+ },
334
+ assetCollection,
335
+ iconCollection
336
+ }
337
+ }
338
+
229
339
  /**
230
340
  * Process a markdown file into a section
231
341
  *
@@ -629,12 +739,13 @@ function determineIndexPage(orderConfig, availableFolders) {
629
739
  * @param {string} dirPath - Directory to scan
630
740
  * @param {string} parentRoute - Parent route (e.g., '/' or '/docs')
631
741
  * @param {string} siteRoot - Site root directory for asset resolution
632
- * @param {Object} orderConfig - { pages: [...], index: 'name' } from parent's config
742
+ * @param {Object} orderConfig - { pages: [...], index: 'name', order: [...] } from parent's config
633
743
  * @param {Object} parentFetch - Parent page's fetch config (for dynamic child routes)
634
744
  * @param {Object} versionContext - Version context from parent { version, versionMeta }
635
- * @returns {Promise<Object>} { pages, assetCollection, iconCollection, header, footer, left, right, notFound, versionedScopes }
745
+ * @param {string} contentMode - 'sections' (default) or 'pages' (md files are child pages)
746
+ * @returns {Promise<Object>} { pages, assetCollection, iconCollection, notFound, versionedScopes }
636
747
  */
637
- async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null) {
748
+ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig = {}, parentFetch = null, versionContext = null, contentMode = 'sections') {
638
749
  const entries = await readdir(dirPath)
639
750
  const pages = []
640
751
  let assetCollection = {
@@ -649,23 +760,28 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
649
760
  let notFound = null
650
761
  const versionedScopes = new Map() // scope route → versionMeta
651
762
 
652
- // First pass: discover all page folders and read their order values
763
+ // First pass: discover all page folders and read their config
653
764
  const pageFolders = []
654
765
  for (const entry of entries) {
655
766
  const entryPath = join(dirPath, entry)
656
767
  const stats = await stat(entryPath)
657
768
  if (!stats.isDirectory()) continue
658
769
 
659
- // Read page.yml to get order and child page config
660
- const pageConfig = await readYamlFile(join(entryPath, 'page.yml'))
770
+ // Read folder.yml or page.yml to determine mode and get config
771
+ const { config: dirConfig, mode: dirMode } = await readFolderConfig(entryPath, contentMode)
772
+ const numericOrder = typeof dirConfig.order === 'number' ? dirConfig.order : undefined
773
+ const childOrderArray = Array.isArray(dirConfig.order) ? dirConfig.order : undefined
774
+
661
775
  pageFolders.push({
662
776
  name: entry,
663
777
  path: entryPath,
664
- order: pageConfig.order,
665
- pageConfig,
778
+ order: numericOrder,
779
+ dirConfig,
780
+ dirMode,
666
781
  childOrderConfig: {
667
- pages: pageConfig.pages,
668
- index: pageConfig.index
782
+ pages: dirConfig.pages,
783
+ index: dirConfig.index,
784
+ order: childOrderArray
669
785
  }
670
786
  })
671
787
  }
@@ -679,67 +795,46 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
679
795
  return a.name.localeCompare(b.name)
680
796
  })
681
797
 
798
+ // Apply non-strict order from parent config (if present)
799
+ const orderedFolders = applyNonStrictOrder(pageFolders, orderConfig?.order)
800
+
682
801
  // Check if this directory contains version folders (versioned section)
683
- const folderNames = pageFolders.map(f => f.name)
802
+ const folderNames = orderedFolders.map(f => f.name)
684
803
  const detectedVersions = detectVersions(folderNames)
685
804
 
686
- // If versioned section, handle version folders specially
805
+ // If versioned section, handle version folders specially (always sections mode)
687
806
  if (detectedVersions && !versionContext) {
688
- // Read parent page.yml for version metadata
689
807
  const parentConfig = await readYamlFile(join(dirPath, 'page.yml'))
690
808
  const versionMeta = buildVersionMetadata(detectedVersions, parentConfig)
691
-
692
- // Record this versioned scope
693
809
  versionedScopes.set(parentRoute, versionMeta)
694
810
 
695
- // Process version folders
696
- for (const folder of pageFolders) {
697
- const { name: entry, path: entryPath, childOrderConfig, pageConfig } = folder
811
+ for (const folder of orderedFolders) {
812
+ const { name: entry, path: entryPath, childOrderConfig } = folder
698
813
 
699
814
  if (isVersionFolder(entry)) {
700
- // This is a version folder
701
815
  const versionInfo = versionMeta.versions.find(v => v.id === entry)
702
816
  const isLatest = versionInfo?.latest || false
703
-
704
- // For latest version, use parent route directly
705
- // For other versions, add version prefix to route
706
- // Handle root scope specially to avoid double slash (//v1 → /v1)
707
817
  const versionRoute = isLatest
708
818
  ? parentRoute
709
819
  : parentRoute === '/'
710
820
  ? `/${entry}`
711
821
  : `${parentRoute}/${entry}`
712
822
 
713
- // Recurse into version folder with version context
714
823
  const subResult = await collectPagesRecursive(
715
- entryPath,
716
- versionRoute,
717
- siteRoot,
718
- childOrderConfig,
719
- parentFetch,
720
- {
721
- version: versionInfo,
722
- versionMeta,
723
- scope: parentRoute // The route where versioning is scoped
724
- }
824
+ entryPath, versionRoute, siteRoot, childOrderConfig, parentFetch,
825
+ { version: versionInfo, versionMeta, scope: parentRoute }
725
826
  )
726
827
 
727
828
  pages.push(...subResult.pages)
728
829
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
729
830
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
730
- // Merge any nested versioned scopes (shouldn't happen often, but possible)
731
831
  for (const [scope, meta] of subResult.versionedScopes) {
732
832
  versionedScopes.set(scope, meta)
733
833
  }
734
834
  } else {
735
- // Non-version folders in a versioned section
736
- // These could be shared across versions - process normally
737
835
  const result = await processPage(entryPath, entry, siteRoot, {
738
- isIndex: false,
739
- parentRoute,
740
- parentFetch
836
+ isIndex: false, parentRoute, parentFetch
741
837
  })
742
-
743
838
  if (result) {
744
839
  pages.push(result.page)
745
840
  assetCollection = mergeAssetCollections(assetCollection, result.assetCollection)
@@ -748,67 +843,235 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
748
843
  }
749
844
  }
750
845
 
751
- // Return early - we've handled all children
752
846
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
753
847
  }
754
848
 
755
- // Determine which page is the index for this level
756
- // A directory with its own .md content is a real page, not a container —
757
- // never promote a child as index, even if explicit config says so
758
- // (that config is likely a leftover from before the directory had content)
759
- const regularFolders = pageFolders
760
- const hasExplicitOrder = orderConfig?.index || (Array.isArray(orderConfig?.pages) && orderConfig.pages.length > 0)
761
- const hasMdContent = entries.some(e => isMarkdownFile(e))
762
- const indexPageName = hasMdContent ? null : determineIndexPage(orderConfig, regularFolders)
849
+ // --- Pages mode: .md files are child pages ---
850
+ if (contentMode === 'pages') {
851
+ // Collect and process .md files as individual pages
852
+ const mdFiles = entries.filter(isMarkdownFile).sort(compareFilenames)
853
+ const mdPageItems = []
763
854
 
764
- // Second pass: process each page folder
765
- for (const folder of pageFolders) {
766
- const { name: entry, path: entryPath, childOrderConfig } = folder
767
- const isIndex = entry === indexPageName
855
+ for (const file of mdFiles) {
856
+ const { name } = parse(file)
857
+ const { name: stableName } = parseNumericPrefix(name)
858
+ const result = await processFileAsPage(join(dirPath, file), file, siteRoot, parentRoute)
859
+ if (result) {
860
+ mdPageItems.push({ name: stableName || name, result })
861
+ }
862
+ }
768
863
 
769
- // Process this directory as a page
770
- // Pass parentFetch so dynamic routes can inherit parent's data schema
771
- const result = await processPage(entryPath, entry, siteRoot, {
772
- isIndex,
773
- parentRoute,
774
- parentFetch,
775
- versionContext
776
- })
864
+ // Apply non-strict order to md-file-pages
865
+ const orderedMdPages = applyNonStrictOrder(mdPageItems, orderConfig?.order)
777
866
 
778
- if (result) {
867
+ // In pages mode, only promote an index if explicitly set via index: in folder.yml
868
+ // The container page itself owns the parent route — don't auto-promote children
869
+ const indexName = orderConfig?.index || null
870
+
871
+ // Add md-file-pages
872
+ for (const { name, result } of orderedMdPages) {
779
873
  const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
780
874
  assetCollection = mergeAssetCollections(assetCollection, pageAssets)
781
875
  iconCollection = mergeIconCollections(iconCollection, pageIcons)
782
876
 
783
- // Handle 404 page - only at root level
784
- if (parentRoute === '/' && entry === '404') {
785
- notFound = page
786
- } else {
787
- pages.push(page)
877
+ // Handle index: promote to parent route
878
+ if (name === indexName) {
879
+ page.isIndex = true
880
+ page.sourcePath = page.route
881
+ page.route = parentRoute
788
882
  }
789
883
 
790
- // Recursively process subdirectories
791
- {
792
- // The child route depends on whether this page is the index
793
- // For explicit index (from site.yml `index:` or `pages:`), children use parentRoute
794
- // since that's a true structural promotion. For auto-detected index, children use
795
- // the page's original folder path so they nest correctly under it.
796
- const childParentRoute = isIndex
797
- ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
798
- : page.route
799
- // Pass this page's fetch config to children (for dynamic routes that inherit parent data)
800
- const childFetch = page.fetch || parentFetch
801
- // Pass version context to children (maintains version scope)
802
- const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext)
884
+ pages.push(page)
885
+ }
886
+
887
+ // Process subdirectories
888
+ for (const folder of orderedFolders) {
889
+ const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
890
+ const isIndex = entry === indexName
891
+
892
+ if (dirMode === 'sections') {
893
+ // Subdirectory overrides to sections mode process normally
894
+ const result = await processPage(entryPath, entry, siteRoot, {
895
+ isIndex, parentRoute, parentFetch, versionContext
896
+ })
897
+
898
+ if (result) {
899
+ const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
900
+ assetCollection = mergeAssetCollections(assetCollection, pageAssets)
901
+ iconCollection = mergeIconCollections(iconCollection, pageIcons)
902
+ pages.push(page)
903
+
904
+ // Recurse into subdirectories (sections mode)
905
+ const childParentRoute = isIndex ? parentRoute : page.route
906
+ const childFetch = page.fetch || parentFetch
907
+ const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, 'sections')
908
+ pages.push(...subResult.pages)
909
+ assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
910
+ iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
911
+ for (const [scope, meta] of subResult.versionedScopes) {
912
+ versionedScopes.set(scope, meta)
913
+ }
914
+ }
915
+ } else {
916
+ // Container directory in pages mode — create minimal page, recurse
917
+ const containerRoute = isIndex
918
+ ? parentRoute
919
+ : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
920
+
921
+ const containerPage = {
922
+ route: containerRoute,
923
+ sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
924
+ id: dirConfig.id || null,
925
+ isIndex,
926
+ title: dirConfig.title || entry,
927
+ description: dirConfig.description || '',
928
+ label: dirConfig.label || null,
929
+ lastModified: null,
930
+ isDynamic: false,
931
+ paramName: null,
932
+ parentSchema: null,
933
+ version: versionContext?.version || null,
934
+ versionMeta: versionContext?.versionMeta || null,
935
+ versionScope: versionContext?.scope || null,
936
+ hidden: dirConfig.hidden || false,
937
+ hideInHeader: dirConfig.hideInHeader || false,
938
+ hideInFooter: dirConfig.hideInFooter || false,
939
+ layout: {
940
+ header: dirConfig.layout?.header !== false,
941
+ footer: dirConfig.layout?.footer !== false,
942
+ leftPanel: dirConfig.layout?.leftPanel !== false,
943
+ rightPanel: dirConfig.layout?.rightPanel !== false
944
+ },
945
+ seo: {
946
+ noindex: dirConfig.seo?.noindex || false,
947
+ image: dirConfig.seo?.image || null,
948
+ changefreq: dirConfig.seo?.changefreq || null,
949
+ priority: dirConfig.seo?.priority || null
950
+ },
951
+ fetch: null,
952
+ sections: [],
953
+ order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
954
+ }
955
+
956
+ pages.push(containerPage)
957
+
958
+ // Recurse in pages mode
959
+ const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
803
960
  pages.push(...subResult.pages)
804
961
  assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
805
962
  iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
806
- // Merge any versioned scopes from children
807
963
  for (const [scope, meta] of subResult.versionedScopes) {
808
964
  versionedScopes.set(scope, meta)
809
965
  }
810
966
  }
811
967
  }
968
+
969
+ return { pages, assetCollection, iconCollection, notFound, versionedScopes }
970
+ }
971
+
972
+ // --- Sections mode (default): existing behavior ---
973
+
974
+ // Determine which page is the index for this level
975
+ // A directory with its own .md content is a real page, not a container —
976
+ // never promote a child as index, even if explicit config says so
977
+ const hasExplicitOrder = orderConfig?.index || (Array.isArray(orderConfig?.pages) && orderConfig.pages.length > 0)
978
+ const hasMdContent = entries.some(e => isMarkdownFile(e))
979
+ const indexPageName = hasMdContent ? null : determineIndexPage(orderConfig, orderedFolders)
980
+
981
+ // Second pass: process each page folder
982
+ for (const folder of orderedFolders) {
983
+ const { name: entry, path: entryPath, dirConfig, dirMode, childOrderConfig } = folder
984
+ const isIndex = entry === indexPageName
985
+
986
+ if (dirMode === 'pages') {
987
+ // Child directory switches to pages mode (has folder.yml) —
988
+ // create container page with empty sections, recurse in pages mode
989
+ const containerRoute = isIndex
990
+ ? parentRoute
991
+ : parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`
992
+
993
+ const containerPage = {
994
+ route: containerRoute,
995
+ sourcePath: isIndex ? (parentRoute === '/' ? `/${entry}` : `${parentRoute}/${entry}`) : null,
996
+ id: dirConfig.id || null,
997
+ isIndex,
998
+ title: dirConfig.title || entry,
999
+ description: dirConfig.description || '',
1000
+ label: dirConfig.label || null,
1001
+ lastModified: null,
1002
+ isDynamic: false,
1003
+ paramName: null,
1004
+ parentSchema: null,
1005
+ version: versionContext?.version || null,
1006
+ versionMeta: versionContext?.versionMeta || null,
1007
+ versionScope: versionContext?.scope || null,
1008
+ hidden: dirConfig.hidden || false,
1009
+ hideInHeader: dirConfig.hideInHeader || false,
1010
+ hideInFooter: dirConfig.hideInFooter || false,
1011
+ layout: {
1012
+ header: dirConfig.layout?.header !== false,
1013
+ footer: dirConfig.layout?.footer !== false,
1014
+ leftPanel: dirConfig.layout?.leftPanel !== false,
1015
+ rightPanel: dirConfig.layout?.rightPanel !== false
1016
+ },
1017
+ seo: {
1018
+ noindex: dirConfig.seo?.noindex || false,
1019
+ image: dirConfig.seo?.image || null,
1020
+ changefreq: dirConfig.seo?.changefreq || null,
1021
+ priority: dirConfig.seo?.priority || null
1022
+ },
1023
+ fetch: null,
1024
+ sections: [],
1025
+ order: typeof dirConfig.order === 'number' ? dirConfig.order : undefined
1026
+ }
1027
+
1028
+ if (parentRoute === '/' && entry === '404') {
1029
+ notFound = containerPage
1030
+ } else {
1031
+ pages.push(containerPage)
1032
+ }
1033
+
1034
+ const subResult = await collectPagesRecursive(entryPath, containerRoute, siteRoot, childOrderConfig, parentFetch, versionContext, 'pages')
1035
+ pages.push(...subResult.pages)
1036
+ assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1037
+ iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
1038
+ for (const [scope, meta] of subResult.versionedScopes) {
1039
+ versionedScopes.set(scope, meta)
1040
+ }
1041
+ } else {
1042
+ // Sections mode — process directory as a page (existing behavior)
1043
+ const result = await processPage(entryPath, entry, siteRoot, {
1044
+ isIndex, parentRoute, parentFetch, versionContext
1045
+ })
1046
+
1047
+ if (result) {
1048
+ const { page, assetCollection: pageAssets, iconCollection: pageIcons } = result
1049
+ assetCollection = mergeAssetCollections(assetCollection, pageAssets)
1050
+ iconCollection = mergeIconCollections(iconCollection, pageIcons)
1051
+
1052
+ // Handle 404 page - only at root level
1053
+ if (parentRoute === '/' && entry === '404') {
1054
+ notFound = page
1055
+ } else {
1056
+ pages.push(page)
1057
+ }
1058
+
1059
+ // Recursively process subdirectories
1060
+ {
1061
+ const childParentRoute = isIndex
1062
+ ? (hasExplicitOrder ? parentRoute : (page.sourcePath || page.route))
1063
+ : page.route
1064
+ const childFetch = page.fetch || parentFetch
1065
+ const subResult = await collectPagesRecursive(entryPath, childParentRoute, siteRoot, childOrderConfig, childFetch, versionContext, dirMode)
1066
+ pages.push(...subResult.pages)
1067
+ assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
1068
+ iconCollection = mergeIconCollections(iconCollection, subResult.iconCollection)
1069
+ for (const [scope, meta] of subResult.versionedScopes) {
1070
+ versionedScopes.set(scope, meta)
1071
+ }
1072
+ }
1073
+ }
1074
+ }
812
1075
  }
813
1076
 
814
1077
  return { pages, assetCollection, iconCollection, notFound, versionedScopes }
@@ -930,15 +1193,19 @@ export async function collectSiteContent(sitePath, options = {}) {
930
1193
  // Extract page ordering config from site.yml
931
1194
  const siteOrderConfig = {
932
1195
  pages: siteConfig.pages,
933
- index: siteConfig.index
1196
+ index: siteConfig.index,
1197
+ order: Array.isArray(siteConfig.order) ? siteConfig.order : undefined
934
1198
  }
935
1199
 
1200
+ // Determine root content mode from folder.yml/page.yml presence in pages directory
1201
+ const { mode: rootContentMode } = await readFolderConfig(pagesPath, 'sections')
1202
+
936
1203
  // Collect layout panels from layout/ directory
937
1204
  const { header, footer, left, right } = await collectLayoutPanels(layoutPath, sitePath)
938
1205
 
939
1206
  // Recursively collect all pages
940
1207
  const { pages, assetCollection, iconCollection, notFound, versionedScopes } =
941
- await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig)
1208
+ await collectPagesRecursive(pagesPath, '/', sitePath, siteOrderConfig, null, null, rootContentMode)
942
1209
 
943
1210
  // Deduplicate: remove content-less container pages whose route duplicates
944
1211
  // a content-bearing page (e.g., a promoted index page)
@@ -338,6 +338,7 @@ export function siteContentPlugin(options = {}) {
338
338
  pagesDir = 'pages',
339
339
  variableName = '__SITE_CONTENT__',
340
340
  inject = true,
341
+ shell = false,
341
342
  filename = 'site-content.json',
342
343
  watch: shouldWatch = true,
343
344
  seo = {},
@@ -379,6 +380,7 @@ export function siteContentPlugin(options = {}) {
379
380
  let resolvedPagesPath = null // Resolved from site.yml pagesDir or default
380
381
  let resolvedLayoutPath = null // Resolved from site.yml layoutDir or default
381
382
  let resolvedCollectionsBase = null // Resolved from site.yml collectionsDir
383
+ let headHtml = '' // Contents of site/head.html for injection
382
384
 
383
385
  /**
384
386
  * Load translations for a specific locale
@@ -426,6 +428,18 @@ export function siteContentPlugin(options = {}) {
426
428
  }
427
429
  }
428
430
 
431
+ /**
432
+ * Read head.html from site root (if it exists)
433
+ */
434
+ async function loadHeadHtml() {
435
+ const headPath = resolve(resolvedSitePath, 'head.html')
436
+ try {
437
+ return await readFile(headPath, 'utf-8')
438
+ } catch {
439
+ return ''
440
+ }
441
+ }
442
+
429
443
  /**
430
444
  * Get available locales from locales directory
431
445
  */
@@ -532,6 +546,7 @@ export function siteContentPlugin(options = {}) {
532
546
  // Collect content at build start
533
547
  try {
534
548
  siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
549
+ headHtml = await loadHeadHtml()
535
550
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
536
551
 
537
552
  // Process content collections if defined in site.yml
@@ -579,6 +594,7 @@ export function siteContentPlugin(options = {}) {
579
594
  console.log('[site-content] Content changed, rebuilding...')
580
595
  try {
581
596
  siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
597
+ headHtml = await loadHeadHtml()
582
598
  // Execute fetches for the updated content
583
599
  await executeDevFetches(siteContent, resolvedSitePath)
584
600
  console.log(`[site-content] Rebuilt ${siteContent.pages?.length || 0} pages`)
@@ -649,6 +665,14 @@ export function siteContentPlugin(options = {}) {
649
665
  // theme.yml may not exist, that's ok
650
666
  }
651
667
 
668
+ // Watch head.html
669
+ const headHtmlPath = resolve(resolvedSitePath, 'head.html')
670
+ try {
671
+ watchers.push(watch(headHtmlPath, scheduleRebuild))
672
+ } catch {
673
+ // head.html may not exist, that's ok
674
+ }
675
+
652
676
  // Watch content/ folder for collection changes
653
677
  // Use collectionsConfig cached from configResolved (siteContent may be null here)
654
678
  if (collectionsConfig) {
@@ -849,6 +873,9 @@ export function siteContentPlugin(options = {}) {
849
873
  async transformIndexHtml(html, ctx) {
850
874
  if (!siteContent) return html
851
875
 
876
+ // Shell mode: skip all HTML injections — backend provides __DATA__ at serve time
877
+ if (shell) return html
878
+
852
879
  // Detect locale from URL (e.g., /es/about → 'es')
853
880
  let contentToInject = siteContent
854
881
  let activeLocale = null
@@ -872,6 +899,29 @@ export function siteContentPlugin(options = {}) {
872
899
 
873
900
  let headInjection = ''
874
901
 
902
+ // Inject user's head.html (analytics, third-party scripts)
903
+ if (headHtml) {
904
+ headInjection += headHtml + '\n'
905
+ }
906
+
907
+ // Inject font preconnect links (before theme CSS so browser starts DNS early)
908
+ const fontImports = contentToInject.theme?.fonts?.import
909
+ if (Array.isArray(fontImports) && fontImports.length > 0) {
910
+ const origins = new Set()
911
+ for (const font of fontImports) {
912
+ if (font.url) {
913
+ try { origins.add(new URL(font.url).origin) } catch {}
914
+ }
915
+ }
916
+ for (const origin of origins) {
917
+ headInjection += ` <link rel="preconnect" href="${origin}">\n`
918
+ }
919
+ // Google Fonts serves CSS from googleapis.com but font files from gstatic.com
920
+ if (origins.has('https://fonts.googleapis.com')) {
921
+ headInjection += ` <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n`
922
+ }
923
+ }
924
+
875
925
  // Inject theme CSS
876
926
  if (contentToInject.theme?.css) {
877
927
  headInjection += ` <style id="uniweb-theme">\n${contentToInject.theme.css}\n </style>\n`