Skip to content

Commit 3bf3225

Browse files
committed
fix(builder): fix double-joined srcDir and components.d.ts orphan entries
ResolvedConfig.srcDir is always absolute (guaranteed by the config loader), but join(rootDir, srcDir) in ubeanVite and the core plugin appended it onto rootDir again. unplugin-vue-components and unplugin-auto-import then scanned a nonexistent dir, silently killing local component/composable auto-import in dev (and breaking the dev watcher paths). Use resolve() instead, which passes absolute srcDir through unchanged. generateComponentsDts now emits unplugin-vue-components-compatible inline `typeof import('...')` entries (same relative-path algorithm, `./` prefix, name-sorted) so the file survives unplugin's dev append merge, which preserves scraped interface entries but drops surrounding import lines. PageView is added to the builtin list to stay aligned with UBEAN_BUILTIN_COMPONENTS in vue-plugin.ts.
1 parent 07e4441 commit 3bf3225

4 files changed

Lines changed: 108 additions & 31 deletions

File tree

packages/builder/src/codegen/auto-imports.ts

Lines changed: 39 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ export async function generateAutoImports(
371371
}
372372
}
373373

374-
const componentsDts = generateComponentsDts(components);
374+
const componentsDts = generateComponentsDts(components, componentsDtsPath);
375375
await writeFile(componentsDtsPath, componentsDts, 'utf-8');
376376

377377
return {
@@ -382,42 +382,55 @@ export async function generateAutoImports(
382382
};
383383
}
384384

