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/svelte-vue-peer-resolution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"jsrepo": patch
---

fix: resolve the `svelte`/`vue` compilers from the consumer's project directory instead of jsrepo's own install location, so builds no longer report `<pkg> is required for <lang> language support to work` when the framework is installed in the project but jsrepo lives in a different `node_modules`
13 changes: 11 additions & 2 deletions packages/jsrepo/src/langs/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { createRequire } from 'node:module';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { type CssOptions, css } from '@/langs/css';
import { type HtmlOptions, html } from '@/langs/html';
import { type JsOptions, js } from '@/langs/js';
import { type SvelteOptions, svelte } from '@/langs/svelte';
import type { Language } from '@/langs/types';
import { type VueOptions, vue } from '@/langs/vue';

// resolve from the user's project first, then fall back to jsrepo's own location
const projectRequire = createRequire(pathToFileURL(path.join(process.cwd(), 'package.json')));
const require = createRequire(import.meta.url);

function isModuleAvailable(moduleName: string): boolean {
try {
require.resolve(moduleName);
projectRequire.resolve(moduleName);
return true;
} catch {
return false;
try {
require.resolve(moduleName);
return true;
} catch {
return false;
}
}
}

Expand Down
86 changes: 86 additions & 0 deletions packages/jsrepo/src/langs/load-peer-compiler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { MissingPeerDependencyError } from '@/utils/errors';
import { joinAbsolute } from '@/utils/path';
import type { AbsolutePath } from '@/utils/types';

export type LoadPeerCompilerOptions = {
/** The directory to resolve the module from (i.e. the consumer's project root). */
cwd: AbsolutePath;
/** The package name to report if the module can't be resolved. */
packageName: string;
/** The human readable feature name to report if the module can't be resolved. */
feature: string;
};

/**
* Normalizes ESM default interop for a dynamically imported module.
*
* @remarks
* Only used for the ESM fallback path. When a CommonJS module is reached via
* `import()`, Node exposes its `module.exports` under `default`; unwrap it so
* callers get the real API regardless of how the exports lexer analyzed the file.
*/
function interopDefault<T>(mod: Record<string, unknown>): T {
if (
mod?.default != null &&
(typeof mod.default === 'object' || typeof mod.default === 'function')
) {
return mod.default as T;
}
return mod as T;
}

/**
* Loads a peer compiler module (e.g. `svelte/compiler`) by resolving it from the
* consumer's project directory (`cwd`) first, then falling back to jsrepo's own
* resolution.
*
* @remarks
* A plain `await import(specifier)` resolves the module relative to jsrepo's own
* location on disk rather than the user's project. When jsrepo is installed in a
* different `node_modules` than the framework (a global/`npx` install, a hoisted
* monorepo, or a package manager that doesn't hoist peer deps), that lookup fails
* even though the user *did* install the dependency in their project. Resolving
* from `cwd` first ensures we find the copy the user actually installed.
*
* `svelte/compiler` and `vue/compiler-sfc` ship as CommonJS, so we load them with
* `require()` — which returns `module.exports` directly with the real API on it.
* This avoids the ESM-interop ambiguity (exports ending up under `default`) that
* varies between Node versions when a CJS file is reached via `import()`. Modules
* that are ESM-only fall back to a dynamic `import()`.
*
* @throws {MissingPeerDependencyError} if the module can't be resolved from either location.
*/
export async function loadPeerCompiler<T>(
specifier: string,
{ cwd, packageName, feature }: LoadPeerCompilerOptions
): Promise<T> {
// resolve from the user's project first, then jsrepo's own location
const bases = [pathToFileURL(joinAbsolute(cwd, 'package.json')).href, import.meta.url];

for (const base of bases) {
const require = createRequire(base);

let resolved: string;
try {
resolved = require.resolve(specifier);
} catch {
// not resolvable from this base, try the next one
continue;
}

try {
// CJS: returns module.exports with the real API, no interop ambiguity
return require(specifier) as T;
} catch (error) {
// ESM-only module: require() throws, fall back to a dynamic import
if ((error as NodeJS.ErrnoException)?.code === 'ERR_REQUIRE_ESM') {
return interopDefault<T>(await import(pathToFileURL(resolved).href));
}
throw error;
}
}

throw new MissingPeerDependencyError(packageName, feature);
}
18 changes: 9 additions & 9 deletions packages/jsrepo/src/langs/svelte.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { getImports, installDependencies, resolveImports, transformImports } from '@/langs/js';
import { loadPeerCompiler } from '@/langs/load-peer-compiler';
import type { Language } from '@/langs/types';
import { MissingPeerDependencyError } from '@/utils/errors';
import type { AbsolutePath } from '@/utils/types';

