@uniweb/build 0.1.26 → 0.1.27

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.26",
3
+ "version": "0.1.27",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -50,7 +50,8 @@
50
50
  "sharp": "^0.33.2"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@uniweb/content-reader": "1.0.3"
53
+ "@uniweb/content-reader": "1.0.4",
54
+ "@uniweb/runtime": "0.2.14"
54
55
  },
55
56
  "peerDependencies": {
56
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/prerender.js CHANGED
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Renders each page to static HTML at build time.
5
5
  * The output includes full HTML with hydration support.
6
+ *
7
+ * Uses @uniweb/runtime/ssr for rendering components, ensuring
8
+ * the same code path for both SSG and client-side rendering.
6
9
  */
7
10
 
8
11
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
@@ -11,8 +14,8 @@ import { join, dirname, resolve } from 'node:path'
11
14
  import { pathToFileURL } from 'node:url'
12
15
  import { createRequire } from 'node:module'
13
16
 
14
- // Lazily loaded dependencies (ESM with React)
15
- let React, renderToString, createUniweb
17
+ // Lazily loaded dependencies
18
+ let React, renderToString, createUniweb, PageElement
16
19
 
17
20
  /**
18
21
  * Load dependencies dynamically from the site's context
@@ -25,7 +28,6 @@ async function loadDependencies(siteDir) {
25
28
 
26
29
  // Create a require function that resolves from the site's perspective
27
30
  // This ensures we get the same React instance that the foundation uses
28
- // Note: createRequire requires an absolute path
29
31
  const absoluteSiteDir = resolve(siteDir)
30
32
  const siteRequire = createRequire(join(absoluteSiteDir, 'package.json'))
31
33
 
@@ -47,9 +49,13 @@ async function loadDependencies(siteDir) {
47
49
  renderToString = serverMod.renderToString
48
50
  }
49
51
 
50
- // @uniweb/core can be imported normally
52
+ // Load @uniweb/core
51
53
  const coreMod = await import('@uniweb/core')
52
54
  createUniweb = coreMod.createUniweb
55
+
56
+ // Load @uniweb/runtime/ssr for rendering components
57
+ const ssrMod = await import('@uniweb/runtime/ssr')
58
+ PageElement = ssrMod.PageElement
53
59
  }
54
60
 
55
61
  /**
@@ -108,14 +114,15 @@ export async function prerenderSite(siteDir, options = {}) {
108
114
  const uniweb = createUniweb(siteContent)
109
115
  uniweb.setFoundation(foundation)
110
116
 
111
- // Set foundation config if provided
112
- if (foundation.runtime) {
113
- uniweb.setFoundationConfig(foundation.runtime)
117
+ // Set foundation capabilities (Layout, props, etc.)
118
+ if (foundation.capabilities) {
119
+ uniweb.setFoundationConfig(foundation.capabilities)
114
120
  }
115
121
 
116
122
  // Pre-render each page
117
123
  const renderedFiles = []
118
124
  const pages = uniweb.activeWebsite.pages
125
+ const website = uniweb.activeWebsite
119
126
 
120
127
  for (const page of pages) {
121
128
  // Determine which routes to render this page at
@@ -134,10 +141,8 @@ export async function prerenderSite(siteDir, options = {}) {
134
141
  // Set this as the active page
135
142
  uniweb.activeWebsite.setActivePage(page.route)
136
143
 
137
- // Create the page element
138
- // Note: We don't need StaticRouter for SSG since we're just rendering
139
- // components to strings. The routing context isn't needed for static HTML.
140
- const element = React.createElement(PageRenderer, { page, foundation })
144
+ // Create the page element using the runtime's SSR components
145
+ const element = React.createElement(PageElement, { page, website })
141
146
 
142
147
  // Render to HTML string
143
148
  let renderedContent
@@ -173,249 +178,6 @@ export async function prerenderSite(siteDir, options = {}) {
173
178
  }
174
179
  }
175
180
 
176
- /**
177
- * Render an array of blocks
178
- */
179
- function BlocksRenderer({ blocks, foundation }) {
180
- if (!blocks || blocks.length === 0) return null
181
-
182
- return blocks.map((block, index) =>
183
- React.createElement(BlockRenderer, {
184
- key: block.id || index,
185
- block,
186
- foundation
187
- })
188
- )
189
- }
190
-
191
- /**
192
- * Default layout - renders header, body, footer in sequence
193
- */
194
- function DefaultLayout({ header, body, footer }) {
195
- return React.createElement(React.Fragment, null, header, body, footer)
196
- }
197
-
198
- /**
199
- * Layout component for SSG
200
- * Supports foundation-provided custom Layout via runtime.Layout
201
- */
202
- function Layout({ page, website, foundation }) {
203
- const RemoteLayout = foundation.runtime?.Layout || null
204
-
205
- // Get block groups from page
206
- const headerBlocks = page.getHeaderBlocks()
207
- const bodyBlocks = page.getBodyBlocks()
208
- const footerBlocks = page.getFooterBlocks()
209
- const leftBlocks = page.getLeftBlocks()
210
- const rightBlocks = page.getRightBlocks()
211
-
212
- // Pre-render each area
213
- const headerElement = headerBlocks
214
- ? React.createElement(BlocksRenderer, { blocks: headerBlocks, foundation })
215
- : null
216
- const bodyElement = bodyBlocks
217
- ? React.createElement(BlocksRenderer, { blocks: bodyBlocks, foundation })
218
- : null
219
- const footerElement = footerBlocks
220
- ? React.createElement(BlocksRenderer, { blocks: footerBlocks, foundation })
221
- : null
222
- const leftElement = leftBlocks
223
- ? React.createElement(BlocksRenderer, { blocks: leftBlocks, foundation })
224
- : null
225
- const rightElement = rightBlocks
226
- ? React.createElement(BlocksRenderer, { blocks: rightBlocks, foundation })
227
- : null
228
-
229
- // Use foundation's custom Layout if provided
230
- if (RemoteLayout) {
231
- return React.createElement(RemoteLayout, {
232
- page,
233
- website,
234
- header: headerElement,
235
- body: bodyElement,
236
- footer: footerElement,
237
- left: leftElement,
238
- right: rightElement,
239
- leftPanel: leftElement,
240
- rightPanel: rightElement
241
- })
242
- }
243
-
244
- // Default layout
245
- return React.createElement(DefaultLayout, {
246
- header: headerElement,
247
- body: bodyElement,
248
- footer: footerElement
249
- })
250
- }
251
-
252
- /**
253
- * Page renderer for SSG
254
- * Uses Layout component for proper orchestration of layout areas
255
- */
256
- function PageRenderer({ page, foundation }) {
257
- const website = globalThis.uniweb?.activeWebsite
258
-
259
- return React.createElement(
260
- 'main',
261
- null,
262
- React.createElement(Layout, { page, website, foundation })
263
- )
264
- }
265
-
266
- /**
267
- * Guarantee item has flat content structure
268
- */
269
- function guaranteeItemStructure(item) {
270
- return {
271
- title: item.title || '',
272
- pretitle: item.pretitle || '',
273
- subtitle: item.subtitle || '',
274
- paragraphs: item.paragraphs || [],
275
- links: item.links || [],
276
- imgs: item.imgs || [],
277
- lists: item.lists || [],
278
- icons: item.icons || [],
279
- videos: item.videos || [],
280
- buttons: item.buttons || [],
281
- properties: item.properties || {},
282
- cards: item.cards || [],
283
- documents: item.documents || [],
284
- forms: item.forms || [],
285
- quotes: item.quotes || [],
286
- headings: item.headings || [],
287
- }
288
- }
289
-
290
- /**
291
- * Guarantee content structure exists (mirrors runtime/prepare-props.js)
292
- * Returns a flat content object with all standard fields guaranteed to exist
293
- */
294
- function guaranteeContentStructure(parsedContent) {
295
- const content = parsedContent || {}
296
-
297
- return {
298
- // Flat header fields
299
- title: content.title || '',
300
- pretitle: content.pretitle || '',
301
- subtitle: content.subtitle || '',
302
- subtitle2: content.subtitle2 || '',
303
- alignment: content.alignment || null,
304
-
305
- // Flat body fields
306
- paragraphs: content.paragraphs || [],
307
- links: content.links || [],
308
- imgs: content.imgs || [],
309
- lists: content.lists || [],
310
- icons: content.icons || [],
311
- videos: content.videos || [],
312
- buttons: content.buttons || [],
313
- properties: content.properties || {},
314
- propertyBlocks: content.propertyBlocks || [],
315
- cards: content.cards || [],
316
- documents: content.documents || [],
317
- forms: content.forms || [],
318
- quotes: content.quotes || [],
319
- headings: content.headings || [],
320
-
321
- // Items with guaranteed structure
322
- items: (content.items || []).map(guaranteeItemStructure),
323
-
324
- // Sequence for ordered rendering
325
- sequence: content.sequence || [],
326
-
327
- // Preserve raw content if present
328
- raw: content.raw,
329
- }
330
- }
331
-
332
- /**
333
- * Apply param defaults from runtime schema
334
- */
335
- function applyDefaults(params, defaults) {
336
- if (!defaults || Object.keys(defaults).length === 0) {
337
- return params || {}
338
- }
339
-
340
- return {
341
- ...defaults,
342
- ...(params || {}),
343
- }
344
- }
345
-
346
- /**
347
- * Block renderer - maps block to foundation component
348
- */
349
- function BlockRenderer({ block, foundation }) {
350
- // Get component from foundation
351
- const componentName = block.type
352
- let Component = null
353
-
354
- if (typeof foundation.getComponent === 'function') {
355
- Component = foundation.getComponent(componentName)
356
- } else if (foundation[componentName]) {
357
- Component = foundation[componentName]
358
- }
359
-
360
- if (!Component) {
361
- // Return placeholder for unknown components
362
- return React.createElement(
363
- 'div',
364
- {
365
- className: 'block-placeholder',
366
- 'data-component': componentName,
367
- style: { display: 'none' }
368
- },
369
- `Component: ${componentName}`
370
- )
371
- }
372
-
373
- // Get runtime schema for defaults (from foundation.runtimeSchema)
374
- const runtimeSchema = foundation.runtimeSchema || {}
375
- const schema = runtimeSchema[componentName] || null
376
- const defaults = schema?.defaults || {}
377
-
378
- // Build content and params with runtime guarantees (same as runtime's BlockRenderer)
379
- let content, params
380
- if (block.parsedContent?._isPoc) {
381
- // Simple PoC format - content was passed directly
382
- content = block.parsedContent._pocContent
383
- params = block.properties
384
- } else {
385
- // Apply param defaults from meta.js
386
- params = applyDefaults(block.properties, defaults)
387
-
388
- // Guarantee content structure + merge with properties for backward compat
389
- content = {
390
- ...guaranteeContentStructure(block.parsedContent),
391
- ...block.properties,
392
- _prosemirror: block.parsedContent
393
- }
394
- }
395
-
396
- // Build wrapper props
397
- const theme = block.themeName
398
- const className = theme || ''
399
- const wrapperProps = {
400
- id: `Section${block.id}`,
401
- className
402
- }
403
-
404
- // Component props
405
- const componentProps = {
406
- content,
407
- params,
408
- block,
409
- input: block.input
410
- }
411
-
412
- return React.createElement(
413
- 'div',
414
- wrapperProps,
415
- React.createElement(Component, componentProps)
416
- )
417
- }
418
-
419
181
  /**
420
182
  * Inject rendered content into HTML shell
421
183
  */
@@ -423,7 +185,6 @@ function injectContent(shell, renderedContent, page, siteContent) {
423
185
  let html = shell
424
186
 
425
187
  // Replace the empty root div with pre-rendered content
426
- // Handle various formats of root div
427
188
  html = html.replace(
428
189
  /<div id="root">[\s\S]*?<\/div>/,
429
190
  `<div id="root">${renderedContent}</div>`
@@ -454,7 +215,6 @@ function injectContent(shell, renderedContent, page, siteContent) {
454
215
  }
455
216
 
456
217
  // Inject site content as JSON for hydration
457
- // This allows the client-side React to hydrate with the same data
458
218
  const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(siteContent)}</script>`
459
219
  if (!html.includes('__SITE_CONTENT__')) {
460
220
  html = html.replace(
@@ -470,7 +230,6 @@ function injectContent(shell, renderedContent, page, siteContent) {
470
230
  * Get output path for a route
471
231
  */
472
232
  function getOutputPath(distDir, route) {
473
- // Normalize route
474
233
  let normalizedRoute = route
475
234
 
476
235
  // Handle root route
@@ -35,6 +35,99 @@ function parseDataString(dataString) {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Extract lean schema field for runtime
40
+ * Strips editor-only fields (label, hint, description)
41
+ * Keeps runtime fields (type, default, options, of, schema)
42
+ *
43
+ * @param {string|Object} field - Schema field definition
44
+ * @returns {string|Object} - Lean field definition
45
+ */
46
+ function extractSchemaField(field) {
47
+ // Shorthand: 'string', 'number', 'boolean'
48
+ if (typeof field === 'string') {
49
+ return field
50
+ }
51
+
52
+ if (!field || typeof field !== 'object') {
53
+ return field
54
+ }
55
+
56
+ const lean = {}
57
+
58
+ // Keep runtime-relevant fields
59
+ if (field.type) lean.type = field.type
60
+ if (field.default !== undefined) lean.default = field.default
61
+ if (field.options) lean.options = field.options
62
+
63
+ // Handle array 'of' - can be string, schema name, or inline object
64
+ if (field.of !== undefined) {
65
+ if (typeof field.of === 'string') {
66
+ lean.of = field.of
67
+ } else if (typeof field.of === 'object') {
68
+ // Inline schema definition
69
+ lean.of = extractSchemaFields(field.of)
70
+ }
71
+ }
72
+
73
+ // Handle nested object 'schema'
74
+ if (field.schema && typeof field.schema === 'object') {
75
+ lean.schema = extractSchemaFields(field.schema)
76
+ }
77
+
78
+ // If we only have 'type' and it's a simple type, use shorthand
79
+ const keys = Object.keys(lean)
80
+ if (keys.length === 1 && keys[0] === 'type' && ['string', 'number', 'boolean'].includes(lean.type)) {
81
+ return lean.type
82
+ }
83
+
84
+ return keys.length > 0 ? lean : null
85
+ }
86
+
87
+ /**
88
+ * Extract lean schema fields for an entire schema object
89
+ *
90
+ * @param {Object} schemaFields - Map of fieldName -> field definition
91
+ * @returns {Object} - Map of fieldName -> lean field definition
92
+ */
93
+ function extractSchemaFields(schemaFields) {
94
+ if (!schemaFields || typeof schemaFields !== 'object') {
95
+ return {}
96
+ }
97
+
98
+ const lean = {}
99
+ for (const [name, field] of Object.entries(schemaFields)) {
100
+ const leanField = extractSchemaField(field)
101
+ if (leanField !== null) {
102
+ lean[name] = leanField
103
+ }
104
+ }
105
+ return lean
106
+ }
107
+
108
+ /**
109
+ * Extract lean schemas from meta.js schemas object
110
+ * Strips editor-only fields while preserving structure
111
+ *
112
+ * @param {Object} schemas - The schemas object from meta.js
113
+ * @returns {Object|null} - Lean schemas or null if empty
114
+ */
115
+ function extractSchemas(schemas) {
116
+ if (!schemas || typeof schemas !== 'object') {
117
+ return null
118
+ }
119
+
120
+ const lean = {}
121
+ for (const [schemaName, schemaFields] of Object.entries(schemas)) {
122
+ const leanSchema = extractSchemaFields(schemaFields)
123
+ if (Object.keys(leanSchema).length > 0) {
124
+ lean[schemaName] = leanSchema
125
+ }
126
+ }
127
+
128
+ return Object.keys(lean).length > 0 ? lean : null
129
+ }
130
+
38
131
  /**
39
132
  * Extract param defaults from params object
40
133
  *
@@ -102,6 +195,15 @@ export function extractRuntimeSchema(fullMeta) {
102
195
  runtime.initialState = fullMeta.initialState
103
196
  }
104
197
 
198
+ // Schemas - lean version for runtime validation/defaults
199
+ // Strips editor-only fields (label, hint, description)
200
+ if (fullMeta.schemas) {
201
+ const schemas = extractSchemas(fullMeta.schemas)
202
+ if (schemas) {
203
+ runtime.schemas = schemas
204
+ }
205
+ }
206
+
105
207
  return Object.keys(runtime).length > 0 ? runtime : null
106
208
  }
107
209
 
@@ -166,6 +166,33 @@ export async function processAssets(assetManifest, options = {}) {
166
166
  return { pathMapping, results }
167
167
  }
168
168
 
169
+ /**
170
+ * Recursively rewrite asset paths in a data object
171
+ *
172
+ * @param {any} data - Parsed JSON/YAML data
173
+ * @param {Object} pathMapping - Map of original paths to new paths
174
+ * @returns {any} Data with rewritten paths
175
+ */
176
+ function rewriteDataPaths(data, pathMapping) {
177
+ if (typeof data === 'string') {
178
+ return pathMapping[data] || data
179
+ }
180
+
181
+ if (Array.isArray(data)) {
182
+ return data.map(item => rewriteDataPaths(item, pathMapping))
183
+ }
184
+
185
+ if (data && typeof data === 'object') {
186
+ const result = {}
187
+ for (const [key, value] of Object.entries(data)) {
188
+ result[key] = rewriteDataPaths(value, pathMapping)
189
+ }
190
+ return result
191
+ }
192
+
193
+ return data
194
+ }
195
+
169
196
  /**
170
197
  * Rewrite asset paths in ProseMirror content
171
198
  *
@@ -188,6 +215,11 @@ export function rewriteContentPaths(content, pathMapping) {
188
215
  }
189
216
  }
190
217
 
218
+ // Rewrite paths in data blocks (structured data parsed at build time)
219
+ if (node.type === 'dataBlock' && node.attrs?.data) {
220
+ node.attrs.data = rewriteDataPaths(node.attrs.data, pathMapping)
221
+ }
222
+
191
223
  // Recurse into content
192
224
  if (node.content && Array.isArray(node.content)) {
193
225
  node.content.forEach(walk)
@@ -50,6 +50,72 @@ function isPdfPath(src) {
50
50
  return src.toLowerCase().endsWith(PDF_EXTENSION)
51
51
  }
52
52
 
53
+ /**
54
+ * Check if a string looks like a local asset path
55
+ *
56
+ * @param {string} value - String to check
57
+ * @returns {boolean} True if it looks like a local asset path
58
+ */
59
+ function isLocalAssetPath(value) {
60
+ if (typeof value !== 'string' || !value) return false
61
+
62
+ // Skip external URLs
63
+ if (isExternalUrl(value)) return false
64
+
65
+ // Must start with ./, ../, or / (absolute site path)
66
+ if (!value.startsWith('./') && !value.startsWith('../') && !value.startsWith('/')) {
67
+ return false
68
+ }
69
+
70
+ // Must have a media extension
71
+ return isImagePath(value) || isVideoPath(value) || isPdfPath(value)
72
+ }
73
+
74
+ /**
75
+ * Recursively walk a parsed data object and collect asset paths
76
+ *
77
+ * @param {any} data - Parsed JSON/YAML data
78
+ * @param {Function} visitor - Callback for each asset path: (path) => void
79
+ */
80
+ function walkDataAssets(data, visitor) {
81
+ if (typeof data === 'string') {
82
+ if (isLocalAssetPath(data)) {
83
+ visitor(data)
84
+ }
85
+ return
86
+ }
87
+
88
+ if (Array.isArray(data)) {
89
+ data.forEach(item => walkDataAssets(item, visitor))
90
+ return
91
+ }
92
+
93
+ if (data && typeof data === 'object') {
94
+ Object.values(data).forEach(value => walkDataAssets(value, visitor))
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Walk ProseMirror content and collect assets from data blocks
100
+ * Data blocks have pre-parsed structured data (parsed at content-reader build time)
101
+ *
102
+ * @param {Object} doc - ProseMirror document
103
+ * @param {Function} visitor - Callback for each asset: (path) => void
104
+ */
105
+ function walkDataBlockAssets(doc, visitor) {
106
+ if (!doc) return
107
+
108
+ // dataBlock nodes have pre-parsed data in attrs.data
109
+ if (doc.type === 'dataBlock' && doc.attrs?.data) {
110
+ walkDataAssets(doc.attrs.data, visitor)
111
+ }
112
+
113
+ // Recurse into content
114
+ if (doc.content && Array.isArray(doc.content)) {
115
+ doc.content.forEach(child => walkDataBlockAssets(child, visitor))
116
+ }
117
+ }
118
+
53
119
  /**
54
120
  * Resolve an asset path to absolute file system path
55
121
  *
@@ -215,6 +281,22 @@ export function collectSectionAssets(section, markdownPath, siteRoot) {
215
281
  }
216
282
  }
217
283
 
284
+ // Collect from tagged code blocks (JSON/YAML data)
285
+ if (section.content) {
286
+ walkDataBlockAssets(section.content, (assetPath) => {
287
+ const result = resolveAssetPath(assetPath, markdownPath, siteRoot)
288
+ if (!result.external && result.resolved) {
289
+ assets[assetPath] = {
290
+ original: assetPath,
291
+ resolved: result.resolved,
292
+ isImage: result.isImage,
293
+ isVideo: result.isVideo,
294
+ isPdf: result.isPdf
295
+ }
296
+ }
297
+ })
298
+ }
299
+
218
300
  return { assets, hasExplicitPoster, hasExplicitPreview }
219
301
  }
220
302