Skip to content

Commit b1ff983

Browse files
authored
fix(export): resolve npm packages via node module resolution in virtual content loader (#346)
* fix(export): resolve npm packages via node module resolution in virtual content loader The virtual content loader hardcoded a node_modules/ path for @openzeppelin/ui-styles/global.css, which only works when pnpm uses shamefully-hoist (hoisted node-linker). In Docker builds where .npmrc is excluded, pnpm defaults to isolated mode and the package is not hoisted to the root node_modules, causing the CSS theme to silently resolve as empty. Use createRequire from the builder package directory to resolve npm: prefixed entries via Node's module resolution algorithm, which correctly follows pnpm symlinks in any hoisting strategy. * fix(export): escape backslashes correctly in css template literals The .replace(/\\/g, '\\') call was a no-op (replacing backslash with itself). Fix to .replace(/\\/g, '\\\\') so backslashes in CSS content are properly escaped when embedded in template literals. Flagged by CodeQL.
1 parent 2b74cde commit b1ff983

1 file changed

Lines changed: 30 additions & 22 deletions

File tree

apps/builder/vite-plugins/virtual-content-loader.ts

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fs from 'fs';
2+
import { createRequire } from 'node:module';
23
import path from 'path';
34
import type { Plugin } from 'vite';
45

@@ -20,6 +21,8 @@ import { logger } from '@openzeppelin/ui-utils';
2021
* How it works:
2122
* 1. Define a mapping (`virtualFiles`) between a virtual module name (e.g., 'virtual:tailwind-config-content')
2223
* and the real file path relative to the monorepo root.
24+
* Entries prefixed with `npm:` are resolved via Node's module resolution from the
25+
* builder package directory, so they work with any pnpm node-linker strategy.
2326
* 2. The `resolveId` hook intercepts imports matching the 'virtual:' prefix and marks them
2427
* as resolved virtual modules using the null byte prefix ('\0').
2528
* 3. The `load` hook intercepts requests for these resolved virtual modules.
@@ -34,23 +37,24 @@ import { logger } from '@openzeppelin/ui-utils';
3437
* - Import the content in your application code: `import myConfig from 'virtual:my-config-content';`
3538
*/
3639

37-
// Define the virtual module IDs and their corresponding real file paths
38-
// Paths are relative to the monorepo root
40+
// Define the virtual module IDs and their corresponding real file paths.
41+
// Paths are relative to the monorepo root unless prefixed with `npm:`,
42+
// which triggers Node module resolution from the builder package directory.
3943
const VIRTUAL_MODULE_PREFIX = 'virtual:';
4044
const RESOLVED_VIRTUAL_MODULE_PREFIX = '\0virtual:'; // Null byte prefix for resolved IDs
45+
const NPM_RESOLVE_PREFIX = 'npm:';
4146

42-
// Renamed map to reflect it handles more than just config
4347
const virtualFiles: Record<string, string> = {
44-
// Config files
48+
// Config files (relative to monorepo root)
4549
'tailwind-config-content': 'tailwind.config.cjs',
4650
'postcss-config-content': 'postcss.config.cjs',
4751
'components-json-content': 'components.json',
48-
// CSS files - now from npm package after migration
49-
'global-css-content': 'node_modules/@openzeppelin/ui-styles/global.css',
50-
// Template-specific CSS (add template name if multiple templates have different styles.css)
52+
// npm packages (resolved via Node module resolution — works with any pnpm hoisting strategy)
53+
'global-css-content': 'npm:@openzeppelin/ui-styles/global.css',
54+
// Template-specific CSS (relative to monorepo root)
5155
'template-vite-styles-css-content':
5256
'apps/builder/src/export/templates/typescript-react-vite/src/styles.css',
53-
// Core Type Files (added)
57+
// Core Type Files (relative to monorepo root)
5458
'contract-schema-content': 'packages/types/src/contracts/schema.ts',
5559
};
5660

@@ -61,44 +65,48 @@ export function virtualContentLoaderPlugin(): Plugin {
6165
// Resolve the monorepo root directory relative to this plugin file
6266
// Assumes this file is in apps/builder/vite-plugins/
6367
const monorepoRoot = path.resolve(__dirname, '../../..');
68+
const builderDir = path.join(monorepoRoot, 'apps/builder');
69+
const builderRequire = createRequire(path.join(builderDir, 'package.json'));
6470

6571
return {
66-
name: 'virtual-content-loader', // Renamed plugin for clarity
72+
name: 'virtual-content-loader',
6773

6874
resolveId(id) {
6975
if (id.startsWith(VIRTUAL_MODULE_PREFIX)) {
7076
const moduleName = id.substring(VIRTUAL_MODULE_PREFIX.length);
71-
// Check the renamed map
7277
if (virtualFiles[moduleName]) {
73-
// Prepend null byte to mark as resolved virtual module
7478
return RESOLVED_VIRTUAL_MODULE_PREFIX + moduleName;
7579
}
7680
}
77-
return null; // Let other plugins handle it
81+
return null;
7882
},
7983

8084
load(id) {
8185
if (id.startsWith(RESOLVED_VIRTUAL_MODULE_PREFIX)) {
8286
const moduleName = id.substring(RESOLVED_VIRTUAL_MODULE_PREFIX.length);
83-
// Use the renamed map
8487
const fileName = virtualFiles[moduleName];
8588

8689
if (fileName) {
8790
try {
88-
const filePath = path.resolve(monorepoRoot, fileName);
91+
let filePath: string;
92+
if (fileName.startsWith(NPM_RESOLVE_PREFIX)) {
93+
// Use Node module resolution from the builder package so this works
94+
// with any pnpm node-linker strategy (hoisted or isolated).
95+
const specifier = fileName.substring(NPM_RESOLVE_PREFIX.length);
96+
filePath = builderRequire.resolve(specifier);
97+
} else {
98+
filePath = path.resolve(monorepoRoot, fileName);
99+
}
100+
89101
const content = fs.readFileSync(filePath, 'utf-8');
90102

91-
// Choose export format based on file type
92103
if (fileName.endsWith('.css')) {
93-
// Export CSS content as a raw JS string literal using backticks
94104
const escapedContent = content
95-
.replace(/\\/g, '\\') // Escape backslashes
96-
.replace(/`/g, '\\`') // Escape backticks
97-
.replace(/\$/g, '\\$'); // Escape dollars (for template literals)
105+
.replace(/\\/g, '\\\\')
106+
.replace(/`/g, '\\`')
107+
.replace(/\$/g, '\\$');
98108
return `export default \`${escapedContent}\`;`;
99109
} else {
100-
// For non-CSS (like .cjs, .json), try JSON.stringify
101-
// This worked for postcss.config.cjs previously and might be safer for JS/JSON code
102110
return `export default ${JSON.stringify(content)};`;
103111
}
104112
} catch (error: unknown) {
@@ -111,7 +119,7 @@ export function virtualContentLoaderPlugin(): Plugin {
111119
}
112120
}
113121
}
114-
return null; // Let other plugins handle it
122+
return null;
115123
},
116124
};
117125
}

0 commit comments

Comments
 (0)