diff --git a/.changeset/tidy-paths-resolve.md b/.changeset/tidy-paths-resolve.md new file mode 100644 index 000000000..693bb8647 --- /dev/null +++ b/.changeset/tidy-paths-resolve.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Scope the built-in `~` alias to the app package, so files in other workspace packages can map `~` to their own root through an importer-aware plugin such as `vite-tsconfig-paths`. In stylesheets and asset URLs (CSS `@import`, `url()`, `new URL(..., import.meta.url)`) `~` still always means the app root, since Vite resolves those without running plugins. diff --git a/packages/start/src/config/app-root-alias.spec.ts b/packages/start/src/config/app-root-alias.spec.ts new file mode 100644 index 000000000..8680adf0f --- /dev/null +++ b/packages/start/src/config/app-root-alias.spec.ts @@ -0,0 +1,77 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { build, createServer, type Plugin, type ViteDevServer } from "vite"; +import { afterEach, expect, it } from "vitest"; + +import { appRootAlias } from "./app-root-alias.ts"; + +const roots: string[] = []; +const servers: ViteDevServer[] = []; + +/** An app package with a `src` app root, plus a nested `lib` workspace package. */ +function createProject() { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "solid-start-app-root-alias-"))); + roots.push(root); + + mkdirSync(join(root, "src")); + mkdirSync(join(root, "lib/src"), { recursive: true }); + writeFileSync(join(root, "package.json"), "{}"); + writeFileSync(join(root, "lib/package.json"), "{}"); + writeFileSync(join(root, "src/demo.ts"), 'export default "app";'); + writeFileSync(join(root, "lib/src/demo.ts"), 'export default "package";'); + + return root; +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => server.close())); + for (const root of roots.splice(0)) rmSync(root, { recursive: true }); +}); + +it("resolves ~ for the app package, leaving other packages to importer-aware plugins", async () => { + const root = createProject(); + const packageRoot = join(root, "lib"); + // Stands in for vite-tsconfig-paths: maps `~` to the importing package's root. + const packagePaths: Plugin = { + name: "test:package-paths", + enforce: "pre", + resolveId(id, importer, options) { + if (id !== "~/demo" || !importer?.startsWith(packageRoot)) return null; + return this.resolve(join(packageRoot, "src/demo"), importer, { ...options, skipSelf: true }); + }, + }; + const server = await createServer({ + configFile: false, + logLevel: "silent", + root, + server: { middlewareMode: true }, + plugins: [appRootAlias(root, "./src"), packagePaths], + }); + servers.push(server); + + await expect( + server.pluginContainer.resolveId("~/demo", join(root, "src/app.tsx")), + ).resolves.toMatchObject({ id: join(root, "src/demo.ts") }); + await expect( + server.pluginContainer.resolveId("~/demo", join(root, "lib/src/index.ts")), + ).resolves.toMatchObject({ id: join(root, "lib/src/demo.ts") }); +}); + +it("resolves ~ in stylesheets, which are resolved without user plugins", async () => { + const root = createProject(); + writeFileSync(join(root, "src/theme.css"), "body { color: red; }"); + writeFileSync(join(root, "src/app.css"), '@import "~/theme.css";'); + writeFileSync(join(root, "src/main.js"), 'import "./app.css";'); + + const result = (await build({ + configFile: false, + logLevel: "silent", + root, + plugins: [appRootAlias(root, "./src")], + build: { write: false, rollupOptions: { input: join(root, "src/main.js") } }, + })) as { output: { fileName: string; source?: unknown }[] }; + + const css = result.output.find(chunk => chunk.fileName.endsWith(".css")); + expect(String(css?.source)).toContain("color:red"); +}); diff --git a/packages/start/src/config/app-root-alias.ts b/packages/start/src/config/app-root-alias.ts new file mode 100644 index 000000000..a3fca0801 --- /dev/null +++ b/packages/start/src/config/app-root-alias.ts @@ -0,0 +1,65 @@ +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { normalizePath, type Plugin } from "vite"; + +/** + * `~/app.css`, `~/logo.svg`, ... but not `~/lib/api.ts` or `~/components/Counter`. + * + * CSS `@import`, `url()`, preprocessor imports and `new URL(..., import.meta.url)` + * are resolved by internal Vite resolvers that only run the alias plugin, never + * user plugins, so those ids can never reach `resolveId` below. Keeping non-module + * ids on a plain alias preserves `~` in stylesheets and asset URLs, where it always + * means the app root. + */ +const NON_MODULE_ID = /^~\/([^?#]*\.(?![cm]?[jt]sx?(?:[?#]|$))[^./?#]+(?:[?#].*)?)$/; + +/** + * Provides SolidStart's `~` app-root alias without claiming module imports made + * by other workspace packages. Those packages may map `~` to their own root + * through an importer-aware resolver such as vite-tsconfig-paths. + */ +export function appRootAlias(projectRoot: string, appRoot: string): Plugin { + const appDir = normalizePath(resolve(projectRoot, appRoot)); + const packageRoots = new Map(); + + /** Nearest directory at or above `directory` that holds a `package.json`. */ + function packageRoot(directory: string): string | undefined { + if (!packageRoots.has(directory)) { + const parent = dirname(directory); + packageRoots.set( + directory, + existsSync(join(directory, "package.json")) + ? directory + : parent === directory + ? undefined + : packageRoot(parent), + ); + } + return packageRoots.get(directory); + } + + const appPackage = packageRoot(appDir); + + /** True only when the importer demonstrably belongs to another package. */ + function isForeignImporter(importer: string) { + const file = normalizePath(importer.replace(/[?#].*$/s, "")); + if (!isAbsolute(file)) return false; + const owner = packageRoot(dirname(file)); + return owner !== undefined && owner !== appPackage; + } + + return { + name: "solid-start:app-root-alias", + enforce: "pre", + config() { + return { resolve: { alias: [{ find: NON_MODULE_ID, replacement: `${appDir}/$1` }] } }; + }, + async resolveId(id, importer, options) { + if (id !== "~" && !id.startsWith("~/")) return null; + if (importer && isForeignImporter(importer)) return null; + + const target = join(appDir, id.slice(1)); + return (await this.resolve(target, importer, { ...options, skipSelf: true })) ?? target; + }, + }; +} diff --git a/packages/start/src/config/index.ts b/packages/start/src/config/index.ts index de2b20503..cf22a13d2 100644 --- a/packages/start/src/config/index.ts +++ b/packages/start/src/config/index.ts @@ -4,6 +4,7 @@ import { basename, extname, isAbsolute, join } from "node:path"; import type { PluginOption } from "vite"; import solid, { type Options as SolidOptions } from "vite-plugin-solid"; import { type ServerFunctionsOptions, serverFunctionsPlugin } from "../directives/index.ts"; +import { appRootAlias } from "./app-root-alias.ts"; import { boundaryModules } from "./boundary-modules.ts"; import { DEFAULT_EXTENSIONS, VIRTUAL_MODULES, VITE_ENVIRONMENTS } from "./constants.ts"; import { devServer } from "./dev-server.ts"; @@ -262,7 +263,6 @@ export function solidStart(options?: SolidStartOptions): Array { resolve: { alias: { "@solidjs/start/server/entry": handlers.server, - "~": join(process.cwd(), start.appRoot), ...(!start.ssr ? { "@solidjs/start/server": "@solidjs/start/server/spa", @@ -311,6 +311,7 @@ export function solidStart(options?: SolidStartOptions): Array { }; }, }, + appRootAlias(root, start.appRoot), manifest(start), fsRoutes({ routers: {