@uniweb/build 0.4.10 → 0.6.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/src/i18n/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  extractCollectionContent,
21
21
  buildLocalizedCollections,
22
22
  getCollectionLocales,
23
+ translateCollectionData,
23
24
  COLLECTIONS_DIR
24
25
  } from './collections.js'
25
26
  import { generateSearchIndex, isSearchEnabled } from '../search/index.js'
@@ -69,6 +70,7 @@ export {
69
70
  extractCollectionContent,
70
71
  buildLocalizedCollections,
71
72
  getCollectionLocales,
73
+ translateCollectionData,
72
74
  COLLECTIONS_DIR,
73
75
 
74
76
  // Locale resolution
package/src/prerender.js CHANGED
@@ -26,19 +26,19 @@ let preparePropsSSR, getComponentMetaSSR
26
26
  * @param {Object} siteContent - The site content from site-content.json
27
27
  * @param {string} siteDir - Path to the site directory
28
28
  * @param {function} onProgress - Progress callback
29
- * @returns {Object} { siteCascadedData, pageFetchedData } - Fetched data for dynamic route expansion
29
+ * @returns {Object} { pageFetchedData, fetchedData } - Fetched data for dynamic route expansion and DataStore pre-population
30
30
  */
31
31
  async function executeAllFetches(siteContent, siteDir, onProgress) {
32
32
  const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
33
+ const fetchedData = [] // Collected for DataStore pre-population
33
34
 
34
- // 1. Site-level fetch (cascades to all pages)
35
- let siteCascadedData = {}
35
+ // 1. Site-level fetch
36
36
  const siteFetch = siteContent.config?.fetch
37
37
  if (siteFetch && siteFetch.prerender !== false) {
38
38
  onProgress(` Fetching site data: ${siteFetch.path || siteFetch.url}`)
39
39
  const result = await executeFetch(siteFetch, fetchOptions)
40
40
  if (result.data && !result.error) {
41
- siteCascadedData[siteFetch.schema] = result.data
41
+ fetchedData.push({ config: siteFetch, data: result.data })
42
42
  }
43
43
  }
44
44
 
@@ -46,14 +46,13 @@ async function executeAllFetches(siteContent, siteDir, onProgress) {
46
46
  const pageFetchedData = new Map()
47
47
 
48
48
  for (const page of siteContent.pages || []) {
49
- // Page-level fetch (cascades to sections in this page)
50
- let pageCascadedData = { ...siteCascadedData }
49
+ // Page-level fetch
51
50
  const pageFetch = page.fetch
52
51
  if (pageFetch && pageFetch.prerender !== false) {
53
52
  onProgress(` Fetching page data for ${page.route}: ${pageFetch.path || pageFetch.url}`)
54
53
  const result = await executeFetch(pageFetch, fetchOptions)
55
54
  if (result.data && !result.error) {
56
- pageCascadedData[pageFetch.schema] = result.data
55
+ fetchedData.push({ config: pageFetch, data: result.data })
57
56
  // Store for dynamic route expansion
58
57
  pageFetchedData.set(page.route, {
59
58
  schema: pageFetch.schema,
@@ -62,11 +61,11 @@ async function executeAllFetches(siteContent, siteDir, onProgress) {
62
61
  }
63
62
  }
64
63
 
65
- // Process sections recursively (handles subsections too)
66
- await processSectionFetches(page.sections, pageCascadedData, fetchOptions, onProgress)
64
+ // Process section-level fetches (own fetch → parsedContent.data, not cascaded)
65
+ await processSectionFetches(page.sections, fetchOptions, onProgress)
67
66
  }
68
67
 
69
- return { siteCascadedData, pageFetchedData }
68
+ return { pageFetchedData, fetchedData }
70
69
  }
71
70
 
72
71
  /**
@@ -142,12 +141,6 @@ function expandDynamicPages(pages, pageFetchedData, onProgress) {
142
141
  allItems: items, // All items from parent
143
142
  }
144
143
 
145
- // Also inject into sections' cascadedData for components with inheritData
146
- injectDynamicData(concretePage.sections, {
147
- [singularSchema]: item, // Current item as singular
148
- [schema]: items, // All items as plural
149
- })
150
-
151
144
  // Use item data for page metadata if available
152
145
  if (item.title) concretePage.title = item.title
153
146
  if (item.description || item.excerpt) concretePage.description = item.description || item.excerpt
@@ -159,38 +152,15 @@ function expandDynamicPages(pages, pageFetchedData, onProgress) {
159
152
  return expandedPages
160
153
  }
161
154
 
162
- /**
163
- * Inject dynamic route data into section cascadedData
164
- * This ensures components with inheritData receive the current item
165
- *
166
- * @param {Array} sections - Sections to update
167
- * @param {Object} data - Data to inject { article: {...}, articles: [...] }
168
- */
169
- function injectDynamicData(sections, data) {
170
- if (!sections || !Array.isArray(sections)) return
171
-
172
- for (const section of sections) {
173
- section.cascadedData = {
174
- ...(section.cascadedData || {}),
175
- ...data,
176
- }
177
-
178
- // Recurse into subsections
179
- if (section.subsections && section.subsections.length > 0) {
180
- injectDynamicData(section.subsections, data)
181
- }
182
- }
183
- }
184
-
185
155
  /**
186
156
  * Process fetch configs for sections (and subsections recursively)
157
+ * Section-level fetches merge data into parsedContent.data (not cascaded).
187
158
  *
188
159
  * @param {Array} sections - Array of section objects
189
- * @param {Object} cascadedData - Data cascaded from site/page level
190
160
  * @param {Object} fetchOptions - Options for executeFetch
191
161
  * @param {function} onProgress - Progress callback
192
162
  */
193
- async function processSectionFetches(sections, cascadedData, fetchOptions, onProgress) {
163
+ async function processSectionFetches(sections, fetchOptions, onProgress) {
194
164
  if (!sections || !Array.isArray(sections)) return
195
165
 
196
166
  for (const section of sections) {
@@ -210,12 +180,9 @@ async function processSectionFetches(sections, cascadedData, fetchOptions, onPro
210
180
  }
211
181
  }
212
182
 
213
- // Attach cascaded data for components with inheritData
214
- section.cascadedData = cascadedData
215
-
216
183
  // Process subsections recursively
217
184
  if (section.subsections && section.subsections.length > 0) {
218
- await processSectionFetches(section.subsections, cascadedData, fetchOptions, onProgress)
185
+ await processSectionFetches(section.subsections, fetchOptions, onProgress)
219
186
  }
220
187
  }
221
188
  }
@@ -329,6 +296,13 @@ function getWrapperProps(block) {
329
296
  style.position = 'relative'
330
297
  }
331
298
 
299
+ // Apply context overrides as inline CSS custom properties (mirrors BlockRenderer.jsx)
300
+ if (block.contextOverrides) {
301
+ for (const [key, value] of Object.entries(block.contextOverrides)) {
302
+ style[`--${key}`] = value
303
+ }
304
+ }
305
+
332
306
  const sectionId = block.stableId || block.id
333
307
  return { id: `section-${sectionId}`, style, className, background }
334
308
  }
@@ -372,18 +346,16 @@ function renderBackground(background) {
372
346
 
373
347
  if (background.mode === 'gradient' && background.gradient) {
374
348
  const g = background.gradient
375
- const angle = g.angle || 0
376
- const start = g.start || 'transparent'
377
- const end = g.end || 'transparent'
378
- const startPos = g.startPosition || 0
379
- const endPos = g.endPosition || 100
349
+ // Raw CSS gradient string (e.g., "linear-gradient(to bottom, #000, #333)")
350
+ const bgValue = typeof g === 'string' ? g
351
+ : `linear-gradient(${g.angle || 0}deg, ${g.start || 'transparent'} ${g.startPosition || 0}%, ${g.end || 'transparent'} ${g.endPosition || 100}%)`
380
352
  children.push(
381
353
  React.createElement('div', {
382
354
  key: 'bg-gradient',
383
355
  className: 'background-gradient',
384
356
  style: {
385
357
  position: 'absolute', inset: '0',
386
- background: `linear-gradient(${angle}deg, ${start} ${startPos}%, ${end} ${endPos}%)`
358
+ background: bgValue
387
359
  },
388
360
  'aria-hidden': 'true'
389
361
  })
@@ -449,7 +421,8 @@ function renderBackground(background) {
449
421
 
450
422
  /**
451
423
  * Render a single block for SSR
452
- * Mirrors BlockRenderer.jsx but without hooks (no runtime data fetching in SSR)
424
+ * Mirrors BlockRenderer.jsx but without hooks (no runtime data fetching in SSR).
425
+ * block.dataLoading is always false at prerender time — runtime fetches only happen client-side.
453
426
  */
454
427
  function renderBlock(block) {
455
428
  const Component = block.initComponent()
@@ -462,35 +435,37 @@ function renderBlock(block) {
462
435
  }
463
436
 
464
437
  // Build content and params with runtime guarantees
465
- let content, params
466
-
467
- if (block.parsedContent?._isPoc) {
468
- content = block.parsedContent._pocContent
469
- params = block.properties
470
- } else {
471
- const meta = getComponentMetaSSR(block.type)
472
- const prepared = preparePropsSSR(block, meta)
473
- params = prepared.params
474
- content = {
475
- ...prepared.content,
476
- ...block.properties,
477
- _prosemirror: block.parsedContent
478
- }
438
+ const meta = getComponentMetaSSR(block.type)
439
+ const prepared = preparePropsSSR(block, meta)
440
+ let params = prepared.params
441
+ let content = {
442
+ ...prepared.content,
443
+ ...block.properties,
479
444
  }
480
445
 
481
446
  // Background handling (mirrors BlockRenderer.jsx)
482
447
  const { background, ...wrapperProps } = getWrapperProps(block)
483
- const meta = getComponentMetaSSR(block.type)
484
- const hasBackground = background?.mode && meta?.background !== 'self'
485
448
 
486
- if (hasBackground) {
487
- params = { ...params, _hasBackground: true }
449
+ // Merge Component.className (static classes declared on the component function)
450
+ // Order: context-{theme} + block.state.className + Component.className
451
+ const componentClassName = Component.className
452
+ if (componentClassName) {
453
+ wrapperProps.className = wrapperProps.className
454
+ ? `${wrapperProps.className} ${componentClassName}`
455
+ : componentClassName
488
456
  }
489
457
 
458
+ const hasBackground = background?.mode && meta?.background !== 'self'
459
+
460
+ block.hasBackground = hasBackground
461
+
462
+ // Use Component.as as the wrapper tag (default: 'section')
463
+ const wrapperTag = Component.as || 'section'
464
+
490
465
  const componentProps = { content, params, block }
491
466
 
492
467
  if (hasBackground) {
493
- return React.createElement('section', wrapperProps,
468
+ return React.createElement(wrapperTag, wrapperProps,
494
469
  renderBackground(background),
495
470
  React.createElement('div', { className: 'relative z-10' },
496
471
  React.createElement(Component, componentProps)
@@ -498,7 +473,7 @@ function renderBlock(block) {
498
473
  )
499
474
  }
500
475
 
501
- return React.createElement('section', wrapperProps,
476
+ return React.createElement(wrapperTag, wrapperProps,
502
477
  React.createElement(Component, componentProps)
503
478
  )
504
479
  }
@@ -678,7 +653,10 @@ export async function prerenderSite(siteDir, options = {}) {
678
653
 
679
654
  // Execute data fetches (site, page, section levels)
680
655
  onProgress('Executing data fetches...')
681
- const { siteCascadedData, pageFetchedData } = await executeAllFetches(siteContent, siteDir, onProgress)
656
+ const { pageFetchedData, fetchedData } = await executeAllFetches(siteContent, siteDir, onProgress)
657
+
658
+ // Store fetchedData on siteContent for runtime DataStore pre-population
659
+ siteContent.fetchedData = fetchedData
682
660
 
683
661
  // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
684
662
  if (siteContent.pages?.some(p => p.isDynamic)) {
@@ -693,6 +671,14 @@ export async function prerenderSite(siteDir, options = {}) {
693
671
  // Initialize the Uniweb runtime for this locale
694
672
  onProgress('Initializing runtime...')
695
673
  const uniweb = createUniweb(siteContent)
674
+
675
+ // Pre-populate DataStore so EntityStore can resolve data during prerender
676
+ if (fetchedData.length > 0 && uniweb.activeWebsite?.dataStore) {
677
+ for (const entry of fetchedData) {
678
+ uniweb.activeWebsite.dataStore.set(entry.config, entry.data)
679
+ }
680
+ }
681
+
696
682
  uniweb.setFoundation(foundation)
697
683
 
698
684
  // Set base path from site config so components can access it during SSR
@@ -197,15 +197,36 @@ export function extractRuntimeSchema(fullMeta) {
197
197
  }
198
198
 
199
199
  // Data binding (CMS entities)
200
+ // Supports both old format (data: 'person:6') and new consolidated format
201
+ // (data: { entity: 'person:6', schemas: {...}, inherit: [...] })
200
202
  if (fullMeta.data) {
201
- const parsed = parseDataString(fullMeta.data)
202
- if (parsed) {
203
- runtime.data = parsed
203
+ if (typeof fullMeta.data === 'string') {
204
+ // Old format: data: 'person:6'
205
+ const parsed = parseDataString(fullMeta.data)
206
+ if (parsed) {
207
+ runtime.data = parsed
208
+ }
209
+ } else if (typeof fullMeta.data === 'object') {
210
+ // New format: data: { entity, schemas, inherit }
211
+ if (fullMeta.data.entity) {
212
+ const parsed = parseDataString(fullMeta.data.entity)
213
+ if (parsed) {
214
+ runtime.data = parsed
215
+ }
216
+ }
217
+ if (fullMeta.data.schemas) {
218
+ const schemas = extractSchemas(fullMeta.data.schemas)
219
+ if (schemas) {
220
+ runtime.schemas = schemas
221
+ }
222
+ }
223
+ if (fullMeta.data.inherit !== undefined) {
224
+ runtime.inheritData = fullMeta.data.inherit
225
+ }
204
226
  }
205
227
  }
206
228
 
207
- // Param defaults - support both v2 'params' and v1 'properties'
208
- const paramsObj = fullMeta.params || fullMeta.properties
229
+ const paramsObj = fullMeta.params
209
230
  const defaults = extractParamDefaults(paramsObj)
210
231
  if (defaults) {
211
232
  runtime.defaults = defaults
@@ -225,7 +246,8 @@ export function extractRuntimeSchema(fullMeta) {
225
246
 
226
247
  // Schemas - lean version for runtime validation/defaults
227
248
  // Strips editor-only fields (label, hint, description)
228
- if (fullMeta.schemas) {
249
+ // Top-level schemas supported for backwards compat (lower priority than data.schemas)
250
+ if (fullMeta.schemas && !runtime.schemas) {
229
251
  const schemas = extractSchemas(fullMeta.schemas)
230
252
  if (schemas) {
231
253
  runtime.schemas = schemas
@@ -234,10 +256,17 @@ export function extractRuntimeSchema(fullMeta) {
234
256
 
235
257
  // Data inheritance - component receives cascaded data from page/site level fetches
236
258
  // Can be: true (inherit all), false (inherit none), or ['schema1', 'schema2'] (selective)
237
- if (fullMeta.inheritData !== undefined) {
259
+ // Top-level inheritData supported for backwards compat (lower priority than data.inherit)
260
+ if (fullMeta.inheritData !== undefined && runtime.inheritData === undefined) {
238
261
  runtime.inheritData = fullMeta.inheritData
239
262
  }
240
263
 
264
+ // Auto-derive inheritData from entity type when no explicit inherit is set.
265
+ // data: { entity: 'articles' } implies inheritData: ['articles']
266
+ if (runtime.data && runtime.inheritData === undefined) {
267
+ runtime.inheritData = [runtime.data.type]
268
+ }
269
+
241
270
  return Object.keys(runtime).length > 0 ? runtime : null
242
271
  }
243
272
 
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)