Skip to content

Commit 53b956d

Browse files
committed
feat: symlink all source directories in dev mode
1 parent 9aec722 commit 53b956d

4 files changed

Lines changed: 98 additions & 42 deletions

File tree

src/Package.ts

Lines changed: 82 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export async function build(options: build.Options): Promise<build.ReturnType> {
3939
}
4040

4141
const sourceDir = getSourceDir({ cwd, sources })
42+
43+
if (link) await linkSourceFiles({ cwd, outDir, sourceDir, sources })
4244
const packageJson = await decoratePackageJson(pkgJson, { cwd, link, outDir, sourceDir, assets })
4345

4446
await writePackageJson(cwd, packageJson)
@@ -262,23 +264,6 @@ export async function decoratePackageJson(
262264
const exports = Object.fromEntries(
263265
exps
264266
? Object.entries(exps).map(([key, value]) => {
265-
function linkExports(entry: string) {
266-
try {
267-
const destJsAbsolute = path.resolve(cwd, outFile(entry, '.js'))
268-
const destDtsAbsolute = path.resolve(cwd, outFile(entry, '.d.ts'))
269-
const dir = path.dirname(destJsAbsolute)
270-
271-
if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true })
272-
273-
const srcAbsolute = path.resolve(cwd, entry)
274-
const srcRelativeJs = path.relative(path.dirname(destJsAbsolute), srcAbsolute)
275-
const srcRelativeDts = path.relative(path.dirname(destDtsAbsolute), srcAbsolute)
276-
277-
fsSync.symlinkSync(srcRelativeJs, destJsAbsolute, 'file')
278-
fsSync.symlinkSync(srcRelativeDts, destDtsAbsolute, 'file')
279-
} catch {}
280-
}
281-
282267
// Transform single `package.json#exports` entrypoints. They
283268
// must point to the source file. Otherwise, an error is thrown.
284269
//
@@ -297,7 +282,6 @@ export async function decoratePackageJson(
297282
if (assets.includes(absolutePath)) return [key, outAsset(absolutePath)]
298283
return [key, value]
299284
}
300-
if (link) linkExports(value)
301285
return [
302286
key,
303287
{
@@ -335,7 +319,6 @@ export async function decoratePackageJson(
335319
}
336320
return [key, value]
337321
}
338-
if (link) linkExports(value.src)
339322
return [
340323
key,
341324
{
@@ -383,6 +366,68 @@ export declare namespace decoratePackageJson {
383366
}
384367
}
385368

369+
/**
370+
* Links source files to output directory for development mode.
371+
*
372+
* @param options - Options for linking source files.
373+
*/
374+
// biome-ignore lint/correctness/noUnusedVariables: _
375+
async function linkSourceFiles(options: linkSourceFiles.Options): Promise<void> {
376+
const { cwd, outDir, sourceDir, sources } = options
377+
378+
const relativeSourceDir = path.relative(cwd, sourceDir)
379+
const sourceFiles: string[] = []
380+
381+
async function collectFiles(dir: string): Promise<void> {
382+
const entries = await fs.readdir(dir, { withFileTypes: true })
383+
for (const entry of entries) {
384+
const fullPath = path.join(dir, entry.name)
385+
if (entry.isDirectory()) await collectFiles(fullPath)
386+
else if (/\.(m|c)?[jt]sx?$/.test(entry.name)) sourceFiles.push(fullPath)
387+
}
388+
}
389+
390+
// Collect source files from sourceDir if it exists and is a directory
391+
if (sourceDir !== cwd && fsSync.existsSync(sourceDir) && fsSync.statSync(sourceDir).isDirectory())
392+
await collectFiles(sourceDir)
393+
394+
// Also add any root-level sources (e.g., ./index.ts)
395+
for (const source of sources) if (!sourceFiles.includes(source)) sourceFiles.push(source)
396+
397+
// Create symlinks for each source file
398+
for (const sourceFile of sourceFiles) {
399+
let relativePath = path.relative(cwd, sourceFile)
400+
// Strip the sourceDir prefix if applicable
401+
if (relativeSourceDir && relativePath.startsWith(relativeSourceDir + path.sep))
402+
relativePath = relativePath.slice(relativeSourceDir.length + 1)
403+
404+
const destJs = path.resolve(outDir, relativePath.replace(/\.(m|c)?[jt]sx?$/, '.js'))
405+
const destDts = path.resolve(outDir, relativePath.replace(/\.(m|c)?[jt]sx?$/, '.d.ts'))
406+
407+
const dir = path.dirname(destJs)
408+
if (!fsSync.existsSync(dir)) await fs.mkdir(dir, { recursive: true })
409+
410+
const srcRelativeJs = path.relative(path.dirname(destJs), sourceFile)
411+
const srcRelativeDts = path.relative(path.dirname(destDts), sourceFile)
412+
413+
try {
414+
fsSync.symlinkSync(srcRelativeJs, destJs, 'file')
415+
} catch {}
416+
try {
417+
fsSync.symlinkSync(srcRelativeDts, destDts, 'file')
418+
} catch {}
419+
}
420+
}
421+
422+
declare namespace linkSourceFiles {
423+
type Options = {
424+
cwd: string
425+
outDir: string
426+
sourceDir: string
427+
sources: string[]
428+
}
429+
}
430+
386431
/**
387432
* Gets entry files from package.json exports field or main field.
388433
*
@@ -455,28 +500,30 @@ export declare namespace getEntries {
455500
export function getSourceDir(options: getSourceDir.Options): string {
456501
const { cwd = process.cwd(), sources } = options
457502

458-
if (sources.length === 0) return path.resolve(cwd, 'src')
459-
460-
// Get directories of all entries
461-
const dirs = sources.map((source) => path.dirname(source))
503+
if (sources.length === 0) return cwd
462504

463-
// Split each directory into segments
464-
const segments = dirs.map((dir) => dir.split(path.sep))
505+
// Filter to only sources in subdirectories (not root-level files)
506+
const subdirSources = sources.filter((source) => {
507+
const rel = path.relative(cwd, path.dirname(source))
508+
return rel !== '' && !rel.startsWith('..')
509+
})
465510

466-
// Find common segments
467-
const commonSegments: string[] = []
468-
const minLength = Math.min(...segments.map((s) => s.length))
511+
// If no subdirectory sources, return cwd (no prefix to strip)
512+
if (subdirSources.length === 0) return cwd
469513

470-
for (let i = 0; i < minLength; i++) {
514+
// Get first directory segment for each subdirectory source
515+
const firstSegments = subdirSources.map((source) => {
516+
const rel = path.relative(cwd, source)
471517
// biome-ignore lint/style/noNonNullAssertion: _
472-
const segment = segments[0]![i]!
473-
if (segments.every((s) => s[i] === segment)) commonSegments.push(segment)
474-
else break
475-
}
518+
return rel.split(path.sep)[0]!
519+
})
476520

477-
const commonPath = commonSegments.join(path.sep)
521+
// Find the common first segment
478522
// biome-ignore lint/style/noNonNullAssertion: _
479-
return path.resolve(cwd, path.relative(cwd, commonPath).split(path.sep)[0]!)
523+
const firstSegment = firstSegments[0]!
524+
if (firstSegments.every((s) => s === firstSegment)) return path.resolve(cwd, firstSegment)
525+
526+
return cwd
480527
}
481528

482529
export declare namespace getSourceDir {

src/__snapshots__/Cli.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ exports[`'/basic': Cli.run > link > output 1`] = `
122122
123123
124124
├── dist
125+
│ ├── cli.d.ts -> ../cli.ts
126+
│ ├── cli.js -> ../cli.ts
125127
│ ├── foo.d.ts -> ../foo.ts
126128
│ ├── foo.js -> ../foo.ts
127129
│ ├── index.d.ts -> ../index.ts

src/__snapshots__/Package.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,8 @@ exports[`'/basic': Package.build > link > output 1`] = `
229229
230230
231231
├── dist
232+
│ ├── cli.d.ts -> ../cli.ts
233+
│ ├── cli.js -> ../cli.ts
232234
│ ├── foo.d.ts -> ../foo.ts
233235
│ ├── foo.js -> ../foo.ts
234236
│ ├── index.d.ts -> ../index.ts

src/internal/cli/commands.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ export async function createNew(command: Command) {
115115
// Auto-detect from how the command was invoked
116116
const userAgent = process.env.npm_config_user_agent || ''
117117
const execPath = process.env.npm_execpath || ''
118-
118+
119119
if (userAgent.includes('pnpm') || execPath.includes('pnpm')) packageManager = 'pnpm'
120120
else if (userAgent.includes('bun') || execPath.includes('bun')) packageManager = 'bun'
121121
else if (userAgent.includes('yarn') || execPath.includes('yarn')) packageManager = 'yarn'
@@ -177,15 +177,20 @@ export async function createNew(command: Command) {
177177
const entries = fs.readdirSync(dir, { withFileTypes: true })
178178

179179
// Determine pmx (package executor) based on package manager
180-
const pmx = packageManager === 'npm' ? 'npx'
181-
: packageManager === 'pnpm' ? 'pnpx'
182-
: packageManager === 'bun' ? 'bunx'
183-
: packageManager === 'yarn' ? 'yarn dlx'
184-
: 'npx'
180+
const pmx =
181+
packageManager === 'npm'
182+
? 'npx'
183+
: packageManager === 'pnpm'
184+
? 'pnpx'
185+
: packageManager === 'bun'
186+
? 'bunx'
187+
: packageManager === 'yarn'
188+
? 'yarn dlx'
189+
: 'npx'
185190

186191
// Determine setup step based on package manager
187192
let setupStep = ''
188-
if (packageManager === 'pnpm')
193+
if (packageManager === 'pnpm')
189194
setupStep = `\n - name: Set up pnpm\n uses: pnpm/action-setup@v4\n`
190195
else if (packageManager === 'bun')
191196
setupStep = `\n - name: Set up Bun\n uses: oven-sh/setup-bun@v2\n`

0 commit comments

Comments
 (0)