fix(cli): resolve /ui/ in app dev and lint, bare ui/ imports from the app (#12050)

* fix(cli): resolve /ui/ in app dev and lint, and its bare imports from the app

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): match shared ui/ files by real path so a symlinked ui/ still redirects

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): keep relative css @import in shared ui/ resolving next to the file

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): let esbuild resolve relative css @import in ui/ so suffixes keep working

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hugocasa
2026-10-28 06:08:28 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d3872cb183
commit 3ee745192c
4 changed files with 151 additions and 39 deletions
+82 -38
View File
@@ -285,6 +285,84 @@ export async function ensureNodeModules(appDir?: string): Promise<void> {
}
}
/**
* The workspace's shared `ui/` folder for an app: `ui/` next to the nearest
* wmill.yaml above `appDir`, the root `wmill app push` and `wmill app bundle`
* run from. Undefined when no wmill.yaml is found.
*/
export function findSharedUiDir(appDir: string): string | undefined {
let dir = path.resolve(appDir);
while (true) {
if (fs.existsSync(path.join(dir, "wmill.yaml"))) {
return path.join(dir, "ui");
}
const parent = path.dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
}
/**
* Resolves `/ui/<file>` imports to the workspace's shared `ui/` folder, and bare
* package imports made from those files against the app's own node_modules: the
* server-side bundler places `ui/` inside the app, so walking up from the shared
* folder instead would miss the app's packages or load a second copy of them
* (two Reacts break hooks at runtime).
*/
export function createSharedUiPlugin(sharedUiDir: string, appDir: string): any {
// Real paths throughout: esbuild follows symlinks for relative imports, so a
// symlinked ui/ would otherwise give its files two different prefixes.
const uiPrefix = fs.realpathSync(sharedUiDir) + path.sep;
const resolveDir = path.resolve(appDir);
return {
name: "wmill-shared-ui",
setup(build: any) {
build.onResolve({ filter: /^\/ui\// }, (args: any) => {
const rel = args.path.slice("/ui/".length);
const candidates = [rel];
if (!path.extname(rel)) {
candidates.push(
rel + ".tsx",
rel + ".ts",
rel + ".jsx",
rel + ".js",
rel + ".css",
path.join(rel, "index.tsx"),
path.join(rel, "index.ts"),
);
}
for (const c of candidates) {
const full = path.join(sharedUiDir, c);
if (fs.existsSync(full)) {
return { path: fs.realpathSync(full) };
}
}
return {
errors: [
{
text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`,
},
],
};
});
build.onResolve({ filter: /^[^./]/ }, async (args: any) => {
if (!args.importer.startsWith(uiPrefix)) return undefined;
// CSS reads `@import "base.css"` as relative first; keep that when the
// sibling resolves, and only send package names to the app.
if (args.kind === "import-rule" || args.kind === "url-token") {
const sibling = await build.resolve("./" + args.path, {
resolveDir: args.resolveDir,
kind: args.kind,
});
if (sibling.errors.length === 0) return sibling;
}
// No importer is passed, so this nested resolve does not re-enter here.
return build.resolve(args.path, { resolveDir, kind: args.kind });
});
},
};
}
/**
* Creates an esbuild bundle for the app
* @param options Bundle configuration options
@@ -365,44 +443,10 @@ export async function createBundle(
},
};
const sharedUiPlugins: any[] = [];
if (options.sharedUiDir && fs.existsSync(options.sharedUiDir)) {
const sharedUiDir = options.sharedUiDir;
sharedUiPlugins.push({
name: "wmill-shared-ui",
setup(build: any) {
// Intercept imports of /ui/<file> and resolve to the workspace ui/ folder.
build.onResolve({ filter: /^\/ui\// }, (args: any) => {
const rel = args.path.slice("/ui/".length);
const candidates = [rel];
if (!path.extname(rel)) {
candidates.push(
rel + ".tsx",
rel + ".ts",
rel + ".jsx",
rel + ".js",
rel + ".css",
path.join(rel, "index.tsx"),
path.join(rel, "index.ts"),
);
}
for (const c of candidates) {
const full = path.join(sharedUiDir, c);
if (fs.existsSync(full)) {
return { path: full };
}
}
return {
errors: [
{
text: `Could not resolve shared UI import "${args.path}" in ${sharedUiDir}`,
},
],
};
});
},
});
}
const sharedUiPlugins: any[] =
options.sharedUiDir && fs.existsSync(options.sharedUiDir)
? [createSharedUiPlugin(options.sharedUiDir, appDir)]
: [];
const buildOptions = {
...DEFAULT_BUILD_OPTIONS,
+9
View File
@@ -19,8 +19,10 @@ import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts";
import { WebSocket, WebSocketServer } from "ws";
import {
createFrameworkPlugins,
createSharedUiPlugin,
detectFrameworks,
ensureNodeModules,
findSharedUiDir,
getDevBuildOptions,
} from "./bundle.ts";
import { wmillTsDev as wmillTs } from "./wmillTsDev.ts";
@@ -593,6 +595,12 @@ async function dev(opts: DevOptions, appFolder?: string) {
// Load framework-specific plugins (svelte) based on package.json
const frameworkPlugins = await createFrameworkPlugins(appDir);
const sharedUiDir = findSharedUiDir(appDir);
const sharedUiPlugins =
sharedUiDir && fs.existsSync(sharedUiDir)
? [createSharedUiPlugin(sharedUiDir, appDir)]
: [];
const wmillPlugin = {
name: "wmill-virtual",
setup(build: any) {
@@ -663,6 +671,7 @@ async function dev(opts: DevOptions, appFolder?: string) {
},
},
wmillPlugin,
...sharedUiPlugins,
],
});
+2 -1
View File
@@ -6,7 +6,7 @@ import { colors } from "@cliffy/ansi/colors";
import * as log from "../../core/log.ts";
import { yamlParseFile } from "../../utils/yaml.ts";
import { GlobalOptions } from "../../types.ts";
import { createBundle, detectFrameworks } from "./bundle.ts";
import { createBundle, detectFrameworks, findSharedUiDir } from "./bundle.ts";
import { APP_BACKEND_FOLDER } from "./app_metadata.ts";
import { loadRunnablesFromBackend } from "./raw_apps.ts";
import { inferSchema } from "../../utils/metadata.ts";
@@ -173,6 +173,7 @@ async function validateBuild(
entryPoint,
production: true,
minify: false,
sharedUiDir: findSharedUiDir(appDir),
});
log.info(colors.green("✅ Build successful"));
+58
View File
@@ -0,0 +1,58 @@
/**
* `/ui/...` imports, driven through `createBundle` the way `app dev` and
* `app lint` find the shared folder: from the app up to its wmill.yaml.
*/
import { afterAll, beforeAll, expect, test } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { createBundle, findSharedUiDir } from "../src/commands/app/bundle.ts";
let root: string;
let originalCwd: string;
function write(rel: string, content: string) {
const full = path.join(root, rel);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content, "utf-8");
}
beforeAll(() => {
originalCwd = process.cwd();
root = fs.mkdtempSync(path.join(os.tmpdir(), "raw-app-shared-ui-"));
write("wmill.yaml", "defaultTs: bun\n");
// ui/ as a symlink, with the bare import one relative hop away: esbuild reaches
// inner.ts by its real path, not through the link.
write("shared/button.ts", `export { label } from "./inner";\n`);
write("shared/inner.ts", `import { who } from "pkg";\nimport "./styles.css";\nexport const label = who;\n`);
write("shared/styles.css", `@import "base.css?theme";\n`);
write("shared/base.css", `.from-ui-css { color: red; }\n`);
fs.symlinkSync(path.join(root, "shared"), path.join(root, "ui"));
write("f/demo.raw_app/package.json", `{"name":"demo"}`);
write("f/demo.raw_app/index.tsx", `import { label } from "/ui/button";\nconsole.log(label);\n`);
write("f/demo.raw_app/node_modules/pkg/index.js", `export const who = "from-app";\n`);
// A copy above ui/ must not win: it would be a second instance of the package.
write("node_modules/pkg/index.js", `export const who = "from-root";\n`);
process.chdir(path.join(root, "f/demo.raw_app"));
});
afterAll(() => {
process.chdir(originalCwd);
fs.rmSync(root, { recursive: true, force: true });
});
test("bare imports in ui/ resolve from the app's node_modules", async () => {
const appDir = path.join(root, "f/demo.raw_app");
const sharedUiDir = findSharedUiDir(appDir);
expect(sharedUiDir).toBe(path.join(root, "ui"));
const { js, css } = await createBundle({
entryPoint: path.join(appDir, "index.tsx"),
minify: false,
sharedUiDir,
});
expect(js).toContain("from-app");
expect(js).not.toContain("from-root");
expect(css).toContain("from-ui-css");
});