@uniweb/build 0.30.2 → 0.32.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.30.2",
3
+ "version": "0.32.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,15 +59,15 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
+ "@uniweb/content-writer": "^0.3.4",
62
63
  "@uniweb/content-reader": "^1.2.4",
63
- "@uniweb/projections": "^0.5.2",
64
- "@uniweb/schemas": "^0.2.12",
65
- "@uniweb/semantic-parser": "^1.3.1",
64
+ "@uniweb/semantic-parser": "^1.4.0",
65
+ "@uniweb/schemas": "^0.2.13",
66
66
  "@uniweb/theming": "^0.1.15",
67
- "@uniweb/content-writer": "^0.3.4"
67
+ "@uniweb/projections": "^0.5.4"
68
68
  },
69
69
  "optionalDependencies": {
70
- "@uniweb/runtime": "^0.13.3"
70
+ "@uniweb/runtime": "^0.13.5"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -76,7 +76,7 @@
76
76
  "@tailwindcss/vite": "^4.0.0",
77
77
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
78
78
  "vite-plugin-svgr": "^4.0.0",
79
- "@uniweb/core": "^0.14.1"
79
+ "@uniweb/core": "^0.16.0"
80
80
  },
81
81
  "peerDependenciesMeta": {
82
82
  "vite": {
@@ -31,6 +31,7 @@ import {
31
31
  import { importMapPlugin } from '../import-map-plugin.js'
32
32
  import { resolveModuleUrl, resolveExtensionUrls } from './extension-urls.js'
33
33
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
34
+ import { checkFoundationResolution } from '../utils/foundation-resolution-check.js'
34
35
  import { detectFoundationType } from './foundation-ref.js'
35
36
 
36
37
  /**
@@ -191,7 +192,7 @@ export async function defineSiteConfig(options = {}) {
191
192
  // Plugin to ensure foundation entry file exists (for bundled mode with local foundation)
192
193
  const ensureFoundationEntryPlugin = !isRuntimeMode && foundationInfo.type === 'local' ? {
193
194
  name: 'uniweb:ensure-foundation-entry',
194
- async config() {
195
+ async config(_config, env) {
195
196
  const srcDir = resolveFoundationSrcPath(foundationInfo.path)
196
197
  const entryPath = join(srcDir, '_entry.generated.js')
197
198
 
@@ -202,8 +203,50 @@ export async function defineSiteConfig(options = {}) {
202
203
  try {
203
204
  await generateEntryPoint(srcDir, entryPath)
204
205
  } catch (err) {
205
- console.warn('[site] Failed to generate foundation entry:', err.message)
206
+ // **Swallowing this on a BUILD is how you get vite's opaque
207
+ // `Failed to resolve entry for package "<name>"` 50ms later**: we
208
+ // decline to write the entry, say so in a `console.warn` that scrolls
209
+ // past, and hand vite a package whose `main` points at a file nobody
210
+ // created. The message naming the actual cause is the one we printed
211
+ // and discarded.
212
+ //
213
+ // ⚖️ **But only when there is no entry to fall back on.** If a previous
214
+ // build left one, it is stale rather than absent and the build can
215
+ // still complete — turning that into a hard failure would break a
216
+ // build that works today, which is a worse trade than a loud warning.
217
+ // Dev keeps warning either way: a transient error mid-session must not
218
+ // kill a running server.
219
+ const haveFallback = existsSync(entryPath)
220
+ if (env?.command === 'build' && !haveFallback) {
221
+ throw new Error(
222
+ `[site] Could not generate the foundation entry for "${foundationInfo.name}".\n` +
223
+ ` ${err.message}\n\n` +
224
+ ` ${entryPath} does not exist, so the build cannot resolve the\n` +
225
+ ` foundation. Fixing the error above is the fix; vite's next\n` +
226
+ ` message about resolving package "${foundationInfo.name}" is a\n` +
227
+ ` symptom of this one.`,
228
+ { cause: err }
229
+ )
230
+ }
231
+ console.warn(
232
+ `[site] ⛔ Failed to generate foundation entry: ${err.message}` +
233
+ (haveFallback
234
+ ? `\n[site] Continuing with the EXISTING ${entryPath}, which is now STALE.`
235
+ : '')
236
+ )
206
237
  }
238
+
239
+ // Do we and vite agree on which directory that was? Two resolutions run
240
+ // here — ours by path, vite's by the bare specifier below
241
+ // (`alias['#foundation'] = foundationInfo.name`) — and when they diverge
242
+ // the quiet outcome is worse than the loud one: a stale entry in vite's
243
+ // directory builds the WRONG foundation and reports success.
244
+ const agreement = checkFoundationResolution({
245
+ name: foundationInfo.name,
246
+ generatedInto: srcDir,
247
+ siteRoot,
248
+ })
249
+ if (!agreement.ok) console.warn(`\n${agreement.message}\n`)
207
250
  }
208
251
  },
209
252
 
@@ -277,7 +320,31 @@ export async function defineSiteConfig(options = {}) {
277
320
  // Point #foundation at a virtual noop module.
278
321
  alias['#foundation'] = '\0__foundation-noop__'
279
322
  } else if (foundationInfo.type !== 'url') {
280
- // Bundled mode: #foundation points to the actual package
323
+ // Bundled mode: #foundation points to the actual package.
324
+ //
325
+ // ⛔ **A BARE SPECIFIER, NOT `foundationInfo.path` — and the obvious
326
+ // "improvement" is measured and wrong.** We hold the resolved path right
327
+ // here, and handing vite the *name* instead is what lets its node_modules
328
+ // lookup disagree with ours (the whole reason
329
+ // `uniweb:ensure-foundation-entry` now runs `checkFoundationResolution`).
330
+ // Collapsing the two by aliasing to the path looks like it deletes that bug
331
+ // class. It does not:
332
+ //
333
+ // A vite alias is PREFIX replacement, so `#foundation/styles` — which the
334
+ // site's own `entry.js` imports — becomes `<path>/styles`, a filesystem
335
+ // path. That bypasses the foundation's `exports` map, and the map is where
336
+ // `./styles` → `./styles.css` lives (along with `./dist` and
337
+ // `./dist/styles`). Measured 2026-09-01 on a stock marketing scaffold:
338
+ // `[vite:load-fallback] Could not load <path>/src/styles (imported by
339
+ // entry.js): ENOENT`. A HEALTHY project fails to build.
340
+ //
341
+ // Splitting it — exact `#foundation` → path, `#foundation/*` → name — is
342
+ // worse still: on a diverged tree the entry would come from one directory
343
+ // and the stylesheet from another, which is a state neither consistent
344
+ // option can reach.
345
+ //
346
+ // ⇒ **The name is load-bearing because the exports map is.** Detecting the
347
+ // disagreement is the correct shape; eliminating it costs the exports map.
281
348
  alias['#foundation'] = foundationInfo.name
282
349
  }
283
350
 
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Do WE and VITE agree on where the foundation is?
3
+ *
4
+ * ## The bug this exists for
5
+ *
6
+ * A site build resolves its foundation **twice, by two different mechanisms**,
7
+ * and until 2026-09-01 nothing checked that they agreed:
8
+ *
9
+ * 1. **We resolve a PATH.** `detectFoundationType('src', siteRoot)` returns
10
+ * `{ type: 'local', path: <siteRoot>/../src }`, and the
11
+ * `uniweb:ensure-foundation-entry` plugin generates `_entry.generated.js`
12
+ * into exactly that directory.
13
+ * 2. **Vite resolves a NAME.** `site/config.js` sets
14
+ * `alias['#foundation'] = foundationInfo.name` — the bare specifier — so
15
+ * Vite runs its own node_modules lookup and reads that package's `main`.
16
+ *
17
+ * Those name the same directory only because a package manager linked
18
+ * `node_modules/<name>` to the foundation. Nothing enforces it, and when it does
19
+ * not hold we generate into one directory while Vite reads another.
20
+ *
21
+ * ## ⛔ The failure is worse when it does NOT fail
22
+ *
23
+ * If the directory Vite reaches has no `_entry.generated.js`, the build dies with
24
+ * vite's `[commonjs--resolver] Failed to resolve entry for package "<name>"` —
25
+ * opaque, but loud.
26
+ *
27
+ * **If it has a STALE one, nothing fails at all.** The site builds against a
28
+ * different foundation than the one on disk, and reports success. A developer
29
+ * editing `src/` sees their change generated into `src/_entry.generated.js` and
30
+ * simply not take effect. *(Measured by the `flows` lane 2026-09-01: their
31
+ * harness minted fixtures under a `node_modules` that was itself a symlink to a
32
+ * seed tree, so a relative `../../src` link resolved into the seed. Every run
33
+ * that mutated its foundation and then built a site had been silently building
34
+ * the seed's copy — and presenting as green — for as long as the harness had
35
+ * existed. The red they opened a channel about was the harmless half.)*
36
+ *
37
+ * ⇒ **This check is about the silent case.** An error message can only reach the
38
+ * loud one; comparing the two answers reaches both.
39
+ *
40
+ * ## Why it compares REALPATHS and not link text
41
+ *
42
+ * `readlink node_modules/<name>` returning `../../src` looks healthy and proves
43
+ * nothing: **a relative symlink resolves against the link's target, not its
44
+ * location**, so with `node_modules` itself a link, `../../src` lands in a
45
+ * different tree. Only the resolved physical path answers the question vite
46
+ * actually asks. *(This module's first draft compared link text. It reported
47
+ * healthy on the one tree that was broken.)*
48
+ *
49
+ * ## Why it does its own node_modules walk
50
+ *
51
+ * `createRequire(...).resolve(name)` cannot be used: in the failure case `main`
52
+ * points at the missing entry, so it throws rather than telling us where it
53
+ * looked. `require.resolve(name + '/package.json')` cannot either — a foundation
54
+ * declares `exports: { ".": "./_entry.generated.js" }`, which gates every other
55
+ * subpath, so that lookup fails on a *healthy* package.
56
+ *
57
+ * So we walk `node_modules` upward for the package directory, which is the part
58
+ * of node resolution that locates a package before `exports` or `main` is
59
+ * consulted. That is the step whose answer we need.
60
+ */
61
+
62
+ import { existsSync, realpathSync } from 'node:fs'
63
+ import { dirname, join, parse } from 'node:path'
64
+
65
+ /**
66
+ * The directory a bare specifier's package lives in, by node's own
67
+ * node_modules walk — the step that runs before `exports`/`main` are read.
68
+ *
69
+ * @param {string} name - the bare specifier (a foundation's package name)
70
+ * @param {string} fromDir - the directory resolution starts in (the site root)
71
+ * @returns {string|null} the package directory, or null if no node_modules
72
+ * anywhere up the tree holds it
73
+ */
74
+ export function findPackageDir(name, fromDir) {
75
+ let dir = fromDir
76
+ const { root } = parse(fromDir)
77
+ // eslint-disable-next-line no-constant-condition
78
+ while (true) {
79
+ const candidate = join(dir, 'node_modules', name)
80
+ if (existsSync(join(candidate, 'package.json'))) return candidate
81
+ if (dir === root) return null
82
+ const parent = dirname(dir)
83
+ if (parent === dir) return null
84
+ dir = parent
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Compare where we generated the foundation entry against where vite will look.
90
+ *
91
+ * ⭐ **Silence is deliberate when the package is not found at all.** Not every
92
+ * supported layout puts the foundation in the site's `node_modules` — a
93
+ * `foundations/<name>/` multi-site project may not — and a warning that fires on
94
+ * a healthy project is worse than no warning, because the next person learns to
95
+ * ignore it. A package we cannot locate is also a case vite will fail on by
96
+ * itself, loudly. **We report only a disagreement we can actually prove**, which
97
+ * makes a false positive impossible by construction.
98
+ *
99
+ * @param {Object} args
100
+ * @param {string} args.name - the foundation's declared name (the bare specifier)
101
+ * @param {string} args.generatedInto - the directory we wrote `_entry.generated.js` to
102
+ * @param {string} args.siteRoot - where vite resolves the bare specifier from
103
+ * @returns {{ ok: true } | { ok: false, ours: string, theirs: string, message: string }}
104
+ */
105
+ export function checkFoundationResolution({ name, generatedInto, siteRoot }) {
106
+ const theirsRaw = findPackageDir(name, siteRoot)
107
+ if (!theirsRaw) return { ok: true }
108
+
109
+ let ours, theirs
110
+ try {
111
+ ours = realpathSync(generatedInto)
112
+ theirs = realpathSync(theirsRaw)
113
+ } catch {
114
+ // A path vanished between the walk and the realpath. Not a disagreement we
115
+ // can prove, so it is not one we report.
116
+ return { ok: true }
117
+ }
118
+
119
+ if (ours === theirs) return { ok: true }
120
+
121
+ return {
122
+ ok: false,
123
+ ours,
124
+ theirs,
125
+ message: [
126
+ `[site] ⛔ Foundation "${name}" resolves to two different directories.`,
127
+ ``,
128
+ ` we generated its entry into : ${ours}`,
129
+ ` vite will import it from : ${theirs}`,
130
+ ``,
131
+ ` Your site build will use the SECOND one, so edits to the first do not`,
132
+ ` take effect — and if that directory carries a stale _entry.generated.js`,
133
+ ` the build SUCCEEDS against the wrong foundation.`,
134
+ ``,
135
+ ` This means node_modules/${name} under ${siteRoot} is not the foundation`,
136
+ ` directory. Re-run your package manager's install. If you copied or moved`,
137
+ ` this project, check whether node_modules (or a parent of it) is a symlink`,
138
+ ` into another tree — a relative link inside one resolves against the link's`,
139
+ ` target, so it can point outside this project while looking correct.`,
140
+ ].join('\n'),
141
+ }
142
+ }