@uniweb/build 0.5.0 → 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
 
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * Collection Processor
3
3
  *
4
- * Processes content collections from markdown files into JSON data.
4
+ * Processes content collections from markdown and YAML files into JSON data.
5
5
  * Collections are defined in site.yml and processed at build time.
6
6
  *
7
7
  * Features:
8
- * - Discovers markdown files in collection folders
9
- * - Parses frontmatter for metadata
8
+ * - Discovers markdown (.md), data (.yml/.yaml), and JSON (.json) files in collection folders
9
+ * - Parses frontmatter for metadata (markdown), full YAML (data items), or JSON (data items)
10
10
  * - Converts markdown body to ProseMirror JSON
11
11
  * - Supports filtering, sorting, and limiting
12
- * - Auto-generates excerpts and extracts first images
12
+ * - Auto-generates excerpts and extracts first images (markdown items only)
13
13
  *
14
14
  * @module @uniweb/build/site/collection-processor
15
15
  *
@@ -315,6 +315,53 @@ async function processCollectionAssets(content, itemPath, siteRoot, collectionNa
315
315
 
316
316
  // Filter and sort utilities are imported from data-fetcher.js
317
317
 
318
+ /**
319
+ * Process a single data item from a YAML file
320
+ *
321
+ * YAML items are pure data — no ProseMirror conversion, no body, no excerpt,
322
+ * no image extraction, no lastModified. The output is slug + YAML fields.
323
+ *
324
+ * @param {string} dir - Collection directory path
325
+ * @param {string} filename - YAML filename (.yml or .yaml)
326
+ * @returns {Promise<Object|null>} Processed item or null if unpublished
327
+ */
328
+ async function processDataItem(dir, filename) {
329
+ const filepath = join(dir, filename)
330
+ const raw = await readFile(filepath, 'utf-8')
331
+ const slug = basename(filename, extname(filename))
332
+ const data = yaml.load(raw) || {}
333
+
334
+ // Skip unpublished items
335
+ if (data.published === false) return null
336
+
337
+ return { slug, ...data }
338
+ }
339
+
340
+ /**
341
+ * Process a single data item from a JSON file
342
+ *
343
+ * JSON items are pure data — like YAML items, no ProseMirror conversion.
344
+ * A JSON file containing an array returns all items (single-file collection).
345
+ * A JSON file containing an object returns a single item with slug from filename.
346
+ *
347
+ * @param {string} dir - Collection directory path
348
+ * @param {string} filename - JSON filename
349
+ * @returns {Promise<Object|Array|null>} Processed item(s) or null if unpublished
350
+ */
351
+ async function processJsonItem(dir, filename) {
352
+ const filepath = join(dir, filename)
353
+ const raw = await readFile(filepath, 'utf-8')
354
+ const slug = basename(filename, '.json')
355
+ const data = JSON.parse(raw)
356
+
357
+ // Array → multiple items (single-file collection)
358
+ if (Array.isArray(data)) return data
359
+
360
+ // Object → single item
361
+ if (data.published === false) return null
362
+ return { slug, ...data }
363
+ }
364
+
318
365
  /**
319
366
  * Process a single content item from a markdown file
320
367
  *
@@ -384,13 +431,27 @@ async function collectItems(siteDir, config) {
384
431
  }
385
432
 
386
433
  const files = await readdir(collectionDir)
387
- const mdFiles = files.filter(f => f.endsWith('.md') && !f.startsWith('_'))
434
+ const itemFiles = files.filter(f =>
435
+ !f.startsWith('_') &&
436
+ (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json'))
437
+ )
388
438
 
389
- // Process all markdown files
439
+ // Process all collection files (markdown → content items, YAML/JSON → data items)
390
440
  let items = await Promise.all(
391
- mdFiles.map(file => processContentItem(collectionDir, file, config, siteDir))
441
+ itemFiles.map(file => {
442
+ if (file.endsWith('.json')) {
443
+ return processJsonItem(collectionDir, file)
444
+ }
445
+ if (file.endsWith('.yml') || file.endsWith('.yaml')) {
446
+ return processDataItem(collectionDir, file)
447
+ }
448
+ return processContentItem(collectionDir, file, config, siteDir)
449
+ })
392
450
  )
393
451
 
452
+ // Flatten arrays from JSON files that contain multiple items
453
+ items = items.flat()
454
+
394
455
  // Filter out nulls (unpublished items)
395
456
  items = items.filter(Boolean)
396
457
 
@@ -496,11 +557,14 @@ export async function getCollectionLastModified(siteDir, config) {
496
557
  }
497
558
 
498
559
  const files = await readdir(collectionDir)
499
- const mdFiles = files.filter(f => f.endsWith('.md') && !f.startsWith('_'))
560
+ const itemFiles = files.filter(f =>
561
+ !f.startsWith('_') &&
562
+ (f.endsWith('.md') || f.endsWith('.yml') || f.endsWith('.yaml') || f.endsWith('.json'))
563
+ )
500
564
 
501
565
  let lastModified = null
502
566
 
503
- for (const file of mdFiles) {
567
+ for (const file of itemFiles) {
504
568
  const fileStat = await stat(join(collectionDir, file))
505
569
  if (!lastModified || fileStat.mtime > lastModified) {
506
570
  lastModified = fileStat.mtime
@@ -245,9 +245,10 @@ export function parseFetchConfig(fetch) {
245
245
  path,
246
246
  url,
247
247
  schema,
248
- prerender = true,
248
+ prerender = url ? false : true,
249
249
  merge = false,
250
250
  transform,
251
+ detail,
251
252
  // Post-processing options (also supported for path/url fetches)
252
253
  limit,
253
254
  sort,
@@ -264,6 +265,7 @@ export function parseFetchConfig(fetch) {
264
265
  prerender,
265
266
  merge,
266
267
  transform,
268
+ detail,
267
269
  // Post-processing options
268
270
  limit,
269
271
  sort,
@@ -41,50 +41,51 @@ import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
41
41
 
42
42
  /**
43
43
  * Execute all fetches for site content (used in dev mode)
44
- * Populates section.cascadedData with fetched data
44
+ * Collects fetchedData for DataStore pre-population at runtime
45
45
  *
46
46
  * @param {Object} siteContent - The collected site content
47
47
  * @param {string} siteDir - Path to site directory
48
48
  */
49
49
  async function executeDevFetches(siteContent, siteDir) {
50
50
  const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
51
+ const fetchedData = []
51
52
 
52
53
  // Site-level fetch
53
- let siteCascadedData = {}
54
54
  const siteFetch = siteContent.config?.fetch
55
55
  if (siteFetch) {
56
56
  const result = await executeFetch(siteFetch, fetchOptions)
57
57
  if (result.data && !result.error) {
58
- siteCascadedData[siteFetch.schema] = result.data
58
+ fetchedData.push({ config: siteFetch, data: result.data })
59
59
  }
60
60
  }
61
61
 
62
62
  // Process each page
63
63
  for (const page of siteContent.pages || []) {
64
- let pageCascadedData = { ...siteCascadedData }
65
-
66
64
  // Page-level fetch
67
65
  const pageFetch = page.fetch
68
66
  if (pageFetch) {
69
67
  const result = await executeFetch(pageFetch, fetchOptions)
70
68
  if (result.data && !result.error) {
71
- pageCascadedData[pageFetch.schema] = result.data
69
+ fetchedData.push({ config: pageFetch, data: result.data })
72
70
  }
73
71
  }
74
72
 
75
- // Process sections
76
- await processDevSectionFetches(page.sections, pageCascadedData, fetchOptions)
73
+ // Process section-level fetches (own fetch → parsedContent.data)
74
+ await processDevSectionFetches(page.sections, fetchOptions)
77
75
  }
76
+
77
+ // Store on siteContent for runtime DataStore pre-population
78
+ siteContent.fetchedData = fetchedData
78
79
  }
79
80
 
80
81
  /**
81
82
  * Process fetches for sections recursively
83
+ * Section-level fetches merge data into parsedContent.data (not cascaded).
82
84
  *
83
85
  * @param {Array} sections - Sections to process
84
- * @param {Object} cascadedData - Data from parent levels
85
86
  * @param {Object} fetchOptions - Options for executeFetch
86
87
  */
87
- async function processDevSectionFetches(sections, cascadedData, fetchOptions) {
88
+ async function processDevSectionFetches(sections, fetchOptions) {
88
89
  if (!sections || !Array.isArray(sections)) return
89
90
 
90
91
  for (const section of sections) {
@@ -104,13 +105,9 @@ async function processDevSectionFetches(sections, cascadedData, fetchOptions) {
104
105
  }
105
106
  }
106
107
 
107
- // Attach cascaded data for components with inheritData
108
- // Note: cascadedData is from page/site level only, not section's own fetch
109
- section.cascadedData = cascadedData
110
-
111
108
  // Process subsections recursively
112
109
  if (section.subsections && section.subsections.length > 0) {
113
- await processDevSectionFetches(section.subsections, cascadedData, fetchOptions)
110
+ await processDevSectionFetches(section.subsections, fetchOptions)
114
111
  }
115
112
  }
116
113
  }
@@ -376,6 +373,7 @@ export function siteContentPlugin(options = {}) {
376
373
  let watcher = null
377
374
  let server = null
378
375
  let localeTranslations = {} // Cache: { locale: translations }
376
+ let collectionTranslations = {} // Cache: { locale: collection translations }
379
377
  let localesDir = 'locales' // Default, updated from site config
380
378
  let collectionsConfig = null // Cached for watcher setup
381
379
 
@@ -402,6 +400,29 @@ export function siteContentPlugin(options = {}) {
402
400
  }
403
401
  }
404
402
 
403
+ /**
404
+ * Load collection translations for a specific locale
405
+ */
406
+ async function loadCollectionTranslations(locale) {
407
+ if (collectionTranslations[locale]) {
408
+ return collectionTranslations[locale]
409
+ }
410
+
411
+ const localePath = join(resolvedSitePath, localesDir, 'collections', `${locale}.json`)
412
+ if (!existsSync(localePath)) {
413
+ return null
414
+ }
415
+
416
+ try {
417
+ const content = await readFile(localePath, 'utf-8')
418
+ const translations = JSON.parse(content)
419
+ collectionTranslations[locale] = translations
420
+ return translations
421
+ } catch {
422
+ return null
423
+ }
424
+ }
425
+
405
426
  /**
406
427
  * Get available locales from locales directory
407
428
  */
@@ -504,6 +525,7 @@ export function siteContentPlugin(options = {}) {
504
525
 
505
526
  // Clear translation cache on rebuild
506
527
  localeTranslations = {}
528
+ collectionTranslations = {}
507
529
  } catch (err) {
508
530
  console.error('[site-content] Failed to collect content:', err.message)
509
531
  siteContent = { config: {}, theme: {}, pages: [] }
@@ -621,6 +643,7 @@ export function siteContentPlugin(options = {}) {
621
643
  const localeWatcher = watch(localesPath, { recursive: false }, () => {
622
644
  console.log('[site-content] Translation files changed, clearing cache...')
623
645
  localeTranslations = {}
646
+ collectionTranslations = {}
624
647
  server.ws.send({ type: 'full-reload' })
625
648
  })
626
649
  additionalWatchers.push(localeWatcher)
@@ -643,6 +666,22 @@ export function siteContentPlugin(options = {}) {
643
666
  // freeform dir may not exist, that's ok
644
667
  }
645
668
  }
669
+
670
+ // Watch collection translations directory
671
+ const collectionsLocalesPath = resolve(localesPath, 'collections')
672
+ if (existsSync(collectionsLocalesPath)) {
673
+ try {
674
+ const collWatcher = watch(collectionsLocalesPath, { recursive: false }, () => {
675
+ console.log('[site-content] Collection translations changed, clearing cache...')
676
+ collectionTranslations = {}
677
+ server.ws.send({ type: 'full-reload' })
678
+ })
679
+ additionalWatchers.push(collWatcher)
680
+ console.log(`[site-content] Watching ${collectionsLocalesPath} for collection translation changes`)
681
+ } catch (err) {
682
+ // collections locales dir may not exist, that's ok
683
+ }
684
+ }
646
685
  }
647
686
 
648
687
  // Add additional watchers to cleanup
@@ -721,6 +760,45 @@ export function siteContentPlugin(options = {}) {
721
760
  }
722
761
  }