385-
function generateComponentsDts(components: ComponentInfo[]): string {
386-
const lines: string[] = [
387-
'// Auto-generated by ubean - do not edit manually',
388-
'/* eslint-disable */',
389-
'// @ts-nocheck',
390-
'',
391-
"declare module 'vue' {"
392-
];
385+
/**
386+
* Generate the `.ubean/components.d.ts` in a format compatible with
387+
* unplugin-vue-components (both writers own the same file).
388+
*
389+
* `ubean dev` runs codegen first, then unplugin-vue-components rewrites the
390+
* file in dev mode using its append merge: it keeps entries scraped from the
391+
* existing `GlobalComponents` interface but drops any surrounding
392+
* `import`/`const` declaration lines. Entries must therefore be
393+
* self-contained inline `typeof import('...')` declarations so they stay
394+
* valid after unplugin merges them.
395+
*/
396+
function generateComponentsDts(components: ComponentInfo[], dtsPath: string): string {
397+
const dtsDir = fileDirname(toPosixPath(normalize(dtsPath)));
393398

394-
const importLines: string[] = [];
395-
const componentEntries: string[] = [];
399+
const entries = new Map<string, string>();
396400

397-
const BUILTIN_COMPONENTS = ['Link', 'Head'];
401+
// Keep in sync with UBEAN_BUILTIN_COMPONENTS in ../vue-plugin.ts
402+
const BUILTIN_COMPONENTS = ['Link', 'Head', 'PageView'];
398403
for (const name of BUILTIN_COMPONENTS) {
399-
importLines.push(` const ${name}: typeof import('ubean/client')['${name}'];`);
400-
componentEntries.push(` ${name}: typeof ${name};`);
404+
entries.set(name, `typeof import('ubean/client')['${name}']`);
401405
}
402406

403407
for (const comp of components) {
404-
importLines.push(` import ${comp.pascalName} from ${JSON.stringify(comp.importPath)};`);
405-
componentEntries.push(` ${comp.pascalName}: typeof ${comp.pascalName};`);
408+
const rel = toPosixPath(relative(dtsDir, toPosixPath(normalize(comp.filePath))));
409+
// Always prefix `./` like unplugin-vue-components does, so both writers
410+
// emit byte-identical entries and neither triggers a redundant rewrite.
411+
const importPath = `./${rel}`;
412+
entries.set(comp.pascalName, `typeof import('${importPath}')['default']`);
406413
}
407414

408-
if (importLines.length > 0) {
409-
lines.push(...importLines);
410-
lines.push('');
411-
lines.push(' export interface GlobalComponents {');
412-
lines.push(...componentEntries);
413-
lines.push(' }');
414-
} else {
415-
lines.push(' export interface GlobalComponents {}');
416-
}
415+
const lines: string[] = [
416+
'// Auto-generated by ubean - do not edit manually',
417+
'/* eslint-disable */',
418+
'// @ts-nocheck',
419+
'// biome-ignore lint: disable',
420+
'// oxlint-disable',
421+
'',
422+
'export {}',
423+
'',
424+
'/* prettier-ignore */',
425+
"declare module 'vue' {",
426+
' export interface GlobalComponents {'
427+
];
417428

429+
const sorted = [...entries.entries()].sort(([a], [b]) => a.localeCompare(b));
430+
lines.push(...sorted.map(([name, declaration]) => ` ${name}: ${declaration}`));
431+
lines.push(' }');
418432
lines.push('}');
419433
lines.push('');
420-
lines.push('export {}');
421434

422435
return `${lines.join('\n')}\n`;
423436
}

packages/builder/src/vite.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,8 @@ export function ubeanPlugin(options?: UbeanPluginOptions): Plugin {
121121

122122
// config 在 buildStart 中已加载,此处一定可用
123123
const config = ubeanConfig!;
124-
const srcDir = join(config.rootDir, config.srcDir);
124+
// ResolvedConfig.srcDir 已是绝对路径,resolve 避免二次拼接(join 会把绝对路径追加到 rootDir 后)
125+
const srcDir = resolve(config.rootDir, config.srcDir);
125126

126127
for (const dir of watchDirs) {
127128
server.watcher.add(join(srcDir, dir));

packages/builder/src/vue-plugin.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getVueLocaleParam } from '@ubean/i18n';
1111
import { ubeanMdxPlugin } from '@ubean/markdown';
1212
import { renderFaviconLink } from '@ubean/pages';
1313
import { scanProject } from '@ubean/scan';
14-
import { join } from 'pathe';
14+
import { join, resolve } from 'pathe';
1515
import type { InlinePreset } from 'unimport';
1616
import { UBEAN_CLIENT_PRESET, UBEAN_SERVER_PRESET } from './codegen';
1717
import { getComponentResolvers } from './registry';
@@ -83,7 +83,8 @@ function localeVueParamFromConfig(config: UbeanResolvedConfig): string | undefin
8383
export function ubeanVite(options: UbeanViteOptions): Plugin[] {
8484
const { config: ubeanConfig } = options;
8585
const virtualRegistry = useVirtualRegistry();
86-
const srcDir = join(ubeanConfig.rootDir, ubeanConfig.srcDir);
86+
// ResolvedConfig.srcDir 已是绝对路径(loader 保证),resolve 不会像 join 那样二次拼接
87+
const srcDir = resolve(ubeanConfig.rootDir, ubeanConfig.srcDir);
8788
const dtsDir = join(ubeanConfig.rootDir, '.ubean');
8889
const markdownEnabled = ubeanConfig.markdown?.enabled !== false;
8990
const mdxEnabled = ubeanConfig.markdown?.mdx === true;
@@ -422,7 +423,7 @@ async function runPagefindIndexing(
422423
searchConfig: NonNullable<UbeanResolvedConfig['search']>
423424
): Promise<void> {
424425
const { spawn } = await import('node:child_process');
425-
const { resolve } = await import('node:path');
426+
const { resolve: resolvePath } = await import('node:path');
426427

427428
const isObjectConfig = typeof searchConfig === 'object';
428429
const enabled = isObjectConfig ? searchConfig.enabled !== false : true;
@@ -436,7 +437,7 @@ async function runPagefindIndexing(
436437

437438
const verbose = isObjectConfig && searchConfig.verbose === true;
438439

439-
const sitePath = resolve(ubeanConfig.rootDir, outDir);
440+
const sitePath = resolvePath(ubeanConfig.rootDir, outDir);
440441

441442
const args = ['pagefind', '--site', sitePath, '--output-subdir', indexPath];
442443

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { describe, expect, it } from 'vitest';
4+
import type { ScanResult } from '@ubean/scan';
5+
import { dirname, join, resolve } from 'pathe';
6+
import { generateAutoImports } from '../src/codegen/auto-imports';
7+
8+
function emptyScan(): ScanResult {
9+
return {
10+
apiRoutes: [],
11+
pages: [],
12+
layouts: [],
13+
middlewares: [],
14+
plugins: [],
15+
crons: [],
16+
queues: [],
17+
locales: [],
18+
appEntry: { shared: { exists: false }, server: { exists: false }, client: { exists: false } },
19+
serverEntry: { shared: { exists: false }, dev: { exists: false }, prod: { exists: false } }
20+
};
21+
}
22+
23+
describe('generateAutoImports components.d.ts format', () => {
24+
it('emits self-contained inline import entries (unplugin merge-safe)', async () => {
25+
const cwd = await mkdtemp(join(tmpdir(), 'ubean-codegen-dts-'));
26+
const componentsDir = join(cwd, 'src/components/islands');
27+
await mkdir(componentsDir, { recursive: true });
28+
await writeFile(join(componentsDir, 'island-clock.vue'), '<template><span /></template>');
29+
30+
// ResolvedConfig.srcDir is always absolute (guaranteed by the config loader)
31+
const result = await generateAutoImports(emptyScan(), {
32+
cwd,
33+
srcDir: resolve(cwd, 'src'),
34+
buildDir: '.ubean'
35+
});
36+
37+
const raw = await readFile(result.componentsDtsPath, 'utf8');
38+
const entryLines = raw.split('\n').filter(line => /^ {4}\w+: /.test(line));
39+
expect(entryLines.length).toBeGreaterThan(0);
40+
41+
for (const line of entryLines) {
42+
// Every entry must carry its own import path. Bare `typeof X` entries lose
43+
// their meaning when unplugin-vue-components merges this file in dev mode
44+
// (it preserves interface entries but drops surrounding import statements).
45+
expect(line, `entry is not self-contained: ${line}`).toMatch(/^ {4}\w+: typeof import\('/);
46+
expect(line).not.toMatch(/: typeof \w+$/);
47+
}
48+
49+
// Builtins stay aligned with UBEAN_BUILTIN_COMPONENTS in ../src/vue-plugin.ts
50+
for (const name of ['Link', 'Head', 'PageView']) {
51+
expect(raw).toContain(`${name}: typeof import('ubean/client')['${name}']`);
52+
}
53+
54+
// The scanned entry points at the real file, relative to the d.ts directory
55+
const clockLine = entryLines.find(line => line.startsWith(' IslandClock:'));
56+
expect(clockLine).toBeDefined();
57+
expect(clockLine).toContain("./../src/components/islands/island-clock.vue')['default']");
58+
const match = clockLine!.match(/typeof import\('([^']+)'\)/);
59+
expect(match).toBeTruthy();
60+
expect(resolve(dirname(result.componentsDtsPath), match![1])).toBe(join(componentsDir, 'island-clock.vue'));
61+
});
62+
});

0 commit comments

Comments
 (0)