@uniweb/build 0.1.32 → 0.1.33

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
@@ -333,6 +333,34 @@ dist/
333
333
  └── default.webp
334
334
  ```
335
335
 
336
+ ### Schema.json Structure
337
+
338
+ The generated `schema.json` contains:
339
+
340
+ ```json
341
+ {
342
+ "_self": {
343
+ "name": "foundation",
344
+ "version": "0.1.0",
345
+ "description": "My foundation description",
346
+ "vars": { ... }
347
+ },
348
+ "Hero": { ... },
349
+ "Features": { ... }
350
+ }
351
+ ```
352
+
353
+ The `_self` object contains foundation-level metadata:
354
+
355
+ | Field | Source | Description |
356
+ |-------|--------|-------------|
357
+ | `name` | `package.json` | Foundation package name |
358
+ | `version` | `package.json` | Foundation version |
359
+ | `description` | `package.json` | Foundation description |
360
+ | `vars` | `foundation.js` | CSS custom properties sites can override |
361
+
362
+ Identity fields (`name`, `version`, `description`) come from the foundation's `package.json`. Configuration fields (`vars`, etc.) come from `src/foundation.js`.
363
+
336
364
  ## API Reference
337
365
 
338
366
  ### Schema Functions
@@ -341,7 +369,8 @@ dist/
341
369
  |----------|-------------|
342
370
  | `discoverComponents(srcDir)` | Discover all exposed components |
343
371
  | `loadComponentMeta(componentDir)` | Load meta file for a component |
344
- | `loadFoundationMeta(srcDir)` | Load foundation-level meta |
372
+ | `loadPackageJson(srcDir)` | Load identity from package.json |
373
+ | `loadFoundationConfig(srcDir)` | Load foundation.js configuration |
345
374
  | `buildSchema(srcDir)` | Build complete schema object |
346
375
 
347
376
  ### Entry Generation
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
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.0.4",
54
- "@uniweb/runtime": "0.2.18"
54
+ "@uniweb/runtime": "0.2.19"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -60,7 +60,7 @@
60
60
  "@tailwindcss/vite": "^4.0.0",
61
61
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
62
62
  "vite-plugin-svgr": "^4.0.0",
63
- "@uniweb/core": "0.1.15"
63
+ "@uniweb/core": "0.1.16"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "vite": {
@@ -26,20 +26,41 @@ import { discoverComponents } from './schema.js'
26
26
  import { extractAllRuntimeSchemas } from './runtime-schema.js'
27
27
 
28
28
  /**
29
- * Detect foundation exports file (for custom Layout, props, etc.)
30
- * Looks for: src/exports.js, src/exports.jsx, src/exports/index.js, src/exports/index.jsx
29
+ * Detect foundation config/exports file (for custom Layout, props, vars, etc.)
30
+ *
31
+ * Looks for (in order of preference):
32
+ * 1. foundation.js - New consolidated format
33
+ * 2. exports.js - Legacy format (for backward compatibility)
34
+ *
35
+ * The file should export:
36
+ * - Layout (optional) - Custom page layout component
37
+ * - props (optional) - Foundation-wide props
38
+ * - vars (optional) - CSS custom properties (also read by schema builder)
31
39
  */
32
40
  function detectFoundationExports(srcDir) {
33
- const candidates = [
41
+ // Prefer foundation.js (new consolidated format)
42
+ const foundationCandidates = [
43
+ { path: 'foundation.js', ext: 'js' },
44
+ { path: 'foundation.jsx', ext: 'jsx' },
45
+ ]
46
+
47
+ for (const { path, ext } of foundationCandidates) {
48
+ if (existsSync(join(srcDir, path))) {
49
+ return { path: `./${path}`, ext, isFoundationJs: true }
50
+ }
51
+ }
52
+
53
+ // Fall back to exports.js (legacy format)
54
+ const legacyCandidates = [
34
55
  { path: 'exports.js', ext: 'js' },
35
56
  { path: 'exports.jsx', ext: 'jsx' },
36
57
  { path: 'exports/index.js', ext: 'js' },
37
58
  { path: 'exports/index.jsx', ext: 'jsx' },
38
59
  ]
39
60
 
40
- for (const { path, ext } of candidates) {
61
+ for (const { path, ext } of legacyCandidates) {
41
62
  if (existsSync(join(srcDir, path))) {
42
- return { path: `./${path.replace(/\/index\.(js|jsx)$/, '')}`, ext }
63
+ return { path: `./${path.replace(/\/index\.(js|jsx)$/, '')}`, ext, isFoundationJs: false }
43
64
  }
44
65
  }
45
66
  return null
package/src/index.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // Schema discovery and loading
8
8
  export {
9
9
  loadComponentMeta,
10
- loadFoundationMeta,
10
+ loadFoundationConfig,
11
+ loadFoundationMeta, // @deprecated - use loadFoundationConfig
11
12
  discoverComponents,
12
13
  buildSchema,
13
14
  getExposedComponents,
package/src/prerender.js CHANGED
@@ -516,6 +516,14 @@ export async function prerenderSite(siteDir, options = {}) {
516
516
  function injectContent(shell, renderedContent, page, siteContent) {
517
517
  let html = shell
518
518
 
519
+ // Inject theme CSS if not already present
520
+ if (siteContent?.theme?.css && !html.includes('id="uniweb-theme"')) {
521
+ html = html.replace(
522
+ '</head>',
523
+ ` <style id="uniweb-theme">\n${siteContent.theme.css}\n </style>\n </head>`
524
+ )
525
+ }
526
+
519
527
  // Replace the empty root div with pre-rendered content
520
528
  html = html.replace(
521
529
  /<div id="root">[\s\S]*?<\/div>/,
@@ -548,7 +556,13 @@ function injectContent(shell, renderedContent, page, siteContent) {
548
556
 
549
557
  // Inject site content as JSON for hydration
550
558
  // Replace existing content if present, otherwise add it
551
- const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(siteContent)}</script>`
559
+ // Strip CSS from theme (it's already in a <style> tag)
560
+ const contentForJson = { ...siteContent }
561
+ if (contentForJson.theme?.css) {
562
+ contentForJson.theme = { ...contentForJson.theme }
563
+ delete contentForJson.theme.css
564
+ }
565
+ const contentScript = `<script id="__SITE_CONTENT__" type="application/json">${JSON.stringify(contentForJson)}</script>`
552
566
  if (html.includes('__SITE_CONTENT__')) {
553
567
  // Replace existing site content with updated version (includes expanded dynamic routes)
554
568
  // Match script tag with attributes in any order
package/src/schema.js CHANGED
@@ -5,14 +5,17 @@
5
5
  * Schema data is for editor-time only, not runtime.
6
6
  */
7
7
 
8
- import { readdir } from 'node:fs/promises'
8
+ import { readdir, readFile } from 'node:fs/promises'
9
9
  import { existsSync } from 'node:fs'
10
- import { join, basename } from 'node:path'
10
+ import { join, dirname } from 'node:path'
11
11
  import { pathToFileURL } from 'node:url'
12
12
 
13
- // Meta file name (standardized to meta.js)
13
+ // Component meta file name
14
14
  const META_FILE_NAME = 'meta.js'
15
15
 
16
+ // Foundation config file name
17
+ const FOUNDATION_FILE_NAME = 'foundation.js'
18
+
16
19
  // Default component paths (relative to srcDir)
17
20
  const DEFAULT_COMPONENT_PATHS = ['components']
18
21
 
@@ -44,21 +47,71 @@ export async function loadComponentMeta(componentDir) {
44
47
  }
45
48
 
46
49
  /**
47
- * Load foundation-level meta file
50
+ * Load package.json from foundation root
51
+ * Extracts identity fields: name, version, description
52
+ *
53
+ * @param {string} srcDir - Source directory (e.g., 'src')
54
+ * @returns {Object} Identity fields from package.json
48
55
  */
49
- export async function loadFoundationMeta(srcDir) {
50
- const filePath = join(srcDir, META_FILE_NAME)
56
+ export async function loadPackageJson(srcDir) {
57
+ // package.json is in the foundation root (parent of srcDir)
58
+ const foundationRoot = dirname(srcDir)
59
+ const packagePath = join(foundationRoot, 'package.json')
60
+
61
+ if (!existsSync(packagePath)) {
62
+ return {}
63
+ }
64
+
65
+ try {
66
+ const content = await readFile(packagePath, 'utf-8')
67
+ const pkg = JSON.parse(content)
68
+
69
+ // Extract only identity fields for schema
70
+ return {
71
+ name: pkg.name,
72
+ version: pkg.version,
73
+ description: pkg.description,
74
+ }
75
+ } catch (error) {
76
+ console.warn(`Warning: Failed to load package.json:`, error.message)
77
+ return {}
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Load foundation-level config file (foundation.js)
83
+ *
84
+ * Contains foundation-wide configuration:
85
+ * - vars: CSS custom properties sites can override
86
+ * - Layout: Custom layout component
87
+ * - Future: providers, middleware, etc.
88
+ */
89
+ export async function loadFoundationConfig(srcDir) {
90
+ const filePath = join(srcDir, FOUNDATION_FILE_NAME)
51
91
  if (!existsSync(filePath)) {
52
92
  return {}
53
93
  }
54
94
  try {
55
- return await loadMetaFile(filePath)
95
+ const module = await import(pathToFileURL(filePath).href)
96
+ // Support both default export and named exports
97
+ return {
98
+ ...module.default,
99
+ vars: module.vars || module.default?.vars,
100
+ Layout: module.Layout || module.default?.Layout,
101
+ }
56
102
  } catch (error) {
57
- console.warn(`Warning: Failed to load foundation meta ${filePath}:`, error.message)
103
+ console.warn(`Warning: Failed to load foundation config ${filePath}:`, error.message)
58
104
  return {}
59
105
  }
60
106
  }
61
107
 
108
+ /**
109
+ * @deprecated Use loadFoundationConfig instead
110
+ */
111
+ export async function loadFoundationMeta(srcDir) {
112
+ return loadFoundationConfig(srcDir)
113
+ }
114
+
62
115
  /**
63
116
  * Discover components in a single path
64
117
  * @param {string} srcDir - Source directory (e.g., 'src')
@@ -127,17 +180,31 @@ export async function discoverComponents(srcDir, componentPaths = DEFAULT_COMPON
127
180
 
128
181
  /**
129
182
  * Build complete schema for a foundation
130
- * Returns { _self: foundationMeta, ComponentName: componentMeta, ... }
183
+ * Returns { _self: { identity + config }, ComponentName: componentMeta, ... }
184
+ *
185
+ * The _self object contains:
186
+ * - Identity from package.json (name, version, description)
187
+ * - Configuration from foundation.js (vars, Layout, etc.)
131
188
  *
132
189
  * @param {string} srcDir - Source directory
133
190
  * @param {string[]} [componentPaths] - Paths to search for components
134
191
  */
135
192
  export async function buildSchema(srcDir, componentPaths) {
136
- const foundationMeta = await loadFoundationMeta(srcDir)
193
+ // Load identity from package.json
194
+ const identity = await loadPackageJson(srcDir)
195
+
196
+ // Load configuration from foundation.js
197
+ const foundationConfig = await loadFoundationConfig(srcDir)
198
+
199
+ // Discover components
137
200
  const components = await discoverComponents(srcDir, componentPaths)
138
201
 
139
202
  return {
140
- _self: foundationMeta,
203
+ // Merge identity and config - identity fields take precedence
204
+ _self: {
205
+ ...foundationConfig,
206
+ ...identity,
207
+ },
141
208
  ...components,
142
209
  }
143
210
  }
@@ -235,7 +235,8 @@ export async function defineSiteConfig(options = {}) {
235
235
  inject: true,
236
236
  seo,
237
237
  assets,
238
- search
238
+ search,
239
+ foundationPath: foundationInfo.path // For loading foundation theme vars
239
240
  }),
240
241
 
241
242
  // Foundation dev server (only in runtime mode with local foundation)
@@ -28,6 +28,7 @@ import { existsSync } from 'node:fs'
28
28
  import yaml from 'js-yaml'
29
29
  import { collectSectionAssets, mergeAssetCollections } from './assets.js'
30
30
  import { parseFetchConfig, singularize } from './data-fetcher.js'
31
+ import { buildTheme, extractFoundationVars } from '../theme/index.js'
31
32
 
32
33
  // Try to import content-reader, fall back to simplified parser
33
34
  let markdownToProseMirror
@@ -161,11 +162,20 @@ async function processMarkdownFile(filePath, id, siteRoot) {
161
162
  }
162
163
  }
163
164
 
164
- const { type, component, preset, input, props, fetch, ...params } = frontMatter
165
+ const { type, component, preset, input, props, fetch, data, ...params } = frontMatter
165
166
 
166
167
  // Convert markdown to ProseMirror
167
168
  const proseMirrorContent = markdownToProseMirror(markdown)
168
169
 
170
+ // Support 'data:' shorthand for collection fetch
171
+ // data: team → fetch: { collection: team }
172
+ // data: [team, articles] → fetch: { collection: team } (first item, others via inheritData)
173
+ let resolvedFetch = fetch
174
+ if (!fetch && data) {
175
+ const collectionName = Array.isArray(data) ? data[0] : data
176
+ resolvedFetch = { collection: collectionName }
177
+ }
178
+
169
179
  const section = {
170
180
  id,
171
181
  component: type || component || 'Section',
@@ -173,7 +183,7 @@ async function processMarkdownFile(filePath, id, siteRoot) {
173
183
  input,
174
184
  params: { ...params, ...props },
175
185
  content: proseMirrorContent,
176
- fetch: parseFetchConfig(fetch),
186
+ fetch: parseFetchConfig(resolvedFetch),
177
187
  subsections: []
178
188
  }
179
189
 
@@ -438,7 +448,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
438
448
  },
439
449
 
440
450
  // Data fetching
441
- fetch: parseFetchConfig(pageConfig.fetch),
451
+ // Support 'data:' shorthand at page level
452
+ // data: team → fetch: { collection: team }
453
+ fetch: parseFetchConfig(
454
+ pageConfig.fetch || (pageConfig.data
455
+ ? { collection: Array.isArray(pageConfig.data) ? pageConfig.data[0] : pageConfig.data }
456
+ : undefined)
457
+ ),
442
458
 
443
459
  sections: hierarchicalSections
444
460
  },
@@ -581,24 +597,69 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
581
597
  return { pages, assetCollection, header, footer, left, right, notFound }
582
598
  }
