Skip to content

Commit aed0f53

Browse files
committed
fix(ui): rewrite registry '../lib/utils.ts' imports on add
Registry components import cn from '../lib/utils.ts' (matching the registry's own layout, where components live one level beside the utils file). When 'webjs ui add <name>' fetches a component over HTTP and writes it to the user's components/ui/<name>.ts, the literal '../lib/utils.ts' resolves to components/lib/utils.ts in the user's project, which doesn't exist. The fix introduces a small rewriteUtilsImport() helper in packages/ui/src/commands/add.js. It uses components.json's config.resolvedPaths.utils (already an absolute path ending in .ts, resolved by get-config.js from the aliases.utils setting) to compute the correct relative path from the target file's directory and substitutes both single- and double-quoted forms of the registry literal. Webjs's scaffold (aliases.utils = 'lib/utils/cn') now correctly gets '../../lib/utils/cn.ts' in fetched components. Other project shapes (shadcn-style lib/utils, vite-style src/lib/utils) work too. Seven new tests cover the rewrite: - Unit: maps to lib/utils/cn, legacy lib/utils, src/lib, double quotes, no-op when no import, no-op when config lacks resolvedPaths.utils. - Integration: a stubbed registry component with the literal '../lib/utils.ts' lands as '../../lib/utils/cn.ts' on disk.
1 parent debb975 commit aed0f53

2 files changed

Lines changed: 141 additions & 3 deletions

File tree

packages/ui/src/commands/add.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Command } from 'commander';
22
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3-
import { dirname, join, basename } from 'node:path';
3+
import { dirname, join, basename, relative as relPath } from 'node:path';
44
import prompts from 'prompts';
55
import { execSync } from 'node:child_process';
66
import { getConfig } from '../utils/get-config.js';
@@ -70,10 +70,39 @@ async function writeRegistryFile(cwd, config, item, file, opts) {
7070
}
7171
}
7272

73-
writeFileSync(target, file.content || '', 'utf8');
73+
const content = rewriteUtilsImport(file.content || '', target, config);
74+
writeFileSync(target, content, 'utf8');
7475
logger.success(`Wrote ${relative(cwd, target)}`);
7576
}
7677

