@uniweb/build 0.4.9 → 0.5.0

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/README.md CHANGED
@@ -15,7 +15,7 @@ npm install @uniweb/build --save-dev
15
15
  ## Features
16
16
 
17
17
  **For Foundations:**
18
- - **Component Discovery** - Automatically discovers components from `src/components/*/meta.js`
18
+ - **Component Discovery** - Discovers section types from `src/sections/` (implicit at root) and `src/components/` (requires `meta.js`)
19
19
  - **Entry Generation** - Generates the foundation entry point with all exports
20
20
  - **Schema Building** - Creates `schema.json` with full component metadata for editors
21
21
  - **Image Processing** - Converts preview images to WebP format
@@ -263,7 +263,7 @@ src/
263
263
  ### Component Meta File
264
264
 
265
265
  ```js
266
- // src/components/Hero/meta.js
266
+ // src/sections/Hero/meta.js
267
267
  export default {
268
268
  title: 'Hero Banner',
269
269
  description: 'A prominent header section',
@@ -367,7 +367,7 @@ Identity fields (`name`, `version`, `description`) come from the foundation's `p
367
367
 
368
368
  | Function | Description |
369
369
  |----------|-------------|
370
- | `discoverComponents(srcDir)` | Discover all exposed components |
370
+ | `discoverComponents(srcDir)` | Discover all section types (folders with meta.js) |
371
371
  | `loadComponentMeta(componentDir)` | Load meta file for a component |
372
372
  | `loadPackageJson(srcDir)` | Load identity from package.json |
