@uniweb/build 0.1.23 → 0.1.24

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.23",
3
+ "version": "0.1.24",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,7 +59,7 @@
59
59
  "@tailwindcss/vite": "^4.0.0",
60
60
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
61
61
  "vite-plugin-svgr": "^4.0.0",
62
- "@uniweb/core": "0.1.10"
62
+ "@uniweb/core": "0.1.11"
63
63
  },
64
64
  "peerDependenciesMeta": {
65
65
  "vite": {
@@ -48,6 +48,9 @@ const DEFAULT_EXTERNALS = [
48
48
  * @param {Object} [options={}] - Configuration options
49
49
  * @param {string} [options.entry] - Entry point path (default: 'src/_entry.generated.js')
50
50
  * @param {string} [options.fileName] - Output file name (default: 'foundation')
51
+ * @param {string[]} [options.components] - Paths to search for components (relative to src/).
52
+ * Default: ['components']
53
+ * Example: ['components', 'components/sections']
51
54
  * @param {string[]} [options.externals] - Additional packages to externalize
52
55
  * @param {boolean} [options.includeDefaultExternals] - Include default externals (default: true)
53
56
  * @param {Array} [options.plugins] - Additional Vite plugins
@@ -60,6 +63,7 @@ export async function defineFoundationConfig(options = {}) {
60
63
  const {
61
64
  entry = 'src/_entry.generated.js',
62
65
  fileName = 'foundation',
66
+ components: componentPaths,
63
67
  externals: additionalExternals = [],
64
68
  includeDefaultExternals = true,
65
69
  plugins: extraPlugins = [],
@@ -104,7 +108,7 @@ export async function defineFoundationConfig(options = {}) {
104
108
  // Build the plugins array
105
109
  // foundationPlugin handles entry generation and schema building
106
110
  const plugins = [
107
- foundationPlugin({ srcDir: 'src' }),
111
+ foundationPlugin({ srcDir: 'src', components: componentPaths }),
108
112
  tailwind && tailwindcss(),
109
113
  react(),
110
114
  svgr(),
@@ -6,28 +6,24 @@
6
6
  * Exports:
7
7
  * - `components` - Object map of component name -> React component
8
8
  * - `capabilities` - Custom Layout and props from src/exports.js (if present)
9
- * - `meta` - Runtime metadata extracted from component meta.js files
9
+ * - `meta` - Per-component runtime metadata extracted from meta.js files
10
10
  *
11
- * The `meta` export contains properties from meta.js that are needed at runtime,
12
- * not just editor-time. Currently this includes:
13
- * - `input` - Form input schemas (for components that accept user input)
11
+ * The `meta` export contains only properties needed at runtime:
12
+ * - `background` - Engine-level background image handling
13
+ * - `data` - CMS entity binding ({ type, limit })
14
+ * - `defaults` - Param default values
15
+ * - `context` - Static capabilities for cross-block coordination
16
+ * - `initialState` - Initial values for mutable block state
14
17
  *
15
18
  * Full component metadata lives in schema.json (for the visual editor).
16
- * Only runtime-essential properties are extracted here to keep bundles small.
19
+ * Foundation identity (name, description) comes from package.json in the editor schema.
17
20
  */
18
21
 
19
22
  import { writeFile, mkdir } from 'node:fs/promises'
20
23
  import { existsSync } from 'node:fs'
21
24
  import { join, dirname } from 'node:path'
22
25
  import { discoverComponents } from './schema.js'
23
-
24
- /**
25
- * Keys from meta.js that should be included in the runtime bundle.
26
- * These are properties needed at runtime, not just editor-time.
27
- *
28
- * - input: Form schemas for components that accept user input
29
- */
30
- const RUNTIME_META_KEYS = ['input']
26
+ import { extractAllRuntimeSchemas } from './runtime-schema.js'
31
27
 
32
28
  /**
33
29
  * Detect foundation exports file (for custom Layout, props, etc.)
@@ -63,32 +59,21 @@ function detectCssFile(srcDir) {
63
59
  return null
64
60
  }
65
61
 
66
- /**
67
- * Extract runtime-needed properties from component meta
68
- */
69
- function extractRuntimeMeta(componentsMeta) {
70
- const meta = {}
71
-
72
- for (const [name, componentMeta] of Object.entries(componentsMeta)) {
73
- const extracted = {}
74
- for (const key of RUNTIME_META_KEYS) {
75
- if (componentMeta[key] !== undefined) {
76
- extracted[key] = componentMeta[key]
77
- }
78
- }
79
- if (Object.keys(extracted).length > 0) {
80
- meta[name] = extracted
81
- }
82
- }
83
-
84
- return meta
85
- }
86
62
 
87
63
  /**
88
64
  * Generate the entry point source code
65
+ *
66
+ * @param {Object} components - Map of componentName -> { name, path, ext, ...meta }
67
+ * @param {Object} options - Generation options
89
68
  */
90
- function generateEntrySource(componentNames, options = {}) {
91
- const { cssPath = null, componentExtensions = {}, foundationExports = null, runtimeMeta = {} } = options
69
+ function generateEntrySource(components, options = {}) {
70
+ const {
71
+ cssPath = null,
72
+ foundationExports = null,
73
+ meta = {},
74
+ } = options
75
+
76
+ const componentNames = Object.keys(components).sort()
92
77
 
93
78
  const lines = [
94
79
  '// Auto-generated foundation entry point',
@@ -106,10 +91,10 @@ function generateEntrySource(componentNames, options = {}) {
106
91
  lines.push(`import capabilities from '${foundationExports.path}'`)
107
92
  }
108
93
 
109
- // Component imports (use detected extension or default to .js)
94
+ // Component imports (use component's path and detected extension)
110
95
  for (const name of componentNames) {
111
- const ext = componentExtensions[name] || 'js'
112
- lines.push(`import ${name} from './components/${name}/index.${ext}'`)
96
+ const { path, ext = 'js' } = components[name]
97
+ lines.push(`import ${name} from './${path}/index.${ext}'`)
113
98
  }
114
99
 
115
100
  lines.push('')
@@ -131,14 +116,11 @@ function generateEntrySource(componentNames, options = {}) {
131
116
  lines.push('export const capabilities = null')
132
117
  }
133
118
 
134
- // Runtime meta (form schemas, etc.) - only if non-empty
119
+ // Per-component metadata (defaults, context, initialState, background, data)
135
120
  lines.push('')
136
- if (Object.keys(runtimeMeta).length > 0) {
137
- const metaJson = JSON.stringify(runtimeMeta, null, 2)
138
- .split('\n')
139
- .map((line, i) => (i === 0 ? line : line))
140
- .join('\n')
141
- lines.push(`// Runtime metadata (form schemas, etc.)`)
121
+ if (Object.keys(meta).length > 0) {
122
+ const metaJson = JSON.stringify(meta, null, 2)
123
+ lines.push(`// Per-component runtime metadata (from meta.js)`)
142
124
  lines.push(`export const meta = ${metaJson}`)
143
125
  } else {
144
126
  lines.push('export const meta = {}')
@@ -151,9 +133,12 @@ function generateEntrySource(componentNames, options = {}) {
151
133
 
152
134
  /**
153
135
  * Detect the index file extension for a component
136
+ *
137
+ * @param {string} srcDir - Source directory
138
+ * @param {string} componentPath - Relative path to component (e.g., 'components/Hero')
154
139
  */
155
- function detectComponentExtension(srcDir, componentName) {
156
- const basePath = join(srcDir, 'components', componentName)
140
+ function detectComponentExtension(srcDir, componentPath) {
141
+ const basePath = join(srcDir, componentPath)
157
142
  for (const ext of ['jsx', 'tsx', 'js', 'ts']) {
158
143
  if (existsSync(join(basePath, `index.${ext}`))) {
159
144
  return ext
@@ -164,20 +149,27 @@ function detectComponentExtension(srcDir, componentName) {
164
149
 
165
150
  /**
166
151
  * Generate the foundation entry point file
152
+ *
153
+ * @param {string} srcDir - Source directory
154
+ * @param {string} [outputPath] - Output file path (default: srcDir/_entry.generated.js)
155
+ * @param {Object} [options] - Options
156
+ * @param {string[]} [options.componentPaths] - Paths to search for components (relative to srcDir)
167
157
  */
168
- export async function generateEntryPoint(srcDir, outputPath = null) {
158
+ export async function generateEntryPoint(srcDir, outputPath = null, options = {}) {
159
+ const { componentPaths } = options
160
+
169
161
  // Discover components (includes meta from meta.js files)
170
- const components = await discoverComponents(srcDir)
162
+ const components = await discoverComponents(srcDir, componentPaths)
171
163
  const componentNames = Object.keys(components).sort()
172
164
 
173
165
  if (componentNames.length === 0) {
174
166
  console.warn('Warning: No exposed components found')
175
167
  }
176
168
 
177
- // Detect extensions for each component
178
- const componentExtensions = {}
169
+ // Detect extensions for each component and add to component info
179
170
  for (const name of componentNames) {
180
- componentExtensions[name] = detectComponentExtension(srcDir, name)
171
+ const component = components[name]
172
+ component.ext = detectComponentExtension(srcDir, component.path)
181
173
  }
182
174
 
183
175
  // Check for CSS file
@@ -186,15 +178,14 @@ export async function generateEntryPoint(srcDir, outputPath = null) {
186
178
  // Check for foundation exports (custom Layout, props, etc.)
187
179
  const foundationExports = detectFoundationExports(srcDir)
188
180
 
189
- // Extract runtime-needed meta (form schemas, etc.)
190
- const runtimeMeta = extractRuntimeMeta(components)
181
+ // Extract per-component runtime metadata from meta.js files
182
+ const meta = extractAllRuntimeSchemas(components)
191
183
 
192
184
  // Generate source
193
- const source = generateEntrySource(componentNames, {
185
+ const source = generateEntrySource(components, {
194
186
  cssPath,
195
- componentExtensions,
196
187
  foundationExports,
197
- runtimeMeta,
188
+ meta,
198
189
  })
199
190
 
200
191
  // Write to file
@@ -212,6 +203,7 @@ export async function generateEntryPoint(srcDir, outputPath = null) {
212
203
  outputPath: output,
213
204
  componentNames,
214
205
  foundationExports,
206
+ meta,
215
207
  }
216
208
  }
217
209
 
package/src/images.js CHANGED
@@ -132,28 +132,21 @@ export async function processComponentPreviews(componentDir, componentName, outp
132
132
  *
133
133
  * @param {string} srcDir - Source directory (e.g., src/)
134
134
  * @param {string} outputDir - Output directory (e.g., dist/)
135
- * @param {Object} schema - Schema object to update with image info
135
+ * @param {Object} schema - Schema object with components (each has `path` property)
136
136
  * @param {boolean} isProduction - Whether to convert to webp
137
137
  * @returns {Object} Updated schema with image references
138
138
  */
139
139
  export async function processAllPreviews(srcDir, outputDir, schema, isProduction = true) {
140
- const componentsDir = join(srcDir, 'components')
141
-
142
- if (!existsSync(componentsDir)) {
143
- return schema
144
- }
145
-
146
- const entries = await readdir(componentsDir, { withFileTypes: true })
147
140
  let totalImages = 0
148
141
 
149
- for (const entry of entries) {
150
- if (!entry.isDirectory()) continue
142
+ // Iterate through components in schema (skip _self)
143
+ for (const [componentName, componentMeta] of Object.entries(schema)) {
144
+ if (componentName === '_self') continue
145
+ if (!componentMeta.path) continue
151
146
 
152
- const componentName = entry.name
153
- const componentDir = join(componentsDir, componentName)
147
+ const componentDir = join(srcDir, componentMeta.path)
154
148
 
155
- // Skip if component not in schema
156
- if (!schema[componentName]) continue
149
+ if (!existsSync(componentDir)) continue
157
150
 
158
151
  // Process preview images
159
152
  const previews = await processComponentPreviews(
package/src/prerender.js CHANGED
@@ -263,6 +263,50 @@ function PageRenderer({ page, foundation }) {
263
263
  )
264
264
  }
265
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
+ return {
274
+ // Main content section
275
+ main: {
276
+ header: {
277
+ title: content.main?.header?.title || '',
278
+ pretitle: content.main?.header?.pretitle || '',
279
+ subtitle: content.main?.header?.subtitle || '',
280
+ },
281
+ body: {
282
+ paragraphs: content.main?.body?.paragraphs || [],
283
+ links: content.main?.body?.links || [],
284
+ imgs: content.main?.body?.imgs || [],
285
+ lists: content.main?.body?.lists || [],
286
+ icons: content.main?.body?.icons || [],
287
+ },
288
+ },
289
+ // Content items (H3 sections)
290
+ items: content.items || [],
291
+ // Preserve any additional fields from parser
292
+ ...content,
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Apply param defaults from runtime schema
298
+ */
299
+ function applyDefaults(params, defaults) {
300
+ if (!defaults || Object.keys(defaults).length === 0) {
301
+ return params || {}
302
+ }
303
+
304
+ return {
305
+ ...defaults,
306
+ ...(params || {}),
307
+ }
308
+ }
309
+
266
310
  /**
267
311
  * Block renderer - maps block to foundation component
268
312
  */
@@ -290,13 +334,24 @@ function BlockRenderer({ block, foundation }) {
290
334
  )
291
335
  }
292
336
 
293
- // Build content object (same as runtime's BlockRenderer)
294
- let content
337
+ // Get runtime schema for defaults (from foundation.runtimeSchema)
338
+ const runtimeSchema = foundation.runtimeSchema || {}
339
+ const schema = runtimeSchema[componentName] || null
340
+ const defaults = schema?.defaults || {}
341
+
342
+ // Build content and params with runtime guarantees (same as runtime's BlockRenderer)
343
+ let content, params
295
344
  if (block.parsedContent?.raw) {
345
+ // Simple PoC format - content was passed directly
296
346
  content = block.parsedContent.raw
347
+ params = block.properties
297
348
  } else {
349
+ // Apply param defaults from meta.js
350
+ params = applyDefaults(block.properties, defaults)
351
+
352
+ // Guarantee content structure + merge with properties for backward compat
298
353
  content = {
299
- ...block.parsedContent,
354
+ ...guaranteeContentStructure(block.parsedContent),
300
355
  ...block.properties,
301
356
  _prosemirror: block.parsedContent
302
357
  }
@@ -313,10 +368,8 @@ function BlockRenderer({ block, foundation }) {
313
368
  // Component props
314
369
  const componentProps = {
315
370
  content,
316
- params: block.properties,
371
+ params,
317
372
  block,
318
- page: globalThis.uniweb?.activeWebsite?.activePage,
319
- website: globalThis.uniweb?.activeWebsite,
320
373
  input: block.input
321
374
  }
322
375
 
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Runtime Schema Extractor
3
+ *
4
+ * Extracts lean runtime-relevant metadata from full meta.js files.
5
+ * The runtime schema is optimized for size and contains only what's
6
+ * needed at render time:
7
+ *
8
+ * - background: boolean for engine-level background handling
9
+ * - data: { type, limit } for CMS entity binding
10
+ * - defaults: param default values
11
+ * - context: static capabilities for cross-block coordination
12
+ * - initialState: initial values for mutable block state
13
+ *
14
+ * Full metadata (titles, descriptions, hints, etc.) stays in schema.json
15
+ * for the visual editor.
16
+ */
17
+
18
+ /**
19
+ * Parse data string into structured object
20
+ * 'events' -> { type: 'events', limit: null }
21
+ * 'events:6' -> { type: 'events', limit: 6 }
22
+ *
23
+ * @param {string} dataString
24
+ * @returns {{ type: string, limit: number|null }}
25
+ */
26
+ function parseDataString(dataString) {
27
+ if (!dataString || typeof dataString !== 'string') {
28
+ return null
29
+ }
30
+
31
+ const [type, limitStr] = dataString.split(':')
32
+ return {
33
+ type: type.trim(),
34
+ limit: limitStr ? parseInt(limitStr, 10) : null,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Extract param defaults from params object
40
+ *
41
+ * @param {Object} params - The params object from meta.js
42
+ * @returns {Object|null} - Object of { paramName: defaultValue } or null if empty
43
+ */
44
+ function extractParamDefaults(params) {
45
+ if (!params || typeof params !== 'object') {
46
+ return null
47
+ }
48
+
49
+ const defaults = {}
50
+
51
+ for (const [key, param] of Object.entries(params)) {
52
+ if (param && typeof param === 'object' && param.default !== undefined) {
53
+ defaults[key] = param.default
54
+ }
55
+ }
56
+
57
+ return Object.keys(defaults).length > 0 ? defaults : null
58
+ }
59
+
60
+ /**
61
+ * Extract lean runtime schema from a full meta.js object
62
+ *
63
+ * @param {Object} fullMeta - The full meta.js default export
64
+ * @returns {Object|null} - Lean runtime schema or null if empty
65
+ */
66
+ export function extractRuntimeSchema(fullMeta) {
67
+ if (!fullMeta || typeof fullMeta !== 'object') {
68
+ return null
69
+ }
70
+
71
+ const runtime = {}
72
+
73
+ // Background handling (boolean or 'auto'/'manual')
74
+ if (fullMeta.background) {
75
+ runtime.background = fullMeta.background
76
+ }
77
+
78
+ // Data binding (CMS entities)
79
+ if (fullMeta.data) {
80
+ const parsed = parseDataString(fullMeta.data)
81
+ if (parsed) {
82
+ runtime.data = parsed
83
+ }
84
+ }
85
+
86
+ // Param defaults - support both v2 'params' and v1 'properties'
87
+ const paramsObj = fullMeta.params || fullMeta.properties
88
+ const defaults = extractParamDefaults(paramsObj)
89
+ if (defaults) {
90
+ runtime.defaults = defaults
91
+ }
92
+
93
+ // Context - static capabilities for cross-block coordination
94
+ // e.g., { allowTranslucentTop: true } for Hero components
95
+ if (fullMeta.context && typeof fullMeta.context === 'object') {
96
+ runtime.context = fullMeta.context
97
+ }
98
+
99
+ // Initial state - default values for mutable block state
100
+ // e.g., { expanded: false } for accordion-like components
101
+ if (fullMeta.initialState && typeof fullMeta.initialState === 'object') {
102
+ runtime.initialState = fullMeta.initialState
103
+ }
104
+
105
+ return Object.keys(runtime).length > 0 ? runtime : null
106
+ }
107
+
108
+ /**
109
+ * Extract runtime schemas for all components
110
+ *
111
+ * @param {Object} componentsMeta - Map of componentName -> meta.js content
112
+ * @returns {Object} - Map of componentName -> runtime schema (excludes null entries)
113
+ */
114
+ export function extractAllRuntimeSchemas(componentsMeta) {
115
+ const schemas = {}
116
+
117
+ for (const [name, meta] of Object.entries(componentsMeta)) {
118
+ const schema = extractRuntimeSchema(meta)
119
+ if (schema) {
120
+ schemas[name] = schema
121
+ }
122
+ }
123
+
124
+ return schemas
125
+ }
package/src/schema.js CHANGED
@@ -13,6 +13,9 @@ import { pathToFileURL } from 'node:url'
13
13
  // Meta file name (standardized to meta.js)
14
14
  const META_FILE_NAME = 'meta.js'
15
15
 
16
+ // Default component paths (relative to srcDir)
17
+ const DEFAULT_COMPONENT_PATHS = ['components']
18
+
16
19
  /**
17
20
  * Load a meta.js file via dynamic import
18
21
  */
@@ -57,23 +60,25 @@ export async function loadFoundationMeta(srcDir) {
57
60
  }
58
61
 
59
62
  /**
60
- * Discover all exposed components in a foundation
61
- * Returns map of componentName -> meta
63
+ * Discover components in a single path
64
+ * @param {string} srcDir - Source directory (e.g., 'src')
65
+ * @param {string} relativePath - Path relative to srcDir (e.g., 'components' or 'components/sections')
66
+ * @returns {Object} Map of componentName -> { name, path, ...meta }
62
67
  */
63
- export async function discoverComponents(srcDir) {
64
- const componentsDir = join(srcDir, 'components')
68
+ async function discoverComponentsInPath(srcDir, relativePath) {
69
+ const fullPath = join(srcDir, relativePath)
65
70
 
66
- if (!existsSync(componentsDir)) {
71
+ if (!existsSync(fullPath)) {
67
72
  return {}
68
73
  }
69
74
 
70
- const entries = await readdir(componentsDir, { withFileTypes: true })
75
+ const entries = await readdir(fullPath, { withFileTypes: true })
71
76
  const components = {}
72
77
 
73
78
  for (const entry of entries) {
74
79
  if (!entry.isDirectory()) continue
75
80
 
76
- const componentDir = join(componentsDir, entry.name)
81
+ const componentDir = join(fullPath, entry.name)
77
82
  const result = await loadComponentMeta(componentDir)
78
83
 
79
84
  if (result && result.meta) {
@@ -84,6 +89,7 @@ export async function discoverComponents(srcDir) {
84
89
 
85
90
  components[entry.name] = {
86
91
  name: entry.name,
92
+ path: join(relativePath, entry.name), // e.g., 'components/Hero' or 'components/sections/Hero'
87
93
  ...result.meta,
88
94
  }
89
95
  }
@@ -92,13 +98,43 @@ export async function discoverComponents(srcDir) {
92
98
  return components
93
99
  }
94
100
 
101
+ /**
102
+ * Discover all exposed components in a foundation
103
+ *
104
+ * @param {string} srcDir - Source directory (e.g., 'src')
105
+ * @param {string[]} [componentPaths] - Paths to search for components (relative to srcDir)
106
+ * Default: ['components']
107
+ * @returns {Object} Map of componentName -> { name, path, ...meta }
108
+ */
109
+ export async function discoverComponents(srcDir, componentPaths = DEFAULT_COMPONENT_PATHS) {
110
+ const components = {}
111
+
112
+ for (const relativePath of componentPaths) {
113
+ const found = await discoverComponentsInPath(srcDir, relativePath)
114
+
115
+ for (const [name, meta] of Object.entries(found)) {
116
+ if (components[name]) {
117
+ // Component already found in an earlier path - skip (first wins)
118
+ console.warn(`Warning: Component "${name}" found in multiple paths. Using ${components[name].path}, ignoring ${meta.path}`)
119
+ continue
120
+ }
121
+ components[name] = meta
122
+ }
123
+ }
124
+
125
+ return components
126
+ }
127
+
95
128
  /**
96
129
  * Build complete schema for a foundation
97
130
  * Returns { _self: foundationMeta, ComponentName: componentMeta, ... }
131
+ *
132
+ * @param {string} srcDir - Source directory
133
+ * @param {string[]} [componentPaths] - Paths to search for components
98
134
  */
99
- export async function buildSchema(srcDir) {
135
+ export async function buildSchema(srcDir, componentPaths) {
100
136
  const foundationMeta = await loadFoundationMeta(srcDir)
101
- const components = await discoverComponents(srcDir)
137
+ const components = await discoverComponents(srcDir, componentPaths)
102
138
 
103
139
  return {
104
140
  _self: foundationMeta,
@@ -108,8 +144,11 @@ export async function buildSchema(srcDir) {
108
144
 
109
145
  /**
110
146
  * Get list of exposed component names
147
+ *
148
+ * @param {string} srcDir - Source directory
149
+ * @param {string[]} [componentPaths] - Paths to search for components
111
150
  */
112
- export async function getExposedComponents(srcDir) {
113
- const components = await discoverComponents(srcDir)
151
+ export async function getExposedComponents(srcDir, componentPaths) {
152
+ const components = await discoverComponents(srcDir, componentPaths)
114
153
  return Object.keys(components)
115
154
  }
@@ -30,12 +30,14 @@
30
30
  * })
31
31
  */
32
32
 
33
- import { resolve } from 'node:path'
34
- import { watch } from 'node:fs'
33
+ import { resolve, join } from 'node:path'
34
+ import { watch, existsSync } from 'node:fs'
35
+ import { readFile, readdir } from 'node:fs/promises'
35
36
  import { collectSiteContent } from './content-collector.js'
36
37
  import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
37
38
  import { processAdvancedAssets } from './advanced-processors.js'
38
39
  import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
40
+ import { mergeTranslations } from '../i18n/merge.js'
39
41
 
40
42
  /**
41
43
  * Generate sitemap.xml content
@@ -269,6 +271,62 @@ export function siteContentPlugin(options = {}) {
269
271
  let isProduction = false
270
272
  let watcher = null
271
273
  let server = null
274
+ let localeTranslations = {} // Cache: { locale: translations }
275
+ let localesDir = 'locales' // Default, updated from site config
276
+
277
+ /**
278
+ * Load translations for a specific locale
279
+ */
280
+ async function loadLocaleTranslations(locale) {
281
+ if (localeTranslations[locale]) {
282
+ return localeTranslations[locale]
283
+ }
284
+
285
+ const localePath = join(resolvedSitePath, localesDir, `${locale}.json`)
286
+ if (!existsSync(localePath)) {
287
+ return null
288
+ }
289
+
290
+ try {
291
+ const content = await readFile(localePath, 'utf-8')
292
+ const translations = JSON.parse(content)
293
+ localeTranslations[locale] = translations
294
+ return translations
295
+ } catch {
296
+ return null
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Get available locales from locales directory
302
+ */
303
+ async function getAvailableLocales() {
304
+ const localesPath = join(resolvedSitePath, localesDir)
305
+ if (!existsSync(localesPath)) {
306
+ return []
307
+ }
308
+
309
+ try {
310
+ const files = await readdir(localesPath)
311
+ return files
312
+ .filter(f => f.endsWith('.json') && f !== 'manifest.json' && !f.startsWith('_'))
313
+ .map(f => f.replace('.json', ''))
314
+ } catch {
315
+ return []
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Get translated content for a locale
321
+ */
322
+ async function getTranslatedContent(locale) {
323
+ if (!siteContent) return null
324
+
325
+ const translations = await loadLocaleTranslations(locale)
326
+ if (!translations) return null
327
+
328
+ return mergeTranslations(siteContent, translations)
329
+ }
272
330
 
273
331
  return {
274
332
  name: 'uniweb:site-content',
@@ -284,6 +342,14 @@ export function siteContentPlugin(options = {}) {
284
342
  try {
285
343
  siteContent = await collectSiteContent(resolvedSitePath)
286
344
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
345
+
346
+ // Update localesDir from site config
347
+ if (siteContent.config?.i18n?.localesDir) {
348
+ localesDir = siteContent.config.i18n.localesDir
349
+ }
350
+
351
+ // Clear translation cache on rebuild
352
+ localeTranslations = {}
287
353
  } catch (err) {
288
354
  console.error('[site-content] Failed to collect content:', err.message)
289
355
  siteContent = { config: {}, theme: {}, pages: [] }
@@ -346,14 +412,59 @@ export function siteContentPlugin(options = {}) {
346
412
  watcher = { close: () => watchers.forEach(w => w.close()) }
347
413
  }
348
414
 
415
+ // Watch locales directory for translation changes
416
+ const localesPath = resolve(resolvedSitePath, localesDir)
417
+ if (existsSync(localesPath)) {
418
+ try {
419
+ const localeWatcher = watch(localesPath, { recursive: false }, () => {
420
+ console.log('[site-content] Translation files changed, clearing cache...')
421
+ localeTranslations = {}
422
+ server.ws.send({ type: 'full-reload' })
423
+ })
424
+ if (watcher) {
425
+ const originalClose = watcher.close
426
+ watcher.close = () => {
427
+ originalClose()
428
+ localeWatcher.close()
429
+ }
430
+ }
431
+ console.log(`[site-content] Watching ${localesPath} for translation changes`)
432
+ } catch (err) {
433
+ // locales dir may not exist, that's ok
434
+ }
435
+ }
436
+
349
437
  // Serve content and SEO files
350
- devServer.middlewares.use((req, res, next) => {
438
+ devServer.middlewares.use(async (req, res, next) => {
439
+ // Handle default locale site-content request
351
440
  if (req.url === `/${filename}`) {
352
441
  res.setHeader('Content-Type', 'application/json')
353
442
  res.end(JSON.stringify(siteContent, null, 2))
354
443
  return
355
444
  }
356
445
 
446
+ // Handle locale-prefixed site-content request (e.g., /es/site-content.json)
447
+ const localeContentMatch = req.url.match(/^\/([a-z]{2})\/site-content\.json$/)
448
+ if (localeContentMatch) {
449
+ const locale = localeContentMatch[1]
450
+ const translatedContent = await getTranslatedContent(locale)
451
+
452
+ if (translatedContent) {
453
+ // Add activeLocale to the content so runtime knows which locale is active
454
+ const contentWithLocale = {
455
+ ...translatedContent,
456
+ config: {
457
+ ...translatedContent.config,
458
+ activeLocale: locale
459
+ }
460
+ }
461
+ res.setHeader('Content-Type', 'application/json')
462
+ res.end(JSON.stringify(contentWithLocale, null, 2))
463
+ return
464
+ }
465
+ // If no translations, fall through to serve default content
466
+ }
467
+
357
468
  // Serve sitemap.xml in dev mode
358
469
  if (req.url === '/sitemap.xml' && seoEnabled && siteContent?.pages) {
359
470
  res.setHeader('Content-Type', 'application/xml')
@@ -394,14 +505,35 @@ export function siteContentPlugin(options = {}) {
394
505
  })
395
506
  },
396
507
 
397
- transformIndexHtml(html) {
508
+ async transformIndexHtml(html, ctx) {
398
509
  if (!siteContent) return html
399
510
 
511
+ // Detect locale from URL (e.g., /es/about → 'es')
512
+ let contentToInject = siteContent
513
+ let activeLocale = null
514
+
515
+ if (ctx?.originalUrl) {
516
+ const localeMatch = ctx.originalUrl.match(/^\/([a-z]{2})(\/|$)/)
517
+ if (localeMatch) {
518
+ activeLocale = localeMatch[1]
519
+ const translatedContent = await getTranslatedContent(activeLocale)
520
+ if (translatedContent) {
521
+ contentToInject = {
522
+ ...translatedContent,
523
+ config: {
524
+ ...translatedContent.config,
525
+ activeLocale
526
+ }
527
+ }
528
+ }
529
+ }
530
+ }
531
+
400
532
  let headInjection = ''
401
533
 
402
534
  // Inject SEO meta tags
403
535
  if (seoEnabled) {
404
- const metaTags = generateMetaTags(siteContent, seoOptions)
536
+ const metaTags = generateMetaTags(contentToInject, seoOptions)
405
537
  if (metaTags) {
406
538
  headInjection += ` ${metaTags}\n`
407
539
  }
@@ -409,7 +541,7 @@ export function siteContentPlugin(options = {}) {
409
541
 
410
542
  // Inject content as JSON script tag
411
543
  if (inject) {
412
- headInjection += ` <script type="application/json" id="${variableName}">${JSON.stringify(siteContent)}</script>\n`
544
+ headInjection += ` <script type="application/json" id="${variableName}">${JSON.stringify(contentToInject)}</script>\n`
413
545
  }
414
546
 
415
547
  if (!headInjection) return html
@@ -16,8 +16,8 @@ import { processAllPreviews } from './images.js'
16
16
  /**
17
17
  * Build schema.json with preview image references
18
18
  */
19
- async function buildSchemaWithPreviews(srcDir, outDir, isProduction) {
20
- const schema = await buildSchema(srcDir)
19
+ async function buildSchemaWithPreviews(srcDir, outDir, isProduction, componentPaths) {
20
+ const schema = await buildSchema(srcDir, componentPaths)
21
21
 
22
22
  // Process preview images
23
23
  const { schema: schemaWithImages, totalImages } = await processAllPreviews(
@@ -42,6 +42,7 @@ export function foundationBuildPlugin(options = {}) {
42
42
  srcDir = 'src',
43
43
  generateEntry = true,
44
44
  entryFileName = '_entry.generated.js',
45
+ components: componentPaths,
45
46
  } = options
46
47
 
47
48
  let resolvedSrcDir
@@ -58,7 +59,7 @@ export function foundationBuildPlugin(options = {}) {
58
59
  const root = config.root || process.cwd()
59
60
  const srcPath = resolve(root, srcDir)
60
61
  const entryPath = join(srcPath, entryFileName)
61
- await generateEntryPoint(srcPath, entryPath)
62
+ await generateEntryPoint(srcPath, entryPath, { componentPaths })
62
63
  },
63
64
 
64
65
  async configResolved(config) {
@@ -78,7 +79,8 @@ export function foundationBuildPlugin(options = {}) {
78
79
  const schema = await buildSchemaWithPreviews(
79
80
  resolvedSrcDir,
80
81
  outDir,
81
- isProduction
82
+ isProduction,
83
+ componentPaths
82
84
  )
83
85
 
84
86
  const schemaPath = join(metaDir, 'schema.json')
@@ -97,6 +99,7 @@ export function foundationDevPlugin(options = {}) {
97
99
  const {
98
100
  srcDir = 'src',
99
101
  entryFileName = '_entry.generated.js',
102
+ components: componentPaths,
100
103
  } = options
101
104
 
102
105
  let resolvedSrcDir
@@ -109,7 +112,7 @@ export function foundationDevPlugin(options = {}) {
109
112
  const root = config.root || process.cwd()
110
113
  const srcPath = resolve(root, srcDir)
111
114
  const entryPath = join(srcPath, entryFileName)
112
- await generateEntryPoint(srcPath, entryPath)
115
+ await generateEntryPoint(srcPath, entryPath, { componentPaths })
113
116
  },
114
117
 
115
118
  configResolved(config) {
@@ -118,10 +121,11 @@ export function foundationDevPlugin(options = {}) {
118
121
 
119
122
  async handleHotUpdate({ file, server }) {
120
123
  // Regenerate entry when meta.js files change
121
- if (file.includes('/components/') && file.endsWith('/meta.js')) {
124
+ // Check if file is a meta.js in the src directory
125
+ if (file.startsWith(resolvedSrcDir) && file.endsWith('/meta.js')) {
122
126
  console.log('Component meta.js changed, regenerating entry...')
123
127
  const entryPath = join(resolvedSrcDir, entryFileName)
124
- await generateEntryPoint(resolvedSrcDir, entryPath)
128
+ await generateEntryPoint(resolvedSrcDir, entryPath, { componentPaths })
125
129
 
126
130
  // Trigger full reload since entry changed
127
131
  server.ws.send({ type: 'full-reload' })
@@ -131,7 +135,7 @@ export function foundationDevPlugin(options = {}) {
131
135
  if (file.endsWith('/exports.js') || file.endsWith('/exports.jsx')) {
132
136
  console.log('Foundation exports changed, regenerating entry...')
133
137
  const entryPath = join(resolvedSrcDir, entryFileName)
134
- await generateEntryPoint(resolvedSrcDir, entryPath)
138
+ await generateEntryPoint(resolvedSrcDir, entryPath, { componentPaths })
135
139
  server.ws.send({ type: 'full-reload' })
136
140
  }
137
141
  },