583
599
 
600
+ /**
601
+ * Load foundation variables from schema.json
602
+ *
603
+ * @param {string} foundationPath - Path to foundation directory
604
+ * @returns {Promise<Object>} Foundation variables or empty object
605
+ */
606
+ async function loadFoundationVars(foundationPath) {
607
+ if (!foundationPath) return {}
608
+
609
+ // Try dist/schema.json first (built foundation), then src/schema.json
610
+ const distSchemaPath = join(foundationPath, 'dist', 'schema.json')
611
+ const srcSchemaPath = join(foundationPath, 'schema.json')
612
+
613
+ const schemaPath = existsSync(distSchemaPath) ? distSchemaPath : srcSchemaPath
614
+
615
+ if (!existsSync(schemaPath)) {
616
+ return {}
617
+ }
618
+
619
+ try {
620
+ const schemaContent = await readFile(schemaPath, 'utf8')
621
+ const schema = JSON.parse(schemaContent)
622
+ // Foundation config is in _self, support both 'vars' (new) and 'themeVars' (legacy)
623
+ return schema._self?.vars || schema._self?.themeVars || schema.themeVars || {}
624
+ } catch (err) {
625
+ console.warn('[content-collector] Failed to load foundation schema:', err.message)
626
+ return {}
627
+ }
628
+ }
629
+
584
630
  /**
585
631
  * Collect all site content
586
632
  *
587
633
  * @param {string} sitePath - Path to site directory
634
+ * @param {Object} options - Collection options
635
+ * @param {string} options.foundationPath - Path to foundation directory (for theme vars)
588
636
  * @returns {Promise<Object>} Site content object with assets manifest
589
637
  */