723
762
 
763
+ // Handle localized collection data (e.g., /fr/data/articles.json)
764
+ const localeDataMatch = req.url.match(/^\/([a-z]{2})\/data\/(.+\.json)$/)
765
+ if (localeDataMatch) {
766
+ const locale = localeDataMatch[1]
767
+ const filename = localeDataMatch[2]
768
+ const collectionName = filename.replace('.json', '')
769
+ const sourcePath = join(resolvedSitePath, 'public', 'data', filename)
770
+
771
+ if (existsSync(sourcePath)) {
772
+ try {
773
+ const raw = await readFile(sourcePath, 'utf-8')
774
+ const items = JSON.parse(raw)
775
+
776
+ // Load collection translations for this locale
777
+ const translations = await loadCollectionTranslations(locale) || {}
778
+
779
+ // Check for free-form translations
780
+ const freeformDir = join(resolvedSitePath, localesDir, 'freeform', locale)
781
+ const hasFreeform = existsSync(freeformDir)
782
+
783
+ // Translate using the collections module
784
+ const { translateCollectionData } = await import('../i18n/collections.js')
785
+ const translated = await translateCollectionData(items, collectionName, resolvedSitePath, {
786
+ locale,
787
+ localesDir: join(resolvedSitePath, localesDir),
788
+ translations,
789
+ freeformEnabled: hasFreeform
790
+ })
791
+
792
+ res.setHeader('Content-Type', 'application/json')
793
+ res.end(JSON.stringify(translated, null, 2))
794
+ return
795
+ } catch (err) {
796
+ console.warn(`[site-content] Failed to serve localized collection ${filename}: ${err.message}`)
797
+ // Fall through to Vite's static server
798
+ }
799
+ }
800
+ }
801
+
724
802
  next()
725
803
  })
726
804
  },