373
373
  | `loadFoundationConfig(srcDir)` | Load foundation.js configuration |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "optionalDependencies": {
53
53
  "@uniweb/content-reader": "1.1.2",
54
- "@uniweb/runtime": "0.5.10"
54
+ "@uniweb/runtime": "0.5.11"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -48,9 +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
+ * @param {string[]} [options.components] - Paths to scan for content interfaces (relative to src/).
52
+ * Default: ['sections', 'components']
53
+ * Example: ['sections', 'sections/marketing']
54
54
  * @param {string[]} [options.externals] - Additional packages to externalize
55
55
  * @param {boolean} [options.includeDefaultExternals] - Include default externals (default: true)
56
56
  * @param {Array} [options.plugins] - Additional Vite plugins
@@ -112,10 +112,10 @@ function generateEntrySource(components, options = {}) {
112
112
  lines.push(`import capabilities from '${foundationExports.path}'`)
113
113
  }
114
114
 
115
- // Component imports (use component's path and detected extension)
115
+ // Component imports
116
116
  for (const name of componentNames) {
117
- const { path, ext = 'js' } = components[name]
118
- lines.push(`import ${name} from './${path}/index.${ext}'`)
117
+ const { path, entryFile = `index.js` } = components[name]
118
+ lines.push(`import ${name} from './${path}/${entryFile}'`)
119
119
  }
120
120
 
121
121
  lines.push('')
@@ -153,19 +153,33 @@ function generateEntrySource(components, options = {}) {
153
153
  }
154
154
 
155
155
  /**
156
- * Detect the index file extension for a component
156
+ * Detect the entry file for a component
157
+ *
158
+ * Supports two conventions:
159
+ * - index.jsx (default)
160
+ * - ComponentName.jsx (named file matching the directory name)
161
+ *
162
+ * Named files are checked first so that Hero/Hero.jsx takes precedence
163
+ * over Hero/index.jsx when both exist (the named file is more intentional).
157
164
  *
158
165
  * @param {string} srcDir - Source directory
159
166
  * @param {string} componentPath - Relative path to component (e.g., 'components/Hero')
167
+ * @param {string} componentName - Component name (e.g., 'Hero')
168
+ * @returns {{ file: string, ext: string }} Entry file name and extension
160
169
  */
161
- function detectComponentExtension(srcDir, componentPath) {
170
+ function detectComponentEntry(srcDir, componentPath, componentName) {
162
171
  const basePath = join(srcDir, componentPath)
163
172
  for (const ext of ['jsx', 'tsx', 'js', 'ts']) {
173
+ // Check named file first: Hero/Hero.jsx
174
+ if (existsSync(join(basePath, `${componentName}.${ext}`))) {
175
+ return { file: `${componentName}.${ext}`, ext }
176
+ }
177
+ // Then index file: Hero/index.jsx
164
178
  if (existsSync(join(basePath, `index.${ext}`))) {
165
- return ext
179
+ return { file: `index.${ext}`, ext }
166
180
  }
167
181
  }
168
- return 'js' // default
182
+ return { file: 'index.js', ext: 'js' } // default
169
183
  }
170
184
 
171
185
  /**
@@ -184,13 +198,18 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
184
198
  const componentNames = Object.keys(components).sort()
185
199
 
186
200
  if (componentNames.length === 0) {
187
- console.warn('Warning: No exposed components found')
201
+ console.warn('Warning: No section types found')
188
202
  }
189
203
 
190
- // Detect extensions for each component and add to component info
204
+ // Detect entry files for each component
205
+ // Bare files discovered in sections/ already have entryFile set — skip detection for those
191
206
  for (const name of componentNames) {
192
207
  const component = components[name]
193
- component.ext = detectComponentExtension(srcDir, component.path)
208
+ if (!component.entryFile) {
209
+ const entry = detectComponentEntry(srcDir, component.path, component.name)
210
+ component.ext = entry.ext
211
+ component.entryFile = entry.file
212
+ }
194
213
  }
195
214
 
196
215
  // Check for CSS file
package/src/schema.js CHANGED
@@ -3,12 +3,18 @@
3
3
  *
4
4
  * Discovers component meta files and loads them for schema.json generation.
5
5
  * Schema data is for editor-time only, not runtime.
6
+ *
7
+ * Discovery rules:
8
+ * - sections/ root: bare files and folders are addressable by default (implicit empty meta)
9
+ * - sections/ nested: meta.js required for addressability
10
+ * - components/ (and other paths): meta.js required (backward compatibility)
6
11
  */
7
12
 
8
13
  import { readdir, readFile } from 'node:fs/promises'
9
14
  import { existsSync } from 'node:fs'
10
- import { join, dirname } from 'node:path'
15
+ import { join, dirname, extname, basename } from 'node:path'
11
16
  import { pathToFileURL } from 'node:url'
17
+ import { inferTitle } from './utils/infer-title.js'
12
18
 
13
19
  // Component meta file name
14
20
  const META_FILE_NAME = 'meta.js'
@@ -16,8 +22,15 @@ const META_FILE_NAME = 'meta.js'
16
22
  // Foundation config file name
17
23
  const FOUNDATION_FILE_NAME = 'foundation.js'
18
24
 
19
- // Default component paths (relative to srcDir)
20
- const DEFAULT_COMPONENT_PATHS = ['components']
25
+ // Default paths to scan for content interfaces (relative to srcDir)
26
+ // sections/ is the primary convention; components/ supported for backward compatibility
27
+ const DEFAULT_COMPONENT_PATHS = ['sections', 'components']
28
+
29
+ // Extensions recognized as component entry files
30
+ const COMPONENT_EXTENSIONS = new Set(['.jsx', '.tsx', '.js', '.ts'])
31
+
32
+ // The primary sections path where relaxed discovery applies
33
+ const SECTIONS_PATH = 'sections'
21
34
 
22
35
  /**
23
36
  * Load a meta.js file via dynamic import
@@ -113,12 +126,173 @@ export async function loadFoundationMeta(srcDir) {
113
126
  }
114
127
 
115
128
  /**
116
- * Discover components in a single path
129
+ * Check if a filename looks like a PascalCase component (starts with uppercase)
130
+ */
131
+ function isComponentFileName(name) {
132
+ return /^[A-Z]/.test(name)
133
+ }
134
+
135
+ /**
136
+ * Check if a directory has a valid entry file (Name.ext or index.ext)
137
+ */
138
+ function hasEntryFile(dirPath, dirName) {
139
+ for (const ext of ['.jsx', '.tsx', '.js', '.ts']) {
140
+ if (existsSync(join(dirPath, `${dirName}${ext}`))) return true
141
+ if (existsSync(join(dirPath, `index${ext}`))) return true
142
+ }
143
+ return false
144
+ }
145
+
146
+ /**
147
+ * Create an implicit empty meta for a section type discovered without meta.js
148
+ */
149
+ function createImplicitMeta(name) {
150
+ return { title: inferTitle(name) }
151
+ }
152
+
153
+ /**
154
+ * Build a component entry with title inference applied
155
+ */
156
+ function buildComponentEntry(name, relativePath, meta) {
157
+ const entry = {
158
+ name,
159
+ path: relativePath,
160
+ ...meta,
161
+ }
162
+ // Apply title inference if meta has no explicit title
163
+ if (!entry.title) {
164
+ entry.title = inferTitle(name)
165
+ }
166
+ return entry
167
+ }
168
+
169
+ /**
170
+ * Discover section types in sections/ with relaxed rules
171
+ *
172
+ * Root level: bare files and folders are addressable by default.
173
+ * Nested levels: meta.js required for addressability.
174
+ *
117
175
  * @param {string} srcDir - Source directory (e.g., 'src')
118
- * @param {string} relativePath - Path relative to srcDir (e.g., 'components' or 'components/sections')
176
+ * @param {string} sectionsRelPath - Relative path to sections dir (e.g., 'sections')
177
+ */
178
+ async function discoverSectionsInPath(srcDir, sectionsRelPath) {
179
+ const fullPath = join(srcDir, sectionsRelPath)
180
+
181
+ if (!existsSync(fullPath)) {
182
+ return {}
183
+ }
184
+
185
+ const entries = await readdir(fullPath, { withFileTypes: true })
186
+ const components = {}
187
+
188
+ // Collect names from both files and directories to detect collisions
189
+ const fileNames = new Set()
190
+ const dirNames = new Set()
191
+
192
+ for (const entry of entries) {
193
+ const ext = extname(entry.name)
194
+ if (entry.isFile() && COMPONENT_EXTENSIONS.has(ext)) {
195
+ const name = basename(entry.name, ext)
196
+ if (isComponentFileName(name)) {
197
+ fileNames.add(name)
198
+ }
199
+ } else if (entry.isDirectory()) {
200
+ dirNames.add(entry.name)
201
+ }
202
+ }
203
+
204
+ // Check for name collisions (e.g., Hero.jsx AND Hero/)
205
+ for (const name of fileNames) {
206
+ if (dirNames.has(name)) {
207
+ throw new Error(
208
+ `Name collision in ${sectionsRelPath}/: both "${name}.jsx" (or similar) and "${name}/" exist. ` +
209
+ `Use one or the other, not both.`
210
+ )
211
+ }
212
+ }
213
+
214
+ // Discover bare files at root
215
+ for (const entry of entries) {
216
+ if (!entry.isFile()) continue
217
+ const ext = extname(entry.name)
218
+ if (!COMPONENT_EXTENSIONS.has(ext)) continue
219
+ const name = basename(entry.name, ext)
220
+ if (!isComponentFileName(name)) continue
221
+
222
+ const meta = createImplicitMeta(name)
223
+ components[name] = {
224
+ ...buildComponentEntry(name, sectionsRelPath, meta),
225
+ // Bare file: the entry file IS the file itself (not inside a subdirectory)
226
+ entryFile: entry.name,
227
+ }
228
+ }
229
+
230
+ // Discover directories at root
231
+ for (const entry of entries) {
232
+ if (!entry.isDirectory()) continue
233
+ if (!isComponentFileName(entry.name)) continue
234
+
235
+ const dirPath = join(fullPath, entry.name)
236
+ const relativePath = join(sectionsRelPath, entry.name)
237
+ const result = await loadComponentMeta(dirPath)
238
+
239
+ if (result && result.meta) {
240
+ // Has meta.js — use explicit meta
241
+ if (result.meta.exposed === false) continue
242
+ components[entry.name] = buildComponentEntry(entry.name, relativePath, result.meta)
243
+ } else if (hasEntryFile(dirPath, entry.name)) {
244
+ // No meta.js but has entry file — implicit section type at root
245
+ components[entry.name] = buildComponentEntry(entry.name, relativePath, createImplicitMeta(entry.name))
246
+ }
247
+
248
+ // Recurse into subdirectories for nested section types (meta.js required)
249
+ await discoverNestedSections(srcDir, dirPath, relativePath, components)
250
+ }
251
+
252
+ return components
253
+ }
254
+
255
+ /**
256
+ * Recursively discover nested section types that have meta.js
257
+ *
258
+ * @param {string} srcDir - Source directory
259
+ * @param {string} parentFullPath - Absolute path to parent directory
260
+ * @param {string} parentRelPath - Relative path from srcDir to parent
261
+ * @param {Object} components - Accumulator for discovered components
262
+ */
263
+ async function discoverNestedSections(srcDir, parentFullPath, parentRelPath, components) {
264
+ let entries
265
+ try {
266
+ entries = await readdir(parentFullPath, { withFileTypes: true })
267
+ } catch {
268
+ return
269
+ }
270
+
271
+ for (const entry of entries) {
272
+ if (!entry.isDirectory()) continue
273
+
274
+ const dirPath = join(parentFullPath, entry.name)
275
+ const relativePath = join(parentRelPath, entry.name)
276
+ const result = await loadComponentMeta(dirPath)
277
+
278
+ if (result && result.meta) {
279
+ if (result.meta.exposed === false) continue
280
+ components[entry.name] = buildComponentEntry(entry.name, relativePath, result.meta)
281
+ }
282
+
283
+ // Continue recursing regardless — deeper levels may have meta.js
284
+ await discoverNestedSections(srcDir, dirPath, relativePath, components)
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Discover components in a non-sections path (meta.js required)
290
+ *
291
+ * @param {string} srcDir - Source directory (e.g., 'src')
292
+ * @param {string} relativePath - Path relative to srcDir (e.g., 'components')
119
293
  * @returns {Object} Map of componentName -> { name, path, ...meta }
120
294
  */
121
- async function discoverComponentsInPath(srcDir, relativePath) {
295
+ async function discoverExplicitComponentsInPath(srcDir, relativePath) {
122
296
  const fullPath = join(srcDir, relativePath)
123
297
 
124
298
  if (!existsSync(fullPath)) {
@@ -135,16 +309,12 @@ async function discoverComponentsInPath(srcDir, relativePath) {
135
309
  const result = await loadComponentMeta(componentDir)
136
310
 
137
311
  if (result && result.meta) {
138
- // Check if explicitly not exposed
312
+ // Check if explicitly hidden from discovery
139
313
  if (result.meta.exposed === false) {
140
314
  continue
141
315
  }
142
316
 
143
- components[entry.name] = {
144
- name: entry.name,
145
- path: join(relativePath, entry.name), // e.g., 'components/Hero' or 'components/sections/Hero'
146
- ...result.meta,
147
- }
317
+ components[entry.name] = buildComponentEntry(entry.name, join(relativePath, entry.name), result.meta)
148
318
  }
149
319
  }
150
320
 
@@ -152,18 +322,25 @@ async function discoverComponentsInPath(srcDir, relativePath) {
152
322
  }
153
323
 
154
324
  /**
155
- * Discover all exposed components in a foundation
325
+ * Discover all section types in a foundation
326
+ *
327
+ * For the 'sections' path: relaxed discovery (bare files and folders at root,
328
+ * meta.js required for nested levels).
329
+ * For other paths: strict discovery (meta.js required).
156
330
  *
157
331
  * @param {string} srcDir - Source directory (e.g., 'src')
158
- * @param {string[]} [componentPaths] - Paths to search for components (relative to srcDir)
159
- * Default: ['components']
160
- * @returns {Object} Map of componentName -> { name, path, ...meta }
332
+ * @param {string[]} [componentPaths] - Paths to scan for section types (relative to srcDir).
333
+ * Default: ['sections', 'components']
334
+ * @returns {Object} Map of sectionTypeName -> { name, path, ...meta }
161
335
  */
162
336
  export async function discoverComponents(srcDir, componentPaths = DEFAULT_COMPONENT_PATHS) {
163
337
  const components = {}
164
338
 
165
339
  for (const relativePath of componentPaths) {
166
- const found = await discoverComponentsInPath(srcDir, relativePath)
340
+ // Use relaxed discovery for the primary sections path
341
+ const found = relativePath === SECTIONS_PATH
342
+ ? await discoverSectionsInPath(srcDir, relativePath)
343
+ : await discoverExplicitComponentsInPath(srcDir, relativePath)
167
344
 
168
345
  for (const [name, meta] of Object.entries(found)) {
169
346
  if (components[name]) {
@@ -210,10 +387,10 @@ export async function buildSchema(srcDir, componentPaths) {
210
387
  }
211
388
 
212
389
  /**
213
- * Get list of exposed component names
390
+ * Get list of section type names
214
391
  *
215
392
  * @param {string} srcDir - Source directory
216
- * @param {string[]} [componentPaths] - Paths to search for components
393
+ * @param {string[]} [componentPaths] - Paths to scan for section types
217
394
  */
218
395
  export async function getExposedComponents(srcDir, componentPaths) {
219
396
  const components = await discoverComponents(srcDir, componentPaths)
@@ -261,6 +261,18 @@ export function rewriteParamPaths(params, pathMapping) {
261
261
  }
262
262
  }
263
263
 
264
+ // Rewrite nested paths in structured background object
265
+ if (result.background && typeof result.background === 'object') {
266
+ const bg = { ...result.background }
267
+ if (bg.image?.src && pathMapping[bg.image.src]) {
268
+ bg.image = { ...bg.image, src: pathMapping[bg.image.src] }
269
+ }
270
+ if (bg.video?.src && pathMapping[bg.video.src]) {
271
+ bg.video = { ...bg.video, src: pathMapping[bg.video.src] }
272
+ }
273
+ result.background = bg
274
+ }
275
+
264
276
  return result
265
277
  }
266
278
 
@@ -130,8 +130,9 @@ export function resolveAssetPath(src, contextPath, siteRoot) {
130
130
  return { src, resolved: null, external: true }
131
131
  }
132
132
 
133
- // Already absolute path on filesystem
134
- if (isAbsolute(src)) {
133
+ // Already absolute path on filesystem (e.g., /Users/foo/bar.jpg)
134
+ // Must actually exist — otherwise it's a site-relative path like /images/hero.jpg
135
+ if (isAbsolute(src) && existsSync(src)) {
135
136
  return { src, resolved: src, external: false }
136
137
  }
137
138
 
@@ -281,6 +282,27 @@ export function collectSectionAssets(section, markdownPath, siteRoot) {
281
282
  }
282
283
  }
283
284
 
285
+ // Collect from structured background object
286
+ // Background can be { image: { src }, video: { src } } with nested asset paths
287
+ const bg = section.params?.background
288
+ if (bg && typeof bg === 'object') {
289
+ const bgSources = [bg.image?.src, bg.video?.src].filter(Boolean)
290
+ for (const src of bgSources) {
291
+ if (typeof src === 'string') {
292
+ const result = resolveAssetPath(src, markdownPath, siteRoot)
293
+ if (!result.external && result.resolved) {
294
+ assets[src] = {
295
+ original: src,
296
+ resolved: result.resolved,
297
+ isImage: result.isImage,
298
+ isVideo: result.isVideo,
299
+ isPdf: result.isPdf
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+
284
306
  // Collect from tagged code blocks (JSON/YAML data)
285
307
  if (section.content) {
286
308
  walkDataBlockAssets(section.content, (assetPath) => {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Infer display title from PascalCase component name.
3
+ *
4
+ * TeamRoster → "Team Roster"
5
+ * CTA → "CTA"
6
+ * FAQSection → "FAQ Section"
7
+ * Hero → "Hero"
8
+ *
9
+ * @param {string} name - PascalCase component name
10
+ * @returns {string} Human-readable title
11
+ */
12
+ export function inferTitle(name) {
13
+ return name
14
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
15
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
16
+ }
@@ -120,21 +120,33 @@ export function foundationDevPlugin(options = {}) {
120
120
  },
121
121
 
122
122
  async handleHotUpdate({ file, server }) {
123
+ const entryPath = join(resolvedSrcDir, entryFileName)
124
+
123
125
  // Regenerate entry when meta.js files change
124
- // Check if file is a meta.js in the src directory
125
126
  if (file.startsWith(resolvedSrcDir) && file.endsWith('/meta.js')) {
126
127
  console.log('Component meta.js changed, regenerating entry...')
127
- const entryPath = join(resolvedSrcDir, entryFileName)
128
128
  await generateEntryPoint(resolvedSrcDir, entryPath, { componentPaths })
129
-
130
- // Trigger full reload since entry changed
131
129
  server.ws.send({ type: 'full-reload' })
130
+ return
131
+ }
132
+
133
+ // Regenerate when component files are added/removed in sections/ root
134
+ // (bare file discovery means any .jsx/.tsx/.js/.ts at sections root is a section type)
135
+ const sectionsDir = join(resolvedSrcDir, 'sections')
136
+ if (file.startsWith(sectionsDir)) {
137
+ const relative = file.slice(sectionsDir.length + 1)
138
+ // Direct child of sections/ (no further slashes) — could be a new/removed bare file
139
+ if (!relative.includes('/') && /\.(jsx|tsx|js|ts)$/.test(relative)) {
140
+ console.log('Section file changed, regenerating entry...')
141
+ await generateEntryPoint(resolvedSrcDir, entryPath, { componentPaths })
142
+ server.ws.send({ type: 'full-reload' })
143
+ return
144
+ }
132
145
  }
133
146
 
134
147
  // Also regenerate if exports.js changes
135
148
  if (file.endsWith('/exports.js') || file.endsWith('/exports.jsx')) {
136
149
  console.log('Foundation exports changed, regenerating entry...')
137
- const entryPath = join(resolvedSrcDir, entryFileName)
138
150
  await generateEntryPoint(resolvedSrcDir, entryPath, { componentPaths })
139
151
  server.ws.send({ type: 'full-reload' })
140
152
  }