@uniweb/build 0.1.25 → 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.25",
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",
@@ -59,7 +60,7 @@
59
60
  "@tailwindcss/vite": "^4.0.0",
60
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
61
62
  "vite-plugin-svgr": "^4.0.0",
62
- "@uniweb/core": "0.1.11"
63
+ "@uniweb/core": "0.1.12"
63
64
  },
64
65
  "peerDependenciesMeta": {
65
66
  "vite": {
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,215 +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 content structure exists (mirrors runtime/prepare-props.js)
268
- * Returns a content object with all standard paths guaranteed to exist
269
- */
270
- function guaranteeContentStructure(parsedContent) {
271
- const content = parsedContent || {}
272
-
273
- // Spread content first, then override with guaranteed structure
274
- // This ensures main.body.paragraphs etc. are always arrays
275
- return {
276
- // Preserve any additional fields from parser
277
- ...content,
278
- // Main content section (override with guarantees)
279
- main: {
280
- header: {
281
- title: content.main?.header?.title || '',
282
- pretitle: content.main?.header?.pretitle || '',
283
- subtitle: content.main?.header?.subtitle || '',
284
- },
285
- body: {
286
- paragraphs: content.main?.body?.paragraphs || [],
287
- links: content.main?.body?.links || [],
288
- imgs: content.main?.body?.imgs || [],
289
- lists: content.main?.body?.lists || [],
290
- icons: content.main?.body?.icons || [],
291
- },
292
- },
293
- // Content items (H3 sections)
294
- items: content.items || [],
295
- }
296
- }
297
-
298
- /**
299
- * Apply param defaults from runtime schema
300
- */
301
- function applyDefaults(params, defaults) {
302
- if (!defaults || Object.keys(defaults).length === 0) {
303
- return params || {}
304
- }
305
-
306
- return {
307
- ...defaults,
308
- ...(params || {}),
309
- }
310
- }
311
-
312
- /**
313
- * Block renderer - maps block to foundation component
314
- */
315
- function BlockRenderer({ block, foundation }) {
316
- // Get component from foundation
317
- const componentName = block.type
318
- let Component = null
319
-
320
- if (typeof foundation.getComponent === 'function') {
321
- Component = foundation.getComponent(componentName)
322
- } else if (foundation[componentName]) {
323
- Component = foundation[componentName]
324
- }
325
-
326
- if (!Component) {
327
- // Return placeholder for unknown components
328
- return React.createElement(
329
- 'div',
330
- {
331
- className: 'block-placeholder',
332
- 'data-component': componentName,
333
- style: { display: 'none' }
334
- },
335
- `Component: ${componentName}`
336
- )
337
- }
338
-
339
- // Get runtime schema for defaults (from foundation.runtimeSchema)
340
- const runtimeSchema = foundation.runtimeSchema || {}
341
- const schema = runtimeSchema[componentName] || null
342
- const defaults = schema?.defaults || {}
343
-
344
- // Build content and params with runtime guarantees (same as runtime's BlockRenderer)
345
- let content, params
346
- if (block.parsedContent?.raw) {
347
- // Simple PoC format - content was passed directly
348
- content = block.parsedContent.raw
349
- params = block.properties
350
- } else {
351
- // Apply param defaults from meta.js
352
- params = applyDefaults(block.properties, defaults)
353
-
354
- // Guarantee content structure + merge with properties for backward compat
355
- content = {
356
- ...guaranteeContentStructure(block.parsedContent),
357
- ...block.properties,
358
- _prosemirror: block.parsedContent
359
- }
360
- }
361
-
362
- // Build wrapper props
363
- const theme = block.themeName
364
- const className = theme || ''
365
- const wrapperProps = {
366
- id: `Section${block.id}`,
367
- className
368
- }
369
-
370
- // Component props
371
- const componentProps = {
372
- content,
373
- params,
374
- block,
375
- input: block.input
376
- }
377
-
378
- return React.createElement(
379
- 'div',
380
- wrapperProps,
381
- React.createElement(Component, componentProps)
382
- )
383
- }
384
-
385
181
  /**
386
182
  * Inject rendered content into HTML shell
387
183
  */
@@ -389,7 +185,6 @@ function injectContent(shell, renderedContent, page, siteContent) {
389
185
  let html = shell
390
186
 
391
187
  // Replace the empty root div with pre-rendered content
392
- // Handle various formats of root div
393
188
  html = html.replace(
394
189
  /<div id="root">[\s\S]*?<\/div>/,
395
190
  `<div id="root">${renderedContent}</div>`
@@ -420,7 +215,6 @@ function injectContent(shell, renderedContent, page, siteContent) {
420
215
  }
421
216
 
422
217
  // Inject site content as JSON for hydration
423
- // This allows the client-side React to hydrate with the same data
424
218
  const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(siteContent)}</script>`
425
219
  if (!html.includes('__SITE_CONTENT__')) {
426
220
  html = html.replace(
@@ -436,7 +230,6 @@ function injectContent(shell, renderedContent, page, siteContent) {
436
230
  * Get output path for a route
437
231
  */
438
232
  function getOutputPath(distDir, route) {
439
- // Normalize route
440
233
  let normalizedRoute = route
441
234
 
442
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