@uniweb/build 0.12.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -58,9 +58,9 @@
58
58
  "@uniweb/theming": "0.1.3"
59
59
  },
60
60
  "optionalDependencies": {
61
- "@uniweb/content-reader": "1.1.9",
61
+ "@uniweb/runtime": "0.8.9",
62
62
  "@uniweb/schemas": "0.2.1",
63
- "@uniweb/runtime": "0.8.9"
63
+ "@uniweb/content-reader": "1.1.9"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/dev/plugin.js CHANGED
@@ -25,6 +25,7 @@ import { watch } from 'node:fs'
25
25
  import { readFile } from 'node:fs/promises'
26
26
  import { existsSync } from 'node:fs'
27
27
  import { build } from 'vite'
28
+ import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
28
29
 
29
30
  /**
30
31
  * Create the foundation dev plugin
@@ -168,7 +169,7 @@ export function foundationDevPlugin(options = {}) {
168
169
 
169
170
  // Watch foundation source for changes
170
171
  if (shouldWatch) {
171
- const srcPath = join(resolvedFoundationPath, 'src')
172
+ const srcPath = resolveFoundationSrcPath(resolvedFoundationPath)
172
173
 
173
174
  // Debounce rebuilds
174
175
  let rebuildTimeout = null
package/src/docs.js CHANGED
@@ -9,6 +9,7 @@ import { readFile, writeFile } from 'node:fs/promises'
9
9
  import { existsSync } from 'node:fs'
10
10
  import { join, isAbsolute } from 'node:path'
11
11
  import { buildSchema } from './schema.js'
12
+ import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
12
13
 
13
14
  /**
14
15
  * Generate markdown documentation for a single component
@@ -174,7 +175,7 @@ export async function generateDocs(foundationDir, options = {}) {
174
175
  schema = JSON.parse(schemaContent)
175
176
  } else {
176
177
  // Build schema from source
177
- const srcDir = join(foundationDir, 'src')
178
+ const srcDir = resolveFoundationSrcPath(foundationDir)
178
179
  if (!existsSync(srcDir)) {
179
180
  throw new Error(`Source directory not found: ${srcDir}`)
180
181
  }
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { resolve } from 'node:path'
27
27
  import { foundationPlugin } from '../vite-foundation-plugin.js'
28
+ import { resolveFoundationSrcDir } from '../utils/foundation-source-root.js'
28
29
 
29
30
  /**
30
31
  * Default externals for foundations
@@ -61,8 +62,16 @@ const DEFAULT_EXTERNALS = [
61
62
  * @returns {Promise<Object>} Vite configuration
62
63
  */
63
64
  export async function defineFoundationConfig(options = {}) {
65
+ // Determine foundation root (where vite.config.js is)
66
+ const foundationRoot = process.cwd()
67
+
68
+ // Source directory derived from package.json::main. `'src'` for legacy
69
+ // nested layouts, `'.'` for flat layouts. The default `entry` follows.
70
+ const srcDir = resolveFoundationSrcDir(foundationRoot)
71
+ const defaultEntry = srcDir === '.' ? '_entry.generated.js' : `${srcDir}/_entry.generated.js`
72
+
64
73
  const {
65
- entry = 'src/_entry.generated.js',
74
+ entry = defaultEntry,
66
75
  fileName = 'foundation',
67
76
  sections: sectionPaths,
68
77
  externals: additionalExternals = [],
@@ -74,9 +83,6 @@ export async function defineFoundationConfig(options = {}) {
74
83
  ...restOptions
75
84
  } = options
76
85
 
77
- // Determine foundation root (where vite.config.js is)
78
- const foundationRoot = process.cwd()
79
-
80
86
  // Build externals list
81
87
  const externals = includeDefaultExternals
82
88
  ? [...DEFAULT_EXTERNALS, ...additionalExternals]
@@ -109,7 +115,7 @@ export async function defineFoundationConfig(options = {}) {
109
115
  // Build the plugins array
110
116
  // foundationPlugin handles entry generation and schema building
111
117
  const plugins = [
112
- foundationPlugin({ srcDir: 'src', sections: sectionPaths }),
118
+ foundationPlugin({ srcDir, sections: sectionPaths }),
113
119
  tailwind && tailwindcss(),
114
120
  react(),
115
121
  svgr(),
@@ -75,17 +75,23 @@ async function detectHostShareableImports(srcDir) {
75
75
  /**
76
76
  * Detect foundation config file (for props, vars, etc.)
77
77
  *
78
- * Looks for: foundation.js or foundation.jsx
78
+ * Looks for (in priority order): main.js, main.jsx, foundation.js, foundation.jsx
79
+ *
80
+ * The canonical name is `main.js` (the foundation package's main authored
81
+ * module). `foundation.js` is the legacy name and remains supported so
82
+ * existing foundations build without a rename.
79
83
  *
80
84
  * The file should export:
81
85
  * - props (optional) - Foundation-wide props
82
86
  * - vars (optional) - CSS custom properties (also read by schema builder)
83
87
  * - defaultLayout (optional) - Default layout name
84
88
  *
85
- * Note: Layout components are now discovered from src/layouts/
89
+ * Note: Layout components are now discovered from layouts/ at the source root.
86
90
  */
87
91
  function detectFoundationExports(srcDir) {
88
92
  const candidates = [
93
+ { path: 'main.js', ext: 'js' },
94
+ { path: 'main.jsx', ext: 'jsx' },
89
95
  { path: 'foundation.js', ext: 'js' },
90
96
  { path: 'foundation.jsx', ext: 'jsx' },
91
97
  ]
@@ -374,8 +380,9 @@ export function shouldRegenerateForFile(file, srcDir) {
374
380
  return 'meta.js changed'
375
381
  }
376
382
 
377
- // foundation.js / foundation.jsx at root — affects capabilities import
378
- if (/^foundation\.(js|jsx)$/.test(rel)) {
383
+ // main.js / main.jsx / foundation.js / foundation.jsx at root —
384
+ // the authored declarations file affects the capabilities import.
385
+ if (/^(main|foundation)\.(js|jsx)$/.test(rel)) {
379
386
  return 'foundation config changed'
380
387
  }
381
388
 
package/src/index.js CHANGED
@@ -48,7 +48,13 @@ export {
48
48
  export { defineFoundationConfig } from './foundation/config.js'
49
49
 
50
50
  // Site config
51
- export { defineSiteConfig } from './site/config.js'
51
+ export { defineSiteConfig, detectFoundationType } from './site/config.js'
52
+
53
+ // Foundation source root resolution (reads package.json::main)
54
+ export { resolveFoundationSrcDir, resolveFoundationSrcPath } from './utils/foundation-source-root.js'
55
+
56
+ // Package classification (foundation vs site)
57
+ export { classifyPackage, isExtensionPackage } from './utils/classify-package.js'
52
58
 
53
59
  // Default export is the combined Vite plugin
54
60
  export { default } from './vite-foundation-plugin.js'
package/src/schema.js CHANGED
@@ -19,8 +19,10 @@ import { inferTitle } from './utils/infer-title.js'
19
19
  // Component meta file name
20
20
  const META_FILE_NAME = 'meta.js'
21
21
 
22
- // Foundation config file name
23
- const FOUNDATION_FILE_NAME = 'foundation.js'
22
+ // Foundation authored-declarations file name. `main.js` is the canonical
23
+ // new name; `foundation.js` is the legacy name kept for backward compat.
24
+ // Whichever exists at the source root is loaded.
25
+ const FOUNDATION_FILE_NAMES = ['main.js', 'foundation.js']
24
26
 
25
27
  // Default paths to scan for section types (relative to srcDir)
26
28
  const DEFAULT_SECTION_PATHS = ['sections']
@@ -62,18 +64,26 @@ export async function loadComponentMeta(componentDir) {
62
64
  }
63
65
 
64
66
  /**
65
- * Load package.json from foundation root
66
- * Extracts identity fields: name, version, description
67
+ * Load package.json from a foundation's root.
68
+ * Extracts identity fields: name, version, description.
67
69
  *
68
- * @param {string} srcDir - Source directory (e.g., 'src')
70
+ * The foundation's `package.json` lives at the *foundation root*, not at
71
+ * the source root. For the legacy nested layout (`main: "./src/_entry.generated.js"`),
72
+ * foundation root is `dirname(srcDir)`. For the flat layout
73
+ * (`main: "./_entry.generated.js"`), `srcDir` IS the foundation root —
74
+ * so we check srcDir first, then fall back to its parent.
75
+ *
76
+ * @param {string} srcDir - Foundation source directory
69
77
  * @returns {Object} Identity fields from package.json
70
78
  */
71
79
  export async function loadPackageJson(srcDir) {
72
- // package.json is in the foundation root (parent of srcDir)
73
- const foundationRoot = dirname(srcDir)
74
- const packagePath = join(foundationRoot, 'package.json')
80
+ const candidates = [
81
+ join(srcDir, 'package.json'), // flat layout: srcDir IS the foundation root
82
+ join(dirname(srcDir), 'package.json'), // legacy nested layout
83
+ ]
84
+ const packagePath = candidates.find(p => existsSync(p))
75
85
 
76
- if (!existsSync(packagePath)) {
86
+ if (!packagePath) {
77
87
  return {}
78
88
  }
79
89
 
@@ -94,22 +104,28 @@ export async function loadPackageJson(srcDir) {
94
104
  }
95
105
 
96
106
  /**
97
- * Load foundation-level config file (foundation.js)
107
+ * Load foundation-level config file (main.js, fallback foundation.js)
98
108
  *
99
109
  * Contains foundation-wide configuration:
100
110
  * - vars: CSS custom properties sites can override
101
- * - Layout: Custom layout component
111
+ * - defaultLayout: Default layout name
112
+ * - props: Foundation-wide props
102
113
  * - Future: providers, middleware, etc.
103
114
  */
104
115
  export async function loadFoundationConfig(srcDir) {
105
- const filePath = join(srcDir, FOUNDATION_FILE_NAME)
106
- if (!existsSync(filePath)) {
107
- return {}
116
+ let filePath = null
117
+ for (const name of FOUNDATION_FILE_NAMES) {
118
+ const candidate = join(srcDir, name)
119
+ if (existsSync(candidate)) {
120
+ filePath = candidate
121
+ break
122
+ }
108
123
  }
124
+ if (!filePath) return {}
125
+
109
126
  try {
110
127
  const module = await import(pathToFileURL(filePath).href)
111
128
  // Support both default export and named exports
112
- // Note: Layout/layouts no longer read from foundation.js — layouts come from src/layouts/ discovery
113
129
  return {
114
130
  ...module.default,
115
131
  vars: module.vars || module.default?.vars,
@@ -25,6 +25,7 @@ import { resolve, dirname, join } from 'node:path'
25
25
  import yaml from 'js-yaml'
26
26
  import { generateEntryPoint, shouldRegenerateForFile } from '../generate-entry.js'
27
27
  import { importMapPlugin } from '../import-map-plugin.js'
28
+ import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
28
29
 
29
30
  /**
30
31
  * Normalize a base path for Vite compatibility
@@ -60,7 +61,7 @@ function normalizeBasePath(raw) {
60
61
  * @param {string|Object} foundation - Foundation config from site.yml
61
62
  * @returns {{ type: 'local'|'npm'|'url', name?: string, url?: string, cssUrl?: string, path?: string }}
62
63
  */
63
- function detectFoundationType(foundation, siteRoot) {
64
+ export function detectFoundationType(foundation, siteRoot) {
64
65
  // Object form with explicit URL
65
66
  if (foundation && typeof foundation === 'object') {
66
67
  if (foundation.url) {
@@ -228,6 +229,16 @@ export async function defineSiteConfig(options = {}) {
228
229
  // Read site.yml
229
230
  const siteConfig = readSiteConfig(siteRoot)
230
231
 
232
+ // Allow callers to override `foundation:` without modifying site.yml on
233
+ // disk. Used by `uniweb deploy` to substitute a workspace-local file: ref
234
+ // with the resolved registry ref (`@ns/name@ver`) for the duration of the
235
+ // deploy build, so the site builds in runtime/link mode against the just-
236
+ // published artifact instead of bundling the local source.
237
+ const foundationOverride = process.env.UNIWEB_FOUNDATION_REF
238
+ if (foundationOverride) {
239
+ siteConfig.foundation = foundationOverride
240
+ }
241
+
231
242
  // Determine base path for deployment (priority: option > env > site.yml)
232
243
  // Normalize: ensure leading slash, collapse repeated slashes, add trailing slash for Vite
233
244
  const rawBase = baseOption || process.env.UNIWEB_BASE || siteConfig.base
@@ -283,7 +294,7 @@ export async function defineSiteConfig(options = {}) {
283
294
  const ensureFoundationEntryPlugin = !isRuntimeMode && foundationInfo.type === 'local' ? {
284
295
  name: 'uniweb:ensure-foundation-entry',
285
296
  async config() {
286
- const srcDir = join(foundationInfo.path, 'src')
297
+ const srcDir = resolveFoundationSrcPath(foundationInfo.path)
287
298
  const entryPath = join(srcDir, '_entry.generated.js')
288
299
 
289
300
  // Always regenerate on dev start to ensure it's current
@@ -300,7 +311,7 @@ export async function defineSiteConfig(options = {}) {
300
311
 
301
312
  configureServer(server) {
302
313
  // Watch foundation src for structural changes that affect the entry
303
- const srcDir = join(foundationInfo.path, 'src')
314
+ const srcDir = resolveFoundationSrcPath(foundationInfo.path)
304
315
  const entryPath = join(srcDir, '_entry.generated.js')
305
316
 
306
317
  server.watcher.add(srcDir)
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Classify a Uniweb package as `'foundation'`, `'site'`, or `null`.
3
+ *
4
+ * One sync classifier shared by the build, the CLI, and any tooling that
5
+ * needs to ask "what kind of Uniweb package is this directory?". Replaces
6
+ * four duplicated implementations across the CLI that used different
7
+ * signals and could disagree at the edges.
8
+ *
9
+ * Signals are checked strict-first, lenient-fallback:
10
+ *
11
+ * 1. `package.json::main` matches `_entry.generated.js` → foundation
12
+ * (mandatory by build contract; uniquely identifies a Uniweb foundation;
13
+ * set at scaffold time, before any install or build)
14
+ * 2. `site.yml` or `document.yml` at the package root → site
15
+ * 3. Authored declarations file (`main.js`, fallback `foundation.js`) at
16
+ * the resolved source root → foundation
17
+ * (covers unscaffolded directories without `package.json` yet)
18
+ * 4. `pages/` at the package root → site
19
+ * 5. otherwise → null
20
+ *
21
+ * Sync because the I/O is microsecond-scale and consumers were already
22
+ * blocking on the async version. Sync also lets workspace-scan helpers
23
+ * (findFoundations, findSites) be plain `.filter()` calls.
24
+ */
25
+
26
+ import { existsSync, readFileSync } from 'node:fs'
27
+ import { join } from 'node:path'
28
+ import { resolveFoundationSrcPath } from './foundation-source-root.js'
29
+
30
+ /**
31
+ * @param {string} packagePath - Absolute path to the package directory.
32
+ * @returns {'foundation'|'site'|null}
33
+ */
34
+ export function classifyPackage(packagePath) {
35
+ // 1. Strongest foundation marker: package.json::main points at the
36
+ // build-generated entry. Only Uniweb foundations have this shape.
37
+ const pkgPath = join(packagePath, 'package.json')
38
+ if (existsSync(pkgPath)) {
39
+ try {
40
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
41
+ if (typeof pkg.main === 'string' && /_entry\.generated\.js$/.test(pkg.main)) {
42
+ return 'foundation'
43
+ }
44
+ } catch {
45
+ // Malformed package.json — fall through to file-based signals.
46
+ }
47
+ }
48
+
49
+ // 2. Strongest site marker: configuration file at root.
50
+ if (existsSync(join(packagePath, 'site.yml')) ||
51
+ existsSync(join(packagePath, 'document.yml'))) {
52
+ return 'site'
53
+ }
54
+
55
+ // 3. Foundation fallback for unscaffolded directories: authored
56
+ // declarations file at the resolved source root. Accept both the
57
+ // new name (main.js) and the legacy name (foundation.js).
58
+ const srcDir = resolveFoundationSrcPath(packagePath)
59
+ if (existsSync(join(srcDir, 'main.js'))) return 'foundation'
60
+ if (existsSync(join(srcDir, 'foundation.js'))) return 'foundation'
61
+
62
+ // 4. Site fallback: pages/ at root.
63
+ if (existsSync(join(packagePath, 'pages'))) return 'site'
64
+
65
+ return null
66
+ }
67
+
68
+ /**
69
+ * Check whether a foundation package declares `extension: true` in its
70
+ * authored declarations file. Uses a regex on the source rather than
71
+ * importing the module — keeps the classifier sync and side-effect-free,
72
+ * and works before any install.
73
+ *
74
+ * @param {string} packagePath - Absolute path to the package directory.
75
+ * @returns {boolean}
76
+ */
77
+ export function isExtensionPackage(packagePath) {
78
+ const srcDir = resolveFoundationSrcPath(packagePath)
79
+ // Prefer the schema's recorded role if the build has run.
80
+ const schemaPath = join(packagePath, 'dist', 'meta', 'schema.json')
81
+ if (existsSync(schemaPath)) {
82
+ try {
83
+ const schema = JSON.parse(readFileSync(schemaPath, 'utf8'))
84
+ if (schema?._self?.role === 'extension') return true
85
+ } catch {
86
+ // Fall through to source-based check.
87
+ }
88
+ }
89
+ // Source-based fallback. Accept both filenames.
90
+ for (const name of ['main.js', 'foundation.js']) {
91
+ const filePath = join(srcDir, name)
92
+ if (existsSync(filePath)) {
93
+ try {
94
+ const content = readFileSync(filePath, 'utf8')
95
+ return /extension\s*:\s*true/.test(content)
96
+ } catch {
97
+ return false
98
+ }
99
+ }
100
+ }
101
+ return false
102
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Resolve a foundation package's source root by reading its `package.json::main`.
3
+ *
4
+ * Two layouts are supported:
5
+ *
6
+ * - Nested (legacy): `main: "./src/_entry.generated.js"` → source root is `<foundationDir>/src/`
7
+ * - Flat: `main: "./_entry.generated.js"` → source root is `<foundationDir>/`
8
+ *
9
+ * The build derives every other path (sections, components, layouts, foundation.js,
10
+ * styles.css, the generated entry) from this single value. There is no filesystem
11
+ * probing — `main` is the source of truth for where the package's code lives,
12
+ * which is also exactly what npm-style consumers use.
13
+ *
14
+ * Existing foundations in the wild keep `main: "./src/_entry.generated.js"` and
15
+ * resolve to the same nested layout they always had. New scaffolds (per the
16
+ * first-run-ux plan, Thread D) ship with the flat layout and `main: "./_entry.generated.js"`.
17
+ */
18
+
19
+ import { readFileSync, existsSync } from 'node:fs'
20
+ import { dirname, join, resolve } from 'node:path'
21
+
22
+ /**
23
+ * Resolve a foundation's source directory (relative to the foundation root).
24
+ *
25
+ * @param {string} foundationDir - Absolute path to the foundation package root.
26
+ * @returns {string} Source directory, relative to `foundationDir` (e.g. `'src'` or `'.'`).
27
+ */
28
+ export function resolveFoundationSrcDir(foundationDir) {
29
+ const pkgPath = join(foundationDir, 'package.json')
30
+ if (!existsSync(pkgPath)) {
31
+ // No package.json — fall back to legacy nested layout. Don't throw; the
32
+ // caller (e.g. the docs builder) may be inspecting a partial directory
33
+ // and existsSync checks downstream will surface the real error.
34
+ return 'src'
35
+ }
36
+
37
+ let pkg
38
+ try {
39
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
40
+ } catch {
41
+ return 'src'
42
+ }
43
+
44
+ const main = pkg.main
45
+ if (typeof main !== 'string' || !main) {
46
+ return 'src'
47
+ }
48
+
49
+ // dirname('./src/_entry.generated.js') → './src' → 'src'
50
+ // dirname('./_entry.generated.js') → '.' → '.'
51
+ // dirname('src/_entry.generated.js') → 'src' → 'src'
52
+ const dir = dirname(main).replace(/^\.\//, '')
53
+ return dir || '.'
54
+ }
55
+
56
+ /**
57
+ * Resolve a foundation's absolute source directory.
58
+ *
59
+ * @param {string} foundationDir - Absolute path to the foundation package root.
60
+ * @returns {string} Absolute path to the source directory.
61
+ */
62
+ export function resolveFoundationSrcPath(foundationDir) {
63
+ const srcDir = resolveFoundationSrcDir(foundationDir)
64
+ return srcDir === '.' ? foundationDir : resolve(foundationDir, srcDir)
65
+ }
@@ -134,8 +134,10 @@ async function emitRuntimePin(outDir, projectRoot) {
134
134
  if (!runtimeVersion) return
135
135
 
136
136
  // Read foundation's own package.json for an optional runtimePolicy
137
- // field. Default policy (auto-patch) lives at the registry layer; we
138
- // only record the foundation's override if explicitly set.
137
+ // field. Default policy (auto-minor) is applied platform-side when
138
+ // the field is omitted; we only record the foundation's override
139
+ // here if explicitly set. See framework/docs/reference/foundation-config.md
140
+ // for the full set of `uniweb.*` fields foundations can declare.
139
141
  let policy = null
140
142
  try {
141
143
  const foundationPkgPath = join(projectRoot, 'package.json')