590
- export async function collectSiteContent(sitePath) {
638
+ export async function collectSiteContent(sitePath, options = {}) {
639
+ const { foundationPath } = options
591
640
  const pagesPath = join(sitePath, 'pages')
592
641
 
593
- // Read site config
642
+ // Read site config and raw theme config
594
643
  const siteConfig = await readYamlFile(join(sitePath, 'site.yml'))
595
- const themeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
644
+ const rawThemeConfig = await readYamlFile(join(sitePath, 'theme.yml'))
645
+
646
+ // Load foundation vars and process theme
647
+ const foundationVars = await loadFoundationVars(foundationPath)
648
+ const { config: processedTheme, css: themeCSS, warnings } = buildTheme(rawThemeConfig, { foundationVars })
649
+
650
+ // Log theme warnings
651
+ if (warnings?.length > 0) {
652
+ warnings.forEach(w => console.warn(`[theme] ${w}`))
653
+ }
596
654
 
597
655
  // Check if pages directory exists
598
656
  if (!existsSync(pagesPath)) {
599
657
  return {
600
658
  config: siteConfig,
601
- theme: themeConfig,
659
+ theme: {
660
+ ...processedTheme,
661
+ css: themeCSS
662
+ },
602
663
  pages: [],
603
664
  assets: {}
604
665
  }
@@ -629,7 +690,10 @@ export async function collectSiteContent(sitePath) {
629
690
  ...siteConfig,
630
691
  fetch: parseFetchConfig(siteConfig.fetch),
631
692
  },
632
- theme: themeConfig,
693
+ theme: {
694
+ ...processedTheme,
695
+ css: themeCSS
696
+ },
633
697
  pages,
634
698
  header,
635
699
  footer,
@@ -305,6 +305,7 @@ function escapeHtml(str) {
305
305
  * @param {Object} [options.search] - Search index configuration
306
306
  * @param {boolean} [options.search.enabled=true] - Generate search index (uses site.yml config by default)
307
307
  * @param {string} [options.search.filename='search-index.json'] - Search index filename
308
+ * @param {string} [options.foundationPath] - Path to foundation directory (for loading theme vars)
308
309
  */
309
310
  export function siteContentPlugin(options = {}) {
310
311
  const {
@@ -316,7 +317,8 @@ export function siteContentPlugin(options = {}) {
316
317
  watch: shouldWatch = true,
317
318
  seo = {},
318
319
  assets: assetsConfig = {},
319
- search: searchPluginConfig = {}
320
+ search: searchPluginConfig = {},
321
+ foundationPath
320
322
  } = options
321
323
 
322
324
  // Extract asset processing options
@@ -416,7 +418,7 @@ export function siteContentPlugin(options = {}) {
416
418
  if (!isProduction) {
417
419
  try {
418
420
  // Do an early content collection to get the collections config
419
- const earlyContent = await collectSiteContent(resolvedSitePath)
421
+ const earlyContent = await collectSiteContent(resolvedSitePath, { foundationPath })
420
422
  collectionsConfig = earlyContent.config?.collections
421
423
 
422
424
  if (collectionsConfig) {
@@ -433,7 +435,7 @@ export function siteContentPlugin(options = {}) {
433
435
  async buildStart() {
434
436
  // Collect content at build start
435
437
  try {
436
- siteContent = await collectSiteContent(resolvedSitePath)
438
+ siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
437
439
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
438
440
 
439
441
  // Process content collections if defined in site.yml
@@ -480,7 +482,7 @@ export function siteContentPlugin(options = {}) {
480
482
  rebuildTimeout = setTimeout(async () => {
481
483
  console.log('[site-content] Content changed, rebuilding...')
482
484
  try {
483
- siteContent = await collectSiteContent(resolvedSitePath)
485
+ siteContent = await collectSiteContent(resolvedSitePath, { foundationPath })
484
486
  // Execute fetches for the updated content
485
487
  await executeDevFetches(siteContent, resolvedSitePath)
486
488
  console.log(`[site-content] Rebuilt ${siteContent.pages?.length || 0} pages`)
@@ -685,6 +687,11 @@ export function siteContentPlugin(options = {}) {
685
687
 
686
688
  let headInjection = ''
687
689
 
690
+ // Inject theme CSS
691
+ if (contentToInject.theme?.css) {
692
+ headInjection += ` <style id="uniweb-theme">\n${contentToInject.theme.css}\n </style>\n`
693
+ }
694
+
688
695
  // Inject SEO meta tags
689
696
  if (seoEnabled) {
690
697
  const metaTags = generateMetaTags(contentToInject, seoOptions)
@@ -791,6 +798,9 @@ export function siteContentPlugin(options = {}) {
791
798
  delete finalContent.hasExplicitPoster
792
799
  delete finalContent.hasExplicitPreview
793
800
 
801
+ // Note: theme.css is kept here so prerender can inject it into HTML
802
+ // Prerender will strip it from the JSON it injects into each page
803
+
794
804
  // Emit content as JSON file in production build
795
805
  this.emitFile({
796
806
  type: 'asset',