@uniweb/build 0.1.2 → 0.1.5

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/docs.js ADDED
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Documentation Generator
3
+ *
4
+ * Generates markdown documentation from foundation schema.json
5
+ * or directly from component meta files.
6
+ */
7
+
8
+ import { readFile, writeFile } from 'node:fs/promises'
9
+ import { existsSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { buildSchema } from './schema.js'
12
+
13
+ /**
14
+ * Generate markdown documentation for a single component
15
+ *
16
+ * @param {string} name - Component name
17
+ * @param {Object} meta - Component metadata
18
+ * @returns {string} Markdown content
19
+ */
20
+ function generateComponentDocs(name, meta) {
21
+ const lines = []
22
+
23
+ // Component header
24
+ lines.push(`## ${name}`)
25
+ lines.push('')
26
+
27
+ // Description
28
+ if (meta.description) {
29
+ lines.push(meta.description)
30
+ lines.push('')
31
+ }
32
+
33
+ // Category badge
34
+ if (meta.category) {
35
+ lines.push(`**Category:** ${meta.category}`)
36
+ lines.push('')
37
+ }
38
+
39
+ // Content Elements
40
+ if (meta.elements && Object.keys(meta.elements).length > 0) {
41
+ lines.push('### Content Elements')
42
+ lines.push('')
43
+ lines.push('| Element | Label | Required | Description |')
44
+ lines.push('|---------|-------|----------|-------------|')
45
+
46
+ for (const [key, element] of Object.entries(meta.elements)) {
47
+ const label = element.label || key
48
+ const required = element.required ? 'Yes' : ''
49
+ const description = element.description || ''
50
+ lines.push(`| \`${key}\` | ${label} | ${required} | ${description} |`)
51
+ }
52
+ lines.push('')
53
+ }
54
+
55
+ // Parameters/Properties
56
+ if (meta.properties && Object.keys(meta.properties).length > 0) {
57
+ lines.push('### Parameters')
58
+ lines.push('')
59
+ lines.push('| Parameter | Type | Default | Description |')
60
+ lines.push('|-----------|------|---------|-------------|')
61
+
62
+ for (const [key, prop] of Object.entries(meta.properties)) {
63
+ const type = prop.type || 'string'
64
+ const defaultVal = prop.default !== undefined ? `\`${prop.default}\`` : ''
65
+ let description = prop.label || ''
66
+
67
+ // Add options for select type
68
+ if (prop.type === 'select' && prop.options) {
69
+ const optionValues = prop.options.map(o =>
70
+ typeof o === 'object' ? o.value : o
71
+ ).join(', ')
72
+ description += description ? ` (${optionValues})` : optionValues
73
+ }
74
+
75
+ lines.push(`| \`${key}\` | ${type} | ${defaultVal} | ${description} |`)
76
+ }
77
+ lines.push('')
78
+ }
79
+
80
+ // Presets
81
+ if (meta.presets && meta.presets.length > 0) {
82
+ lines.push('### Presets')
83
+ lines.push('')
84
+
85
+ for (const preset of meta.presets) {
86
+ const settings = preset.settings
87
+ ? Object.entries(preset.settings)
88
+ .map(([k, v]) => `${k}: ${v}`)
89
+ .join(', ')
90
+ : ''
91
+ lines.push(`- **${preset.name}** - ${preset.label || ''} ${settings ? `(${settings})` : ''}`)
92
+ }
93
+ lines.push('')
94
+ }
95
+
96
+ return lines.join('\n')
97
+ }
98
+
99
+ /**
100
+ * Generate full markdown documentation for a foundation
101
+ *
102
+ * @param {Object} schema - Foundation schema object
103
+ * @param {Object} options - Generation options
104
+ * @param {string} [options.title] - Document title
105
+ * @returns {string} Complete markdown documentation
106
+ */
107
+ export function generateDocsFromSchema(schema, options = {}) {
108
+ const { title = 'Foundation Components' } = options
109
+ const lines = []
110
+
111
+ // Header
112
+ lines.push(`# ${title}`)
113
+ lines.push('')
114
+
115
+ // Foundation info
116
+ const foundationMeta = schema._self
117
+ if (foundationMeta) {
118
+ if (foundationMeta.name) {
119
+ lines.push(`**${foundationMeta.name}**`)
120
+ lines.push('')
121
+ }
122
+ if (foundationMeta.description) {
123
+ lines.push(foundationMeta.description)
124
+ lines.push('')
125
+ }
126
+ }
127
+
128
+ lines.push('---')
129
+ lines.push('')
130
+
131
+ // Table of contents
132
+ const componentNames = Object.keys(schema).filter(k => k !== '_self')
133
+
134
+ if (componentNames.length > 0) {
135
+ lines.push('## Components')
136
+ lines.push('')
137
+ for (const name of componentNames) {
138
+ const meta = schema[name]
139
+ const title = meta.title || name
140
+ lines.push(`- [${title}](#${name.toLowerCase()}) - ${meta.description || ''}`)
141
+ }
142
+ lines.push('')
143
+ lines.push('---')
144
+ lines.push('')
145
+ }
146
+
147
+ // Component documentation
148
+ for (const name of componentNames) {
149
+ const meta = schema[name]
150
+ lines.push(generateComponentDocs(name, meta))
151
+ lines.push('---')
152
+ lines.push('')
153
+ }
154
+
155
+ // Footer
156
+ lines.push('*Generated from foundation schema*')
157
+
158
+ return lines.join('\n')
159
+ }
160
+
161
+ /**
162
+ * Generate documentation for a foundation directory
163
+ *
164
+ * Can read from existing schema.json or build schema from source.
165
+ *
166
+ * @param {string} foundationDir - Path to foundation directory
167
+ * @param {Object} options - Options
168
+ * @param {string} [options.output] - Output file path (default: COMPONENTS.md)
169
+ * @param {boolean} [options.fromSource] - Build schema from source instead of dist
170
+ * @returns {Promise<{outputPath: string, componentCount: number}>}
171
+ */
172
+ export async function generateDocs(foundationDir, options = {}) {
173
+ const {
174
+ output = 'COMPONENTS.md',
175
+ fromSource = false,
176
+ } = options
177
+
178
+ let schema
179
+
180
+ // Try to load schema.json from dist
181
+ const schemaPath = join(foundationDir, 'dist', 'schema.json')
182
+
183
+ if (!fromSource && existsSync(schemaPath)) {
184
+ // Load from existing schema.json
185
+ const schemaContent = await readFile(schemaPath, 'utf-8')
186
+ schema = JSON.parse(schemaContent)
187
+ } else {
188
+ // Build schema from source
189
+ const srcDir = join(foundationDir, 'src')
190
+ if (!existsSync(srcDir)) {
191
+ throw new Error(`Source directory not found: ${srcDir}`)
192
+ }
193
+ schema = await buildSchema(srcDir)
194
+ }
195
+
196
+ // Get foundation name for title
197
+ const pkgPath = join(foundationDir, 'package.json')
198
+ let title = 'Foundation Components'
199
+ if (existsSync(pkgPath)) {
200
+ const pkg = JSON.parse(await readFile(pkgPath, 'utf-8'))
201
+ if (pkg.name) {
202
+ title = `${pkg.name} Components`
203
+ }
204
+ }
205
+
206
+ // Generate markdown
207
+ const markdown = generateDocsFromSchema(schema, { title })
208
+
209
+ // Write output
210
+ const outputPath = join(foundationDir, output)
211
+ await writeFile(outputPath, markdown)
212
+
213
+ // Count components
214
+ const componentCount = Object.keys(schema).filter(k => k !== '_self').length
215
+
216
+ return { outputPath, componentCount }
217
+ }
package/src/images.js CHANGED
@@ -2,6 +2,8 @@
2
2
  * Image Processing Utilities
3
3
  *
4
4
  * Handles preview image discovery, conversion to webp, and metadata extraction.
5
+ * Preview images are editor metadata (for preset visualization) and are output
6
+ * to dist/meta/previews/ to keep them separate from runtime assets.
5
7
  */
6
8
 
7
9
  import { readdir, mkdir, copyFile } from 'node:fs/promises'
@@ -71,8 +73,8 @@ export async function processComponentPreviews(componentDir, componentName, outp
71
73
  return previews
72
74
  }
73
75
 
74
- // Create output directory
75
- const componentOutputDir = join(outputDir, 'assets', componentName)
76
+ // Create output directory for preview images (editor metadata)
77
+ const componentOutputDir = join(outputDir, 'meta', 'previews', componentName)
76
78
  await mkdir(componentOutputDir, { recursive: true })
77
79
 
78
80
  // Get all image files
@@ -115,7 +117,7 @@ export async function processComponentPreviews(componentDir, componentName, outp
115
117
  }
116
118
 
117
119
  previews[presetName] = {
118
- path: `assets/${componentName}/${outputFilename}`,
120
+ path: `meta/previews/${componentName}/${outputFilename}`,
119
121
  width: metadata.width,
120
122
  height: metadata.height,
121
123
  type: finalFormat,
package/src/index.js CHANGED
@@ -35,5 +35,16 @@ export {
35
35
  foundationDevPlugin,
36
36
  } from './vite-foundation-plugin.js'
37
37
 
38
+ // SSG Prerendering
39
+ export {
40
+ prerenderSite,
41
+ } from './prerender.js'
42
+
43
+ // Documentation generation
44
+ export {
45
+ generateDocs,
46
+ generateDocsFromSchema,
47
+ } from './docs.js'
48
+
38
49
  // Default export is the combined Vite plugin
39
50
  export { default } from './vite-foundation-plugin.js'
@@ -0,0 +1,310 @@
1
+ /**
2
+ * SSG Prerendering for Uniweb Sites
3
+ *
4
+ * Renders each page to static HTML at build time.
5
+ * The output includes full HTML with hydration support.
6
+ */
7
+
8
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
9
+ import { existsSync } from 'node:fs'
10
+ import { join, dirname } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+
13
+ // Lazily loaded dependencies (ESM with React)
14
+ let React, renderToString, createUniweb
15
+
16
+ /**
17
+ * Load dependencies dynamically
18
+ * These are ESM modules that may not be available at import time
19
+ */
20
+ async function loadDependencies() {
21
+ if (React) return // Already loaded
22
+
23
+ const [reactMod, serverMod, coreMod] = await Promise.all([
24
+ import('react'),
25
+ import('react-dom/server'),
26
+ import('@uniweb/core')
27
+ ])
28
+
29
+ React = reactMod.default || reactMod
30
+ renderToString = serverMod.renderToString
31
+ createUniweb = coreMod.createUniweb
32
+ }
33
+
34
+ /**
35
+ * Pre-render all pages in a built site to static HTML
36
+ *
37
+ * @param {string} siteDir - Path to the site directory
38
+ * @param {Object} options
39
+ * @param {string} options.foundationDir - Path to foundation directory (default: ../foundation)
40
+ * @param {function} options.onProgress - Progress callback
41
+ * @returns {Promise<{pages: number, files: string[]}>}
42
+ */
43
+ export async function prerenderSite(siteDir, options = {}) {
44
+ const {
45
+ foundationDir = join(siteDir, '..', 'foundation'),
46
+ onProgress = () => {}
47
+ } = options
48
+
49
+ const distDir = join(siteDir, 'dist')
50
+
51
+ // Verify build exists
52
+ if (!existsSync(distDir)) {
53
+ throw new Error(`Site must be built first. No dist directory found at: ${distDir}`)
54
+ }
55
+
56
+ // Load dependencies
57
+ onProgress('Loading dependencies...')
58
+ await loadDependencies()
59
+
60
+ // Load site content
61
+ onProgress('Loading site content...')
62
+ const contentPath = join(distDir, 'site-content.json')
63
+ if (!existsSync(contentPath)) {
64
+ throw new Error(`site-content.json not found at: ${contentPath}`)
65
+ }
66
+ const siteContent = JSON.parse(await readFile(contentPath, 'utf8'))
67
+
68
+ // Load the HTML shell
69
+ onProgress('Loading HTML shell...')
70
+ const shellPath = join(distDir, 'index.html')
71
+ if (!existsSync(shellPath)) {
72
+ throw new Error(`index.html not found at: ${shellPath}`)
73
+ }
74
+ const htmlShell = await readFile(shellPath, 'utf8')
75
+
76
+ // Load the foundation module
77
+ onProgress('Loading foundation...')
78
+ const foundationPath = join(foundationDir, 'dist', 'foundation.js')
79
+ if (!existsSync(foundationPath)) {
80
+ throw new Error(`Foundation not found at: ${foundationPath}. Build foundation first.`)
81
+ }
82
+ const foundationUrl = pathToFileURL(foundationPath).href
83
+ const foundation = await import(foundationUrl)
84
+
85
+ // Initialize the Uniweb runtime (this sets globalThis.uniweb)
86
+ onProgress('Initializing runtime...')
87
+ const uniweb = createUniweb(siteContent)
88
+ uniweb.setFoundation(foundation)
89
+
90
+ if (foundation.config || foundation.site) {
91
+ uniweb.setFoundationConfig(foundation.config || foundation.site)
92
+ }
93
+
94
+ // Pre-render each page
95
+ const renderedFiles = []
96
+ const pages = uniweb.activeWebsite.pages
97
+
98
+ for (const page of pages) {
99
+ const route = page.route
100
+ onProgress(`Rendering ${route}...`)
101
+
102
+ // Set this as the active page
103
+ uniweb.activeWebsite.setActivePage(route)
104
+
105
+ // Create the page element
106
+ // Note: We don't need StaticRouter for SSG since we're just rendering
107
+ // components to strings. The routing context isn't needed for static HTML.
108
+ const element = React.createElement(PageRenderer, { page, foundation })
109
+
110
+ // Render to HTML string
111
+ let renderedContent
112
+ try {
113
+ renderedContent = renderToString(element)
114
+ } catch (err) {
115
+ console.warn(`Warning: Failed to render ${route}: ${err.message}`)
116
+ if (process.env.DEBUG) {
117
+ console.error(err.stack)
118
+ }
119
+ continue
120
+ }
121
+
122
+ // Inject into shell
123
+ const html = injectContent(htmlShell, renderedContent, page, siteContent)
124
+
125
+ // Determine output path
126
+ const outputPath = getOutputPath(distDir, route)
127
+ await mkdir(dirname(outputPath), { recursive: true })
128
+ await writeFile(outputPath, html)
129
+
130
+ renderedFiles.push(outputPath)
131
+ onProgress(` → ${outputPath.replace(distDir, 'dist')}`)
132
+ }
133
+
134
+ onProgress(`Pre-rendered ${renderedFiles.length} pages`)
135
+
136
+ return {
137
+ pages: renderedFiles.length,
138
+ files: renderedFiles
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Minimal page renderer for SSG
144
+ * Renders blocks using foundation components
145
+ */
146
+ function PageRenderer({ page, foundation }) {
147
+ const blocks = page.getPageBlocks()
148
+
149
+ return React.createElement(
150
+ 'main',
151
+ null,
152
+ blocks.map((block, index) =>
153
+ React.createElement(BlockRenderer, {
154
+ key: block.id || index,
155
+ block,
156
+ foundation
157
+ })
158
+ )
159
+ )
160
+ }
161
+
162
+ /**
163
+ * Block renderer - maps block to foundation component
164
+ */
165
+ function BlockRenderer({ block, foundation }) {
166
+ // Get component from foundation
167
+ const componentName = block.component
168
+ let Component = null
169
+
170
+ if (typeof foundation.getComponent === 'function') {
171
+ Component = foundation.getComponent(componentName)
172
+ } else if (foundation[componentName]) {
173
+ Component = foundation[componentName]
174
+ }
175
+
176
+ if (!Component) {
177
+ // Return placeholder for unknown components
178
+ return React.createElement(
179
+ 'div',
180
+ {
181
+ className: 'block-placeholder',
182
+ 'data-component': componentName,
183
+ style: { display: 'none' }
184
+ },
185
+ `Component: ${componentName}`
186
+ )
187
+ }
188
+
189
+ // Build content object (same as runtime's BlockRenderer)
190
+ let content
191
+ if (block.parsedContent?.raw) {
192
+ content = block.parsedContent.raw
193
+ } else {
194
+ content = {
195
+ ...block.parsedContent,
196
+ ...block.properties,
197
+ _prosemirror: block.parsedContent
198
+ }
199
+ }
200
+
201
+ // Build wrapper props
202
+ const theme = block.themeName
203
+ const className = theme || ''
204
+ const wrapperProps = {
205
+ id: `Section${block.id}`,
206
+ className
207
+ }
208
+
209
+ // Component props
210
+ const componentProps = {
211
+ content,
212
+ params: block.properties,
213
+ block,
214
+ page: globalThis.uniweb?.activeWebsite?.activePage,
215
+ website: globalThis.uniweb?.activeWebsite,
216
+ input: block.input
217
+ }
218
+
219
+ return React.createElement(
220
+ 'div',
221
+ wrapperProps,
222
+ React.createElement(Component, componentProps)
223
+ )
224
+ }
225
+
226
+ /**
227
+ * Inject rendered content into HTML shell
228
+ */
229
+ function injectContent(shell, renderedContent, page, siteContent) {
230
+ let html = shell
231
+
232
+ // Replace the empty root div with pre-rendered content
233
+ // Handle various formats of root div
234
+ html = html.replace(
235
+ /<div id="root">[\s\S]*?<\/div>/,
236
+ `<div id="root">${renderedContent}</div>`
237
+ )
238
+
239
+ // Update page title
240
+ if (page.title) {
241
+ html = html.replace(
242
+ /<title>.*?<\/title>/,
243
+ `<title>${escapeHtml(page.title)}</title>`
244
+ )
245
+ }
246
+
247
+ // Add meta description if available
248
+ if (page.description) {
249
+ const metaDesc = `<meta name="description" content="${escapeHtml(page.description)}">`
250
+ if (html.includes('<meta name="description"')) {
251
+ html = html.replace(
252
+ /<meta name="description"[^>]*>/,
253
+ metaDesc
254
+ )
255
+ } else {
256
+ html = html.replace(
257
+ '</head>',
258
+ ` ${metaDesc}\n </head>`
259
+ )
260
+ }
261
+ }
262
+
263
+ // Inject site content as JSON for hydration
264
+ // This allows the client-side React to hydrate with the same data
265
+ const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(siteContent)}</script>`
266
+ if (!html.includes('__SITE_CONTENT__')) {
267
+ html = html.replace(
268
+ '</head>',
269
+ ` ${contentScript}\n </head>`
270
+ )
271
+ }
272
+
273
+ return html
274
+ }
275
+
276
+ /**
277
+ * Get output path for a route
278
+ */
279
+ function getOutputPath(distDir, route) {
280
+ // Normalize route
281
+ let normalizedRoute = route
282
+
283
+ // Handle root route
284
+ if (normalizedRoute === '/' || normalizedRoute === '') {
285
+ return join(distDir, 'index.html')
286
+ }
287
+
288
+ // Remove leading slash
289
+ if (normalizedRoute.startsWith('/')) {
290
+ normalizedRoute = normalizedRoute.slice(1)
291
+ }
292
+
293
+ // Create directory structure: /about -> /about/index.html
294
+ return join(distDir, normalizedRoute, 'index.html')
295
+ }
296
+
297
+ /**
298
+ * Escape HTML special characters
299
+ */
300
+ function escapeHtml(str) {
301
+ if (!str) return ''
302
+ return String(str)
303
+ .replace(/&/g, '&amp;')
304
+ .replace(/</g, '&lt;')
305
+ .replace(/>/g, '&gt;')
306
+ .replace(/"/g, '&quot;')
307
+ .replace(/'/g, '&#39;')
308
+ }
309
+
310
+ export default prerenderSite