@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.
@@ -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
  },
@@ -134,7 +134,7 @@ function generateContextCSS(context, tokens = {}) {
134
134
 
135
135
  const vars = generateVarDeclarations(mergedTokens)
136
136
 
137
- return `.context-${context} {\n${vars}\n}`
137
+ return `.context-${context} {\n${vars}\n background-color: var(--bg);\n}`
138
138
  }
139
139
 
140
140
  /**
@@ -305,6 +305,26 @@ export function generateThemeCSS(config = {}) {
305
305
  sections.push(generateDarkSchemeCSS(appearance))
306
306
  }
307
307
 
308
+ // 7. Site background (if specified in theme.yml)
309
+ if (config.background) {
310
+ sections.push(`/* Site Background */\nbody {\n background: ${config.background};\n}`)
311
+ }
312
+
313
+ // 8. Inline text styles (if specified in theme.yml)
314
+ if (config.inline && typeof config.inline === 'object') {
315
+ const rules = Object.entries(config.inline)
316
+ .filter(([, styles]) => styles && typeof styles === 'object')
317
+ .map(([name, styles]) => {
318
+ const declarations = Object.entries(styles)
319
+ .map(([prop, value]) => ` ${prop}: ${value};`)
320
+ .join('\n')
321
+ return `span[${name}] {\n${declarations}\n}`
322
+ })
323
+ if (rules.length > 0) {
324
+ sections.push('/* Inline Text Styles */\n' + rules.join('\n\n'))
325
+ }
326
+ }
327
+
308
328
  return sections.join('\n\n')
309
329
  }
310
330
 
@@ -422,6 +422,12 @@ export function processTheme(rawConfig = {}, options = {}) {
422
422
  ...(rawConfig.code || {}),
423
423
  }
424
424
 
425
+ // Site background (pass through as CSS value)
426
+ const background = rawConfig.background || null
427
+
428
+ // Inline text styles (semantic names → CSS declarations)
429
+ const inline = rawConfig.inline || null
430
+
425
431
  const config = {
426
432
  colors, // Raw colors for CSS generator
427
433
  palettes, // Generated palettes for Theme class
@@ -430,6 +436,8 @@ export function processTheme(rawConfig = {}, options = {}) {
430
436
  appearance,
431
437
  foundationVars: mergedFoundationVars,
432
438
  code, // Code block theme for runtime injection
439
+ background, // Site-level background CSS value
440
+ inline, // Inline text style definitions
433
441
  }
434
442
 
435
443
  return { config, errors, warnings }
@@ -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
  }