// biome-ignore lint/complexity/noBannedTypes: leave me alone for a minute
export type SvelteOptions = {};

let svelteCompiler: typeof import('svelte/compiler') | null = null;

async function loadSvelteCompiler() {
async function loadSvelteCompiler(cwd: AbsolutePath) {
if (svelteCompiler) {
return svelteCompiler;
}

try {
svelteCompiler = await import('svelte/compiler');
return svelteCompiler;
} catch {
throw new MissingPeerDependencyError('svelte', 'Svelte language support');
}
svelteCompiler = await loadPeerCompiler<typeof import('svelte/compiler')>('svelte/compiler', {
cwd,
packageName: 'svelte',
feature: 'Svelte language support',
});
return svelteCompiler;
}

/**
Expand All @@ -32,7 +32,7 @@ export function svelte(_options: SvelteOptions = {}): Language {
name: 'svelte',
canResolveDependencies: (fileName) => fileName.endsWith('.svelte'),
resolveDependencies: async (code, opts) => {
const sv = await loadSvelteCompiler();
const sv = await loadSvelteCompiler(opts.cwd);
const neededScripts: string[] = [];
await sv.preprocess(code, {
script: async ({ content }) => {
Expand Down
18 changes: 9 additions & 9 deletions packages/jsrepo/src/langs/vue.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
import { getImports, installDependencies, resolveImports, transformImports } from '@/langs/js';
import { loadPeerCompiler } from '@/langs/load-peer-compiler';
import type { Language } from '@/langs/types';
import { MissingPeerDependencyError } from '@/utils/errors';
import type { AbsolutePath } from '@/utils/types';

// biome-ignore lint/complexity/noBannedTypes: leave me alone for a minute
export type VueOptions = {};

let vueCompiler: typeof import('vue/compiler-sfc') | null = null;

async function loadVueCompiler() {
async function loadVueCompiler(cwd: AbsolutePath) {
if (vueCompiler) {
return vueCompiler;
}

try {
vueCompiler = await import('vue/compiler-sfc');
return vueCompiler;
} catch {
throw new MissingPeerDependencyError('vue', 'Vue language support');
}
vueCompiler = await loadPeerCompiler<typeof import('vue/compiler-sfc')>('vue/compiler-sfc', {
cwd,
packageName: 'vue',
feature: 'Vue language support',
});
return vueCompiler;
}

/**
Expand All @@ -32,7 +32,7 @@ export function vue(_options: VueOptions = {}): Language {
name: 'vue',
canResolveDependencies: (fileName) => fileName.endsWith('.vue'),
resolveDependencies: async (code, opts) => {
const v = await loadVueCompiler();
const v = await loadVueCompiler(opts.cwd);
const neededScripts: string[] = [];
const parsed = v.parse(code, {
filename: opts.fileName,
Expand Down
35 changes: 35 additions & 0 deletions packages/jsrepo/tests/langs/load-peer-compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import path from 'pathe';
import { describe, expect, it } from 'vitest';
import { loadPeerCompiler } from '@/langs/load-peer-compiler';
import { MissingPeerDependencyError } from '@/utils/errors';
import type { AbsolutePath } from '@/utils/types';

const CWD = process.cwd() as AbsolutePath;

describe('loadPeerCompiler', () => {
it('resolves a peer compiler installed in the project cwd', async () => {
const compiler = await loadPeerCompiler<typeof import('svelte/compiler')>(
'svelte/compiler',
{
cwd: CWD,
packageName: 'svelte',
feature: 'Svelte language support',
}
);

expect(typeof compiler.preprocess).toBe('function');
});

it('throws a MissingPeerDependencyError when the module cannot be resolved from anywhere', async () => {
// a directory with no node_modules containing the dependency
const emptyCwd = path.join(__dirname, '../fixtures') as AbsolutePath;

await expect(
loadPeerCompiler('this-package-does-not-exist/compiler', {
cwd: emptyCwd,
packageName: 'this-package-does-not-exist',
feature: 'Nonexistent language support',
})
).rejects.toBeInstanceOf(MissingPeerDependencyError);
});
});
Loading