78+
/**
79+
* Rewrite the registry-relative `'../lib/utils.ts'` import to the path
80+
* that resolves correctly from the file's target location to the user's
81+
* cn() helper.
82+
*
83+
* The registry source assumes its own layout (`<registry>/components/<x>.ts`
84+
* imports `'../lib/utils.ts'`). When that file lands in the user's
85+
* components/ui/<x>.ts, the literal `'../lib/utils.ts'` resolves to
86+
* `components/lib/utils.ts`, which doesn't exist. We compute the actual
87+
* relative path from the target directory to `config.resolvedPaths.utils`
88+
* (an absolute path the user has already configured via components.json's
89+
* aliases.utils) and substitute it in.
90+
*
91+
* @param {string} content raw file content from the registry
92+
* @param {string} target absolute path where the file will be written
93+
* @param {{ resolvedPaths: { utils: string } }} config parsed components.json
94+
*/
95+
export function rewriteUtilsImport(content, target, config) {
96+
if (!content.includes('../lib/utils.ts')) return content;
97+
const utilsAbs = config?.resolvedPaths?.utils;
98+
if (!utilsAbs) return content;
99+
let rel = relPath(dirname(target), utilsAbs).split(/[\\/]/).join('/');
100+
if (!rel.startsWith('.')) rel = './' + rel;
101+
return content
102+
.replaceAll("'../lib/utils.ts'", `'${rel}'`)
103+
.replaceAll('"../lib/utils.ts"', `"${rel}"`);
104+
}
105+
77106
function resolveTarget(cwd, config, item, file) {
78107
// explicit `target` wins
79108
if (file.target) return join(cwd, file.target);

packages/ui/test/add-command.test.js

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
33
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
44
import { join } from 'node:path';
55
import { tmpdir } from 'node:os';
6-
import { add } from '../src/commands/add.js';
6+
import { add, rewriteUtilsImport } from '../src/commands/add.js';
77

88
const origFetch = globalThis.fetch;
99

@@ -166,3 +166,112 @@ test('add: --overwrite replaces existing files without prompt', async () => {
166166
rmSync(d, { recursive: true });
167167
}
168168
});
169+
170+
/* -------------------- rewriteUtilsImport (unit tests) -------------------- */
171+
172+
test('rewriteUtilsImport: maps to lib/utils/cn alias for a Tier-1 file', () => {
173+
const cwd = '/app';
174+
const config = {
175+
resolvedPaths: {
176+
utils: '/app/lib/utils/cn.ts',
177+
},
178+
};
179+
const target = '/app/components/ui/button.ts';
180+
const content =
181+
`import { cn } from '../lib/utils.ts';\nexport const buttonClass = () => cn('p-2');`;
182+
const out = rewriteUtilsImport(content, target, config);
183+
assert.match(out, /from '\.\.\/\.\.\/lib\/utils\/cn\.ts'/);
184+
assert.doesNotMatch(out, /from '\.\.\/lib\/utils\.ts'/);
185+
});
186+
187+
test('rewriteUtilsImport: handles the legacy lib/utils alias (cn at lib/utils.ts)', () => {
188+
const config = { resolvedPaths: { utils: '/app/lib/utils.ts' } };
189+
const out = rewriteUtilsImport(
190+
`import { cn } from '../lib/utils.ts';`,
191+
'/app/components/ui/button.ts',
192+
config,
193+
);
194+
assert.match(out, /from '\.\.\/\.\.\/lib\/utils\.ts'/);
195+
});
196+
197+
test('rewriteUtilsImport: handles src/lib (vite default)', () => {
198+
const config = { resolvedPaths: { utils: '/app/src/lib/utils.ts' } };
199+
const out = rewriteUtilsImport(
200+
`import { cn } from '../lib/utils.ts';`,
201+
'/app/src/components/ui/button.ts',
202+
config,
203+
);
204+
assert.match(out, /from '\.\.\/\.\.\/lib\/utils\.ts'/);
205+
});
206+
207+
test('rewriteUtilsImport: handles double-quoted form', () => {
208+
const config = { resolvedPaths: { utils: '/app/lib/utils/cn.ts' } };
209+
const out = rewriteUtilsImport(
210+
`import { cn } from "../lib/utils.ts";`,
211+
'/app/components/ui/button.ts',
212+
config,
213+
);
214+
assert.match(out, /from "\.\.\/\.\.\/lib\/utils\/cn\.ts"/);
215+
});
216+
217+
test('rewriteUtilsImport: no-op when content has no utils import', () => {
218+
const config = { resolvedPaths: { utils: '/app/lib/utils/cn.ts' } };
219+
const out = rewriteUtilsImport(
220+
`export const x = 1;`,
221+
'/app/components/ui/x.ts',
222+
config,
223+
);
224+
assert.equal(out, 'export const x = 1;');
225+
});
226+
227+
test('rewriteUtilsImport: gracefully no-ops if config lacks resolvedPaths.utils', () => {
228+
const out = rewriteUtilsImport(
229+
`import { cn } from '../lib/utils.ts';`,
230+
'/app/components/ui/button.ts',
231+
{},
232+
);
233+
// Returns content unchanged so we never crash; the file may still be
234+
// broken, but that's a configuration error in components.json.
235+
assert.match(out, /from '\.\.\/lib\/utils\.ts'/);
236+
});
237+
238+
/* -------------------- integration: add rewrites the import -------------------- */
239+
240+
test('add: rewrites a registry component\'s ../lib/utils.ts import to the user\'s aliases.utils path', async () => {
241+
// Local stub of fetch that returns a button.ts whose body imports
242+
// the registry-relative '../lib/utils.ts'. After `add`, the written
243+
// file should reference the user's lib/utils/cn.ts instead.
244+
const origFetchLocal = globalThis.fetch;
245+
globalThis.fetch = async (url) => {
246+
const name = String(url).split('/').pop().replace('.json', '');
247+
if (name === 'button') {
248+
return new Response(JSON.stringify({
249+
name: 'button', type: 'registry:ui',
250+
files: [{
251+
path: 'components/button.ts',
252+
type: 'registry:ui',
253+
content: `import { cn } from '../lib/utils.ts';\nexport const buttonClass = () => cn('p-2');\n`,
254+
}],
255+
}), { status: 200 });
256+
}
257+
return new Response('not found', { status: 404 });
258+
};
259+
const d = mkdtempSync(join(tmpdir(), 'webjsui-add-rewrite-'));
260+
writeFileSync(join(d, 'components.json'), JSON.stringify({
261+
$schema: 'https://ui.webjs.dev/schema.json',
262+
style: 'default',
263+
tailwind: { css: 'app/globals.css', baseColor: 'neutral', cssVariables: true },
264+
aliases: { components: 'components', utils: 'lib/utils/cn', ui: 'components/ui', lib: 'lib' },
265+
}));
266+
try {
267+
// Unique --registry URL so the in-memory fetcher cache (keyed by URL)
268+
// doesn't return content from an earlier test that reused 'button'.
269+
await add.parseAsync(['button', '--yes', '--no-deps', '--cwd', d, '--registry', 'http://test/rewrite'], { from: 'user' });
270+
const body = readFileSync(join(d, 'components', 'ui', 'button.ts'), 'utf8');
271+
assert.match(body, /from '\.\.\/\.\.\/lib\/utils\/cn\.ts'/);
272+
assert.doesNotMatch(body, /from '\.\.\/lib\/utils\.ts'/);
273+
} finally {
274+
globalThis.fetch = origFetchLocal;
275+
rmSync(d, { recursive: true });
276+
}
277+
});

0 commit comments

Comments
 (0)