@uniweb/build 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prerender.js CHANGED
@@ -9,25 +9,44 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
9
9
  import { existsSync } from 'node:fs'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { pathToFileURL } from 'node:url'
12
+ import { createRequire } from 'node:module'
12
13
 
13
14
  // Lazily loaded dependencies (ESM with React)
14
15
  let React, renderToString, createUniweb
15
16
 
16
17
  /**
17
- * Load dependencies dynamically
18
- * These are ESM modules that may not be available at import time
18
+ * Load dependencies dynamically from the site's context
19
+ * This ensures we use the same React instance as the foundation
20
+ *
21
+ * @param {string} siteDir - Path to the site directory
19
22
  */
20
- async function loadDependencies() {
23
+ async function loadDependencies(siteDir) {
21
24
  if (React) return // Already loaded
22
25
 
23
- const [reactMod, serverMod, coreMod] = await Promise.all([
24
- import('react'),
25
- import('react-dom/server'),
26
- import('@uniweb/core')
27
- ])
26
+ // Create a require function that resolves from the site's perspective
27
+ // This ensures we get the same React instance that the foundation uses
28
+ const siteRequire = createRequire(join(siteDir, 'package.json'))
29
+
30
+ try {
31
+ // Try to load React from site's node_modules
32
+ const reactMod = siteRequire('react')
33
+ const serverMod = siteRequire('react-dom/server')
34
+
35
+ React = reactMod.default || reactMod
36
+ renderToString = serverMod.renderToString
37
+ } catch {
38
+ // Fallback to dynamic import if require fails
39
+ const [reactMod, serverMod] = await Promise.all([
40
+ import('react'),
41
+ import('react-dom/server')
42
+ ])
43
+
44
+ React = reactMod.default || reactMod
45
+ renderToString = serverMod.renderToString
46
+ }
28
47
 
29
- React = reactMod.default || reactMod
30
- renderToString = serverMod.renderToString
48
+ // @uniweb/core can be imported normally
49
+ const coreMod = await import('@uniweb/core')
31
50
  createUniweb = coreMod.createUniweb
32
51
  }
33
52
 
@@ -53,9 +72,9 @@ export async function prerenderSite(siteDir, options = {}) {
53
72
  throw new Error(`Site must be built first. No dist directory found at: ${distDir}`)
54
73
  }
55
74
 
56
- // Load dependencies
75
+ // Load dependencies from site's context (ensures same React instance as foundation)
57
76
  onProgress('Loading dependencies...')
58
- await loadDependencies()
77
+ await loadDependencies(siteDir)
59
78
 
60
79
  // Load site content
61
80
  onProgress('Loading site content...')
@@ -140,22 +159,93 @@ export async function prerenderSite(siteDir, options = {}) {
140
159
  }
141
160
 
142
161
  /**
143
- * Minimal page renderer for SSG
144
- * Renders blocks using foundation components
162
+ * Render an array of blocks
163
+ */
164
+ function BlocksRenderer({ blocks, foundation }) {
165
+ if (!blocks || blocks.length === 0) return null
166
+
167
+ return blocks.map((block, index) =>
168
+ React.createElement(BlockRenderer, {
169
+ key: block.id || index,
170
+ block,
171
+ foundation
172
+ })
173
+ )
174
+ }
175
+
176
+ /**
177
+ * Default layout - renders header, body, footer in sequence
178
+ */
179
+ function DefaultLayout({ header, body, footer }) {
180
+ return React.createElement(React.Fragment, null, header, body, footer)
181
+ }
182
+
183
+ /**
184
+ * Layout component for SSG
185
+ * Supports foundation-provided custom Layout via site.Layout
186
+ */
187
+ function Layout({ page, website, foundation }) {
188
+ // Check if foundation provides a custom Layout
189
+ const RemoteLayout = foundation.site?.Layout || null
190
+
191
+ // Get block groups from page
192
+ const headerBlocks = page.getHeaderBlocks()
193
+ const bodyBlocks = page.getBodyBlocks()
194
+ const footerBlocks = page.getFooterBlocks()
195
+ const leftBlocks = page.getLeftBlocks()
196
+ const rightBlocks = page.getRightBlocks()
197
+
198
+ // Pre-render each area
199
+ const headerElement = headerBlocks
200
+ ? React.createElement(BlocksRenderer, { blocks: headerBlocks, foundation })
201
+ : null
202
+ const bodyElement = bodyBlocks
203
+ ? React.createElement(BlocksRenderer, { blocks: bodyBlocks, foundation })
204
+ : null
205
+ const footerElement = footerBlocks
206
+ ? React.createElement(BlocksRenderer, { blocks: footerBlocks, foundation })
207
+ : null
208
+ const leftElement = leftBlocks
209
+ ? React.createElement(BlocksRenderer, { blocks: leftBlocks, foundation })
210
+ : null
211
+ const rightElement = rightBlocks
212
+ ? React.createElement(BlocksRenderer, { blocks: rightBlocks, foundation })
213
+ : null
214
+
215
+ // Use foundation's custom Layout if provided
216
+ if (RemoteLayout) {
217
+ return React.createElement(RemoteLayout, {
218
+ page,
219
+ website,
220
+ header: headerElement,
221
+ body: bodyElement,
222
+ footer: footerElement,
223
+ left: leftElement,
224
+ right: rightElement,
225
+ leftPanel: leftElement,
226
+ rightPanel: rightElement
227
+ })
228
+ }
229
+
230
+ // Default layout
231
+ return React.createElement(DefaultLayout, {
232
+ header: headerElement,
233
+ body: bodyElement,
234
+ footer: footerElement
235
+ })
236
+ }
237
+
238
+ /**
239
+ * Page renderer for SSG
240
+ * Uses Layout component for proper orchestration of layout areas
145
241
  */
146
242
  function PageRenderer({ page, foundation }) {
147
- const blocks = page.getPageBlocks()
243
+ const website = globalThis.uniweb?.activeWebsite
148
244
 
149
245
  return React.createElement(
150
246
  'main',
151
247
  null,
152
- blocks.map((block, index) =>
153
- React.createElement(BlockRenderer, {
154
- key: block.id || index,
155
- block,
156
- foundation
157
- })
158
- )
248
+ React.createElement(Layout, { page, website, foundation })
159
249
  )
160
250
  }
161
251
 
@@ -188,6 +188,91 @@ function buildSectionHierarchy(sections) {
188
188
  return topLevel
189
189
  }
190
190
 
191
+ /**
192
+ * Process explicit sections array from page.yml
193
+ * Supports nested structure for subsections:
194
+ * sections:
195
+ * - hero
196
+ * - features:
197
+ * - logocloud
198
+ * - stats
199
+ * - pricing
200
+ *
201
+ * @param {Array} sectionsConfig - Sections array from page.yml
202
+ * @param {string} pagePath - Path to page directory
203
+ * @param {string} siteRoot - Site root for asset resolution
204
+ * @param {string} parentId - Parent section ID for building hierarchy
205
+ * @returns {Object} { sections, assetCollection, lastModified }
206
+ */
207
+ async function processExplicitSections(sectionsConfig, pagePath, siteRoot, parentId = '') {
208
+ const sections = []
209
+ let assetCollection = {
210
+ assets: {},
211
+ hasExplicitPoster: new Set(),
212
+ hasExplicitPreview: new Set()
213
+ }
214
+ let lastModified = null
215
+
216
+ let index = 1
217
+ for (const item of sectionsConfig) {
218
+ let sectionName
219
+ let subsections = null
220
+
221
+ if (typeof item === 'string') {
222
+ // Simple section: "hero"
223
+ sectionName = item
224
+ } else if (typeof item === 'object' && item !== null) {
225
+ // Section with subsections: { features: [logocloud, stats] }
226
+ const keys = Object.keys(item)
227
+ if (keys.length === 1) {
228
+ sectionName = keys[0]
229
+ subsections = item[sectionName]
230
+ } else {
231
+ console.warn(`[content-collector] Invalid section entry:`, item)
232
+ continue
233
+ }
234
+ } else {
235
+ continue
236
+ }
237
+
238
+ // Build section ID
239
+ const id = parentId ? `${parentId}.${index}` : String(index)
240
+
241
+ // Look for the markdown file
242
+ const filePath = join(pagePath, `${sectionName}.md`)
243
+ if (!existsSync(filePath)) {
244
+ console.warn(`[content-collector] Section file not found: ${sectionName}.md`)
245
+ index++
246
+ continue
247
+ }
248
+
249
+ // Process the section
250
+ const { section, assetCollection: sectionAssets } = await processMarkdownFile(filePath, id, siteRoot)
251
+ assetCollection = mergeAssetCollections(assetCollection, sectionAssets)
252
+
253
+ // Track last modified
254
+ const fileStat = await stat(filePath)
255
+ if (!lastModified || fileStat.mtime > lastModified) {
256
+ lastModified = fileStat.mtime
257
+ }
258
+
259
+ // Process subsections recursively
260
+ if (Array.isArray(subsections) && subsections.length > 0) {
261
+ const subResult = await processExplicitSections(subsections, pagePath, siteRoot, id)
262
+ section.subsections = subResult.sections
263
+ assetCollection = mergeAssetCollections(assetCollection, subResult.assetCollection)
264
+ if (subResult.lastModified && (!lastModified || subResult.lastModified > lastModified)) {
265
+ lastModified = subResult.lastModified
266
+ }
267
+ }
268
+
269
+ sections.push(section)
270
+ index++
271
+ }
272
+
273
+ return { sections, assetCollection, lastModified }
274
+ }
275
+
191
276
  /**
192
277
  * Process a page directory
193
278
  *
@@ -199,14 +284,11 @@ function buildSectionHierarchy(sections) {
199
284
  async function processPage(pagePath, pageName, siteRoot) {
200
285
  const pageConfig = await readYamlFile(join(pagePath, 'page.yml'))
201
286
 
202
- if (pageConfig.hidden) return null
287
+ // Note: We no longer skip hidden pages here - they still exist as valid pages,
288
+ // they're just filtered from navigation. This allows direct linking to hidden pages.
289
+ // if (pageConfig.hidden) return null
203
290
 
204
- // Get markdown files
205
- const files = await readdir(pagePath)
206
- const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
207
-
208
- // Process sections and collect assets
209
- const sections = []
291
+ let hierarchicalSections = []
210
292
  let pageAssetCollection = {
211
293
  assets: {},
212
294
  hasExplicitPoster: new Set(),
@@ -214,24 +296,45 @@ async function processPage(pagePath, pageName, siteRoot) {
214
296
  }
215
297
  let lastModified = null
216
298
 
217
- for (const file of mdFiles) {
218
- const { name } = parse(file)
219
- const { prefix } = parseNumericPrefix(name)
220
- const id = prefix || name
299
+ // Check for explicit sections configuration
300
+ const { sections: sectionsConfig } = pageConfig
221
301
 
222
- const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot)
223
- sections.push(section)
224
- pageAssetCollection = mergeAssetCollections(pageAssetCollection, assetCollection)
302
+ if (sectionsConfig === undefined || sectionsConfig === '*') {
303
+ // Default behavior: discover all .md files, sort by numeric prefix
304
+ const files = await readdir(pagePath)
305
+ const mdFiles = files.filter(isMarkdownFile).sort(compareFilenames)
225
306
 
226
- // Track last modified time for sitemap
227
- const fileStat = await stat(join(pagePath, file))
228
- if (!lastModified || fileStat.mtime > lastModified) {
229
- lastModified = fileStat.mtime
307
+ const sections = []
308
+ for (const file of mdFiles) {
309
+ const { name } = parse(file)
310
+ const { prefix } = parseNumericPrefix(name)
311
+ const id = prefix || name
312
+
313
+ const { section, assetCollection } = await processMarkdownFile(join(pagePath, file), id, siteRoot)
314
+ sections.push(section)
315
+ pageAssetCollection = mergeAssetCollections(pageAssetCollection, assetCollection)
316
+
317
+ // Track last modified time for sitemap
318
+ const fileStat = await stat(join(pagePath, file))
319
+ if (!lastModified || fileStat.mtime > lastModified) {
320
+ lastModified = fileStat.mtime
321
+ }
230
322
  }
231
- }
232
323
 
233
- // Build hierarchy
234
- const hierarchicalSections = buildSectionHierarchy(sections)
324
+ // Build hierarchy from dot notation
325
+ hierarchicalSections = buildSectionHierarchy(sections)
326
+
327
+ } else if (Array.isArray(sectionsConfig) && sectionsConfig.length > 0) {
328
+ // Explicit sections array
329
+ const result = await processExplicitSections(sectionsConfig, pagePath, siteRoot)
330
+ hierarchicalSections = result.sections
331
+ pageAssetCollection = result.assetCollection
332
+ lastModified = result.lastModified
333
+
334
+ } else {
335
+ // Empty sections (null, empty array, or invalid) = pure route with no content
336
+ // hierarchicalSections stays empty, lastModified stays null
337
+ }
235
338
 
236
339
  // Determine route
237
340
  let route = '/' + pageName
@@ -241,16 +344,31 @@ async function processPage(pagePath, pageName, siteRoot) {
241
344
  route = '/' + pageName
242
345
  }
243
346
 
244
- // Extract SEO config from page
245
- const { seo = {}, ...restConfig } = pageConfig
347
+ // Extract configuration
348
+ const { seo = {}, layout = {}, ...restConfig } = pageConfig
246
349
 
247
350
  return {
248
351
  page: {
249
352
  route,
250
353
  title: pageConfig.title || pageName,
251
354
  description: pageConfig.description || '',
355
+ label: pageConfig.label || null, // Short label for navigation (defaults to title)
252
356
  order: pageConfig.order,
253
357
  lastModified: lastModified?.toISOString(),
358
+
359
+ // Navigation options
360
+ hidden: pageConfig.hidden || false, // Hide from all navigation
361
+ hideInHeader: pageConfig.hideInHeader || false, // Hide from header nav
362
+ hideInFooter: pageConfig.hideInFooter || false, // Hide from footer nav
363
+
364
+ // Layout options (per-page overrides)
365
+ layout: {
366
+ header: layout.header !== false, // Show header (default true)
367
+ footer: layout.footer !== false, // Show footer (default true)
368
+ leftPanel: layout.leftPanel !== false, // Show left panel (default true)
369
+ rightPanel: layout.rightPanel !== false // Show right panel (default true)
370
+ },
371
+
254
372
  seo: {
255
373
  noindex: seo.noindex || false,
256
374
  image: seo.image || null,
@@ -296,6 +414,8 @@ export async function collectSiteContent(sitePath) {
296
414
  }
297
415
  let header = null
298
416
  let footer = null
417
+ let left = null
418
+ let right = null
299
419
 
300
420
  for (const entry of entries) {
301
421
  const entryPath = join(pagesPath, entry)
@@ -309,11 +429,15 @@ export async function collectSiteContent(sitePath) {
309
429
  const { page, assetCollection } = result
310
430
  siteAssetCollection = mergeAssetCollections(siteAssetCollection, assetCollection)
311
431
 
312
- // Handle special pages
432
+ // Handle special pages (layout areas)
313
433
  if (entry === '@header' || page.route === '/@header') {
314
434
  header = page
315
435
  } else if (entry === '@footer' || page.route === '/@footer') {
316
436
  footer = page
437
+ } else if (entry === '@left' || page.route === '/@left') {
438
+ left = page
439
+ } else if (entry === '@right' || page.route === '/@right') {
440
+ right = page
317
441
  } else {
318
442
  pages.push(page)
319
443
  }
@@ -335,6 +459,8 @@ export async function collectSiteContent(sitePath) {
335
459
  pages,
336
460
  header,
337
461
  footer,
462
+ left,
463
+ right,
338
464
  assets: siteAssetCollection.assets,
339
465
  hasExplicitPoster: siteAssetCollection.hasExplicitPoster,
340
466
  hasExplicitPreview: siteAssetCollection.hasExplicitPreview