@uniweb/build 0.1.27 → 0.1.29

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.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,8 +50,8 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/content-reader": "1.0.4",
54
- "@uniweb/runtime": "0.2.14"
53
+ "@uniweb/runtime": "0.2.16",
54
+ "@uniweb/content-reader": "1.0.4"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.1.12"
63
+ "@uniweb/core": "0.1.13"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
package/src/prerender.js CHANGED
@@ -13,9 +13,212 @@ import { existsSync } from 'node:fs'
13
13
  import { join, dirname, resolve } from 'node:path'
14
14
  import { pathToFileURL } from 'node:url'
15
15
  import { createRequire } from 'node:module'
16
+ import { executeFetch, mergeDataIntoContent, singularize } from './site/data-fetcher.js'
16
17
 
17
18
  // Lazily loaded dependencies
18
- let React, renderToString, createUniweb, PageElement
19
+ let React, renderToString, createUniweb
20
+ let preparePropsSSR, getComponentMetaSSR, guaranteeContentStructureSSR
21
+
22
+ /**
23
+ * Execute all data fetches for prerender
24
+ * Processes site, page, and section level fetches, merging data appropriately
25
+ *
26
+ * @param {Object} siteContent - The site content from site-content.json
27
+ * @param {string} siteDir - Path to the site directory
28
+ * @param {function} onProgress - Progress callback
29
+ * @returns {Object} { siteCascadedData, pageFetchedData } - Fetched data for dynamic route expansion
30
+ */
31
+ async function executeAllFetches(siteContent, siteDir, onProgress) {
32
+ const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
33
+
34
+ // 1. Site-level fetch (cascades to all pages)
35
+ let siteCascadedData = {}
36
+ const siteFetch = siteContent.config?.fetch
37
+ if (siteFetch && siteFetch.prerender !== false) {
38
+ onProgress(` Fetching site data: ${siteFetch.path || siteFetch.url}`)
39
+ const result = await executeFetch(siteFetch, fetchOptions)
40
+ if (result.data && !result.error) {
41
+ siteCascadedData[siteFetch.schema] = result.data
42
+ }
43
+ }
44
+
45
+ // 2. Process each page and track fetched data by route
46
+ const pageFetchedData = new Map()
47
+
48
+ for (const page of siteContent.pages || []) {
49
+ // Page-level fetch (cascades to sections in this page)
50
+ let pageCascadedData = { ...siteCascadedData }
51
+ const pageFetch = page.fetch
52
+ if (pageFetch && pageFetch.prerender !== false) {
53
+ onProgress(` Fetching page data for ${page.route}: ${pageFetch.path || pageFetch.url}`)
54
+ const result = await executeFetch(pageFetch, fetchOptions)
55
+ if (result.data && !result.error) {
56
+ pageCascadedData[pageFetch.schema] = result.data
57
+ // Store for dynamic route expansion
58
+ pageFetchedData.set(page.route, {
59
+ schema: pageFetch.schema,
60
+ data: result.data,
61
+ })
62
+ }
63
+ }
64
+
65
+ // Process sections recursively (handles subsections too)
66
+ await processSectionFetches(page.sections, pageCascadedData, fetchOptions, onProgress)
67
+ }
68
+
69
+ return { siteCascadedData, pageFetchedData }
70
+ }
71
+
72
+ /**
73
+ * Expand dynamic pages into concrete pages based on fetched data
74
+ * A dynamic page like /blog/:slug with parent data [{ slug: 'post-1' }, { slug: 'post-2' }]
75
+ * becomes /blog/post-1 and /blog/post-2
76
+ *
77
+ * @param {Array} pages - Original pages array
78
+ * @param {Map} pageFetchedData - Map of route -> { schema, data }
79
+ * @param {function} onProgress - Progress callback
80
+ * @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
81
+ */
82
+ function expandDynamicPages(pages, pageFetchedData, onProgress) {
83
+ const expandedPages = []
84
+
85
+ for (const page of pages) {
86
+ if (!page.isDynamic) {
87
+ // Regular page - include as-is
88
+ expandedPages.push(page)
89
+ continue
90
+ }
91
+
92
+ // Dynamic page - expand based on parent's data
93
+ const { paramName, parentSchema } = page
94
+
95
+ if (!parentSchema) {
96
+ onProgress(` Warning: Dynamic page ${page.route} has no parentSchema, skipping`)
97
+ continue
98
+ }
99
+
100
+ // Find the parent's data
101
+ // The parent route is the route without the :param suffix
102
+ const parentRoute = page.route.replace(/\/:[\w]+$/, '') || '/'
103
+ const parentData = pageFetchedData.get(parentRoute)
104
+
105
+ if (!parentData || !Array.isArray(parentData.data)) {
106
+ onProgress(` Warning: No data found for dynamic page ${page.route} (parent: ${parentRoute})`)
107
+ continue
108
+ }
109
+
110
+ const items = parentData.data
111
+ const schema = parentData.schema
112
+ const singularSchema = singularize(schema)
113
+
114
+ onProgress(` Expanding ${page.route} → ${items.length} pages from ${schema}`)
115
+
116
+ // Create a concrete page for each item
117
+ for (const item of items) {
118
+ // Get the param value from the item (e.g., item.slug for :slug)
119
+ const paramValue = item[paramName]
120
+ if (!paramValue) {
121
+ onProgress(` Skipping item without ${paramName}`)
122
+ continue
123
+ }
124
+
125
+ // Create concrete route: /blog/:slug → /blog/my-post
126
+ const concreteRoute = page.route.replace(`:${paramName}`, paramValue)
127
+
128
+ // Deep clone the page with modifications
129
+ const concretePage = JSON.parse(JSON.stringify(page))
130
+ concretePage.route = concreteRoute
131
+ concretePage.isDynamic = false // No longer dynamic
132
+ concretePage.paramName = undefined
133
+ concretePage.parentSchema = undefined
134
+
135
+ // Store the dynamic route context for runtime data resolution
136
+ concretePage.dynamicContext = {
137
+ paramName,
138
+ paramValue,
139
+ schema, // Plural: 'articles'
140
+ singularSchema, // Singular: 'article'
141
+ currentItem: item, // The item for this specific route
142
+ allItems: items, // All items from parent
143
+ }
144
+
145
+ // Also inject into sections' cascadedData for components with inheritData
146
+ injectDynamicData(concretePage.sections, {
147
+ [singularSchema]: item, // Current item as singular
148
+ [schema]: items, // All items as plural
149
+ })
150
+
151
+ // Use item data for page metadata if available
152
+ if (item.title) concretePage.title = item.title
153
+ if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
154
+
155
+ expandedPages.push(concretePage)
156
+ }
157
+ }
158
+
159
+ return expandedPages
160
+ }
161
+
162
+ /**
163
+ * Inject dynamic route data into section cascadedData
164
+ * This ensures components with inheritData receive the current item
165
+ *
166
+ * @param {Array} sections - Sections to update
167
+ * @param {Object} data - Data to inject { article: {...}, articles: [...] }
168
+ */
169
+ function injectDynamicData(sections, data) {
170
+ if (!sections || !Array.isArray(sections)) return
171
+
172
+ for (const section of sections) {
173
+ section.cascadedData = {
174
+ ...(section.cascadedData || {}),
175
+ ...data,
176
+ }
177
+
178
+ // Recurse into subsections
179
+ if (section.subsections && section.subsections.length > 0) {
180
+ injectDynamicData(section.subsections, data)
181
+ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Process fetch configs for sections (and subsections recursively)
187
+ *
188
+ * @param {Array} sections - Array of section objects
189
+ * @param {Object} cascadedData - Data cascaded from site/page level
190
+ * @param {Object} fetchOptions - Options for executeFetch
191
+ * @param {function} onProgress - Progress callback
192
+ */
193
+ async function processSectionFetches(sections, cascadedData, fetchOptions, onProgress) {
194
+ if (!sections || !Array.isArray(sections)) return
195
+
196
+ for (const section of sections) {
197
+ // Execute section-level fetch
198
+ const sectionFetch = section.fetch
199
+ if (sectionFetch && sectionFetch.prerender !== false) {
200
+ onProgress(` Fetching section data: ${sectionFetch.path || sectionFetch.url}`)
201
+ const result = await executeFetch(sectionFetch, fetchOptions)
202
+ if (result.data && !result.error) {
203
+ // Merge fetched data into section's parsedContent
204
+ section.parsedContent = mergeDataIntoContent(
205
+ section.parsedContent || {},
206
+ result.data,
207
+ sectionFetch.schema,
208
+ sectionFetch.merge
209
+ )
210
+ }
211
+ }
212
+
213
+ // Attach cascaded data for components with inheritData
214
+ section.cascadedData = cascadedData
215
+
216
+ // Process subsections recursively
217
+ if (section.subsections && section.subsections.length > 0) {
218
+ await processSectionFetches(section.subsections, cascadedData, fetchOptions, onProgress)
219
+ }
220
+ }
221
+ }
19
222
 
20
223
  /**
21
224
  * Load dependencies dynamically from the site's context
@@ -53,9 +256,127 @@ async function loadDependencies(siteDir) {
53
256
  const coreMod = await import('@uniweb/core')
54
257
  createUniweb = coreMod.createUniweb
55
258
 
56
- // Load @uniweb/runtime/ssr for rendering components
57
- const ssrMod = await import('@uniweb/runtime/ssr')
58
- PageElement = ssrMod.PageElement
259
+ // Load runtime utilities (prepare-props doesn't use React)
260
+ const runtimeMod = await import('@uniweb/runtime/ssr')
261
+ preparePropsSSR = runtimeMod.prepareProps
262
+ getComponentMetaSSR = runtimeMod.getComponentMeta
263
+ guaranteeContentStructureSSR = runtimeMod.guaranteeContentStructure
264
+ }
265
+
266
+ /**
267
+ * Inline BlockRenderer for SSR
268
+ * Uses React from prerender's scope to avoid module resolution issues
269
+ */
270
+ function renderBlock(block) {
271
+ const Component = block.initComponent()
272
+
273
+ if (!Component) {
274
+ return React.createElement('div', {
275
+ className: 'block-error',
276
+ style: { padding: '1rem', background: '#fef2f2', color: '#dc2626' }
277
+ }, `Component not found: ${block.type}`)
278
+ }
279
+
280
+ // Build content and params with runtime guarantees
281
+ let content, params
282
+
283
+ if (block.parsedContent?._isPoc) {
284
+ // Simple PoC format - content was passed directly
285
+ content = block.parsedContent._pocContent
286
+ params = block.properties
287
+ } else {
288
+ // Get runtime metadata for this component
289
+ const meta = getComponentMetaSSR(block.type)
290
+
291
+ // Prepare props with runtime guarantees
292
+ const prepared = preparePropsSSR(block, meta)
293
+ params = prepared.params
294
+ content = {
295
+ ...prepared.content,
296
+ ...block.properties,
297
+ _prosemirror: block.parsedContent
298
+ }
299
+ }
300
+
301
+ const componentProps = {
302
+ content,
303
+ params,
304
+ block,
305
+ input: block.input
306
+ }
307
+
308
+ // Wrapper props
309
+ const theme = block.themeName
310
+ const wrapperProps = {
311
+ id: `Section${block.id}`,
312
+ className: theme || ''
313
+ }
314
+
315
+ return React.createElement('div', wrapperProps,
316
+ React.createElement(Component, componentProps)
317
+ )
318
+ }
319
+
320
+ /**
321
+ * Inline Blocks renderer for SSR
322
+ */
323
+ function renderBlocks(blocks) {
324
+ if (!blocks || blocks.length === 0) return null
325
+ return blocks.map((block, index) =>
326
+ React.createElement(React.Fragment, { key: block.id || index },
327
+ renderBlock(block)
328
+ )
329
+ )
330
+ }
331
+
332
+ /**
333
+ * Inline Layout renderer for SSR
334
+ */
335
+ function renderLayout(page, website) {
336
+ const RemoteLayout = website.getRemoteLayout()
337
+
338
+ const headerBlocks = page.getHeaderBlocks()
339
+ const bodyBlocks = page.getBodyBlocks()
340
+ const footerBlocks = page.getFooterBlocks()
341
+ const leftBlocks = page.getLeftBlocks()
342
+ const rightBlocks = page.getRightBlocks()
343
+
344
+ const headerElement = headerBlocks ? renderBlocks(headerBlocks) : null
345
+ const bodyElement = bodyBlocks ? renderBlocks(bodyBlocks) : null
346
+ const footerElement = footerBlocks ? renderBlocks(footerBlocks) : null
347
+ const leftElement = leftBlocks ? renderBlocks(leftBlocks) : null
348
+ const rightElement = rightBlocks ? renderBlocks(rightBlocks) : null
349
+
350
+ if (RemoteLayout) {
351
+ return React.createElement(RemoteLayout, {
352
+ page,
353
+ website,
354
+ header: headerElement,
355
+ body: bodyElement,
356
+ footer: footerElement,
357
+ left: leftElement,
358
+ right: rightElement,
359
+ leftPanel: leftElement,
360
+ rightPanel: rightElement
361
+ })
362
+ }
363
+
364
+ // Default layout
365
+ return React.createElement(React.Fragment, null,
366
+ headerElement,
367
+ bodyElement,
368
+ footerElement
369
+ )
370
+ }
371
+
372
+ /**
373
+ * Inline PageElement for SSR
374
+ * Uses React from prerender's scope
375
+ */
376
+ function createPageElement(page, website) {
377
+ return React.createElement('main', null,
378
+ renderLayout(page, website)
379
+ )
59
380
  }
60
381
 
61
382
  /**
@@ -92,6 +413,16 @@ export async function prerenderSite(siteDir, options = {}) {
92
413
  }
93
414
  const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
94
415
 
416
+ // Execute data fetches (site, page, section levels)
417
+ onProgress('Executing data fetches...')
418
+ const { siteCascadedData, pageFetchedData } = await executeAllFetches(siteContent, siteDir, onProgress)
419
+
420
+ // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
421
+ if (siteContent.pages?.some(p => p.isDynamic)) {
422
+ onProgress('Expanding dynamic routes...')
423
+ siteContent.pages = expandDynamicPages(siteContent.pages, pageFetchedData, onProgress)
424
+ }
425
+
95
426
  // Load the HTML shell
96
427
  onProgress('Loading HTML shell...')
97
428
  const shellPath = join(distDir, 'index.html')
@@ -141,8 +472,9 @@ export async function prerenderSite(siteDir, options = {}) {
141
472
  // Set this as the active page
142
473
  uniweb.activeWebsite.setActivePage(page.route)
143
474
 
144
- // Create the page element using the runtime's SSR components
145
- const element = React.createElement(PageElement, { page, website })
475
+ // Create the page element using inline SSR rendering
476
+ // (uses React from prerender's scope to avoid module resolution issues)
477
+ const element = createPageElement(page, website)
146
478
 
147
479
  // Render to HTML string
148
480
  let renderedContent
@@ -215,8 +547,16 @@ function injectContent(shell, renderedContent, page, siteContent) {
215
547
  }
216
548
 
217
549
  // Inject site content as JSON for hydration
550
+ // Replace existing content if present, otherwise add it
218
551
  const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(siteContent)}</script>`
219
- if (!html.includes('__SITE_CONTENT__')) {
552
+ if (html.includes('__SITE_CONTENT__')) {
553
+ // Replace existing site content with updated version (includes expanded dynamic routes)
554
+ // Match script tag with attributes in any order
555
+ html = html.replace(
556
+ /<script[^>]*id="__SITE_CONTENT__"[^>]*>[\s\S]*?<\/script>/,
557
+ contentScript
558
+ )
559
+ } else {
220
560
  html = html.replace(
221
561
  '</head>',
222
562
  ` ${contentScript}\n </head>`
@@ -10,6 +10,7 @@
10
10
  * - defaults: param default values
11
11
  * - context: static capabilities for cross-block coordination
12
12
  * - initialState: initial values for mutable block state
13
+ * - inheritData: boolean or array for cascaded data from page/site fetches
13
14
  *
14
15
  * Full metadata (titles, descriptions, hints, etc.) stays in schema.json
15
16
  * for the visual editor.
@@ -105,10 +106,30 @@ function extractSchemaFields(schemaFields) {
105
106
  return lean
106
107
  }
107
108
 
109
+ /**
110
+ * Check if a schema value is in the full @uniweb/schemas format
111
+ * Full format has: { name, version?, description?, fields: {...} }
112
+ *
113
+ * @param {Object} schema - Schema value to check
114
+ * @returns {boolean}
115
+ */
116
+ function isFullSchemaFormat(schema) {
117
+ return (
118
+ schema &&
119
+ typeof schema === 'object' &&
120
+ typeof schema.fields === 'object' &&
121
+ schema.fields !== null
122
+ )
123
+ }
124
+
108
125
  /**
109
126
  * Extract lean schemas from meta.js schemas object
110
127
  * Strips editor-only fields while preserving structure
111
128
  *
129
+ * Supports two formats:
130
+ * 1. Full @uniweb/schemas format: { name, version, fields: {...} }
131
+ * 2. Inline fields format: { fieldName: fieldDef, ... }
132
+ *
112
133
  * @param {Object} schemas - The schemas object from meta.js
113
134
  * @returns {Object|null} - Lean schemas or null if empty
114
135
  */
@@ -118,7 +139,13 @@ function extractSchemas(schemas) {
118
139
  }
119
140
 
120
141
  const lean = {}
121
- for (const [schemaName, schemaFields] of Object.entries(schemas)) {
142
+ for (const [schemaName, schemaValue] of Object.entries(schemas)) {
143
+ // Handle full schema format (from @uniweb/schemas or npm packages)
144
+ // Extract just the fields, discard name/version/description metadata
145
+ const schemaFields = isFullSchemaFormat(schemaValue)
146
+ ? schemaValue.fields
147
+ : schemaValue
148
+
122
149
  const leanSchema = extractSchemaFields(schemaFields)
123
150
  if (Object.keys(leanSchema).length > 0) {
124
151
  lean[schemaName] = leanSchema
@@ -204,6 +231,12 @@ export function extractRuntimeSchema(fullMeta) {
204
231
  }
205
232
  }
206
233
 
234
+ // Data inheritance - component receives cascaded data from page/site level fetches
235
+ // Can be: true (inherit all), false (inherit none), or ['schema1', 'schema2'] (selective)
236
+ if (fullMeta.inheritData !== undefined) {
237
+ runtime.inheritData = fullMeta.inheritData
238
+ }
239
+
207
240
  return Object.keys(runtime).length > 0 ? runtime : null
208
241
  }
209
242