Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-paths-resolve.md
Original file line number Diff line number Diff line change
@@ -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.
77 changes: 77 additions & 0 deletions packages/start/src/config/app-root-alias.spec.ts
Original file line number Diff line number Diff line change
@@ -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");
});
65 changes: 65 additions & 0 deletions packages/start/src/config/app-root-alias.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | undefined>();

/** 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;
},
};
}
3 changes: 2 additions & 1 deletion packages/start/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -262,7 +263,6 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
resolve: {
alias: {
"@solidjs/start/server/entry": handlers.server,
"~": join(process.cwd(), start.appRoot),
...(!start.ssr
? {
"@solidjs/start/server": "@solidjs/start/server/spa",
Expand Down Expand Up @@ -311,6 +311,7 @@ export function solidStart(options?: SolidStartOptions): Array<PluginOption> {
};
},
},
appRootAlias(root, start.appRoot),
manifest(start),
fsRoutes({
routers: {
Expand Down
Loading