zile 0.0.0 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Package.ts ADDED
@@ -0,0 +1,609 @@
1
+ import * as cp from 'node:child_process'
2
+ import * as fsSync from 'node:fs'
3
+ import * as fs from 'node:fs/promises'
4
+ import * as path from 'node:path'
5
+ import * as Tsconfig from 'tsconfck'
6
+ import type { PackageJson, TsConfigJson } from 'type-fest'
7
+
8
+ export type { PackageJson, TsConfigJson }
9
+
10
+ /**
11
+ * Builds a package.
12
+ *
13
+ * @param options - Options for building a package
14
+ * @returns Build artifacts.
15
+ */
16
+ export async function build(options: build.Options): Promise<build.ReturnType> {
17
+ const { cwd = process.cwd(), link = false, project = './tsconfig.json', tsgo } = options
18
+
19
+ let [pkgJson, tsConfig] = await Promise.all([
20
+ readPackageJson({ cwd }),
21
+ readTsconfigJson({ cwd, project }),
22
+ ])
23
+ const outDir = tsConfig.compilerOptions?.outDir ?? path.resolve(cwd, 'dist')
24
+
25
+ await checkInput({ cwd, outDir })
26
+
27
+ const entries = getEntries({ cwd, pkgJson })
28
+
29
+ if (!link) {
30
+ const result = await transpile({ cwd, entries, tsgo })
31
+ tsConfig = result.tsConfig
32
+ }
33
+
34
+ if (link) {
35
+ await fs.rm(outDir, { recursive: true }).catch(() => {})
36
+ await fs.mkdir(outDir, { recursive: true })
37
+ }
38
+
39
+ const sourceDir = getSourceDir({ cwd, entries })
40
+ const packageJson = await decoratePackageJson(pkgJson, { cwd, link, outDir, sourceDir })
41
+
42
+ await writePackageJson(cwd, packageJson)
43
+
44
+ return { packageJson, tsConfig }
45
+ }
46
+
47
+ export declare namespace build {
48
+ type Options = {
49
+ /** Working directory to start searching from. @default process.cwd() */
50
+ cwd?: string | undefined
51
+ /** Whether to link output files to source files for development. @default false */
52
+ link?: boolean | undefined
53
+ /** Path to tsconfig.json file, relative to the working directory. @default './tsconfig.json' */
54
+ project?: string | undefined
55
+ /** Whether to use tsgo for transpilation. @default false */
56
+ tsgo?: boolean | undefined
57
+ }
58
+
59
+ type ReturnType = {
60
+ /** Transformed package.json file. */
61
+ packageJson: PackageJson
62
+ /** tsconfig.json used for transpilation. */
63
+ tsConfig: TsConfigJson
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Checks the inputs of the package.
69
+ *
70
+ * @param options - Options for checking the input.
71
+ * @returns Input check results.
72
+ */
73
+ export async function checkInput(options: checkInput.Options): Promise<checkInput.ReturnType> {
74
+ return await checkPackageJson(options)
75
+ }
76
+
77
+ export declare namespace checkInput {
78
+ type Options = {
79
+ /** Working directory to check. @default process.cwd() */
80
+ cwd?: string | undefined
81
+ /** Output directory. @default path.resolve(cwd, 'dist') */
82
+ outDir?: string | undefined
83
+ }
84
+
85
+ type ReturnType = undefined
86
+ }
87
+
88
+ /**
89
+ * Determines if the package.json file is valid for transpiling.
90
+ *
91
+ * @param options - Options for checking the package.json file.
92
+ * @returns Whether the package.json file is valid for transpiling.
93
+ */
94
+ export async function checkPackageJson(
95
+ options: checkPackageJson.Options,
96
+ ): Promise<checkPackageJson.ReturnType> {
97
+ const { cwd = process.cwd(), outDir = path.resolve(cwd, 'dist') } = options
98
+ const pkgJson = await readPackageJson({ cwd })
99
+
100
+ function exists(value: string) {
101
+ if (value.includes(path.relative(cwd, outDir))) return true
102
+ return fsSync.existsSync(path.resolve(cwd, value))
103
+ }
104
+
105
+ if (!pkgJson.exports && !pkgJson.main && !pkgJson.bin)
106
+ throw new Error('package.json must have an `exports`, `main`, or `bin` field')
107
+
108
+ if (pkgJson.bin)
109
+ if (typeof pkgJson.bin === 'string') {
110
+ if (!exists(pkgJson.bin))
111
+ throw new Error(`\`${pkgJson.bin}\` does not exist on \`package.json#bin\``)
112
+ } else {
113
+ for (const [key, value] of Object.entries(pkgJson.bin)) {
114
+ if (!value) throw new Error(`\`bin.${key}\` value must be a string`)
115
+ if (typeof value === 'string' && !exists(value))
116
+ throw new Error(`\`${value}\` does not exist on \`package.json#bin.${key}\``)
117
+ }
118
+ }
119
+
120
+ if (pkgJson.main)
121
+ if (!exists(pkgJson.main))
122
+ throw new Error(`\`${pkgJson.main}\` does not exist on \`package.json#main\``)
123
+
124
+ if (pkgJson.exports) {
125
+ for (const [key, entry] of Object.entries(pkgJson.exports)) {
126
+ if (typeof entry === 'string' && !exists(entry))
127
+ throw new Error(`\`${entry}\` does not exist on \`package.json#exports["${key}"]\``)
128
+ if (
129
+ typeof entry === 'object' &&
130
+ entry &&
131
+ 'src' in entry &&
132
+ typeof entry.src === 'string' &&
133
+ !exists(entry.src)
134
+ )
135
+ throw new Error(`\`${entry.src}\` does not exist on \`package.json#exports["${key}"].src\``)
136
+ }
137
+ }
138
+
139
+ return undefined
140
+ }
141
+
142
+ export declare namespace checkPackageJson {
143
+ type Options = {
144
+ /** Working directory to check. @default process.cwd() */
145
+ cwd?: string | undefined
146
+ /** Output directory. @default path.resolve(cwd, 'dist') */
147
+ outDir?: string | undefined
148
+ }
149
+
150
+ type ReturnType = undefined
151
+ }
152
+
153
+ /**
154
+ * Decorates the package.json file to include publish-specific fields.
155
+ *
156
+ * @param pkgJson - Package.json file to transform.
157
+ * @param options - Options.
158
+ * @returns Transformed package.json file as an object.
159
+ */
160
+ export async function decoratePackageJson(
161
+ pkgJson: PackageJson,
162
+ options: decoratePackageJson.Options,
163
+ ) {
164
+ const { cwd, link, outDir, sourceDir } = options
165
+
166
+ const relativeOutDir = `./${path.relative(cwd, outDir)}`
167
+ const relativeSourceDir = `./${path.relative(cwd, sourceDir)}`
168
+
169
+ const outFile = (name: string, ext: string = '') =>
170
+ './' +
171
+ path.join(
172
+ relativeOutDir,
173
+ name.replace(relativeSourceDir, '').replace(path.extname(name), '') + ext,
174
+ )
175
+
176
+ let bin = pkgJson.bin
177
+ if (bin) {
178
+ if (typeof bin === 'string') {
179
+ if (!bin.startsWith(relativeOutDir))
180
+ bin = {
181
+ // biome-ignore lint/style/noNonNullAssertion: _
182
+ [pkgJson.name!]: outFile(bin, '.js'),
183
+ // biome-ignore lint/style/useTemplate: _
184
+ // biome-ignore lint/style/noNonNullAssertion: _
185
+ [pkgJson.name! + '.src']: bin,
186
+ }
187
+ } else {
188
+ bin = Object.fromEntries(
189
+ Object.entries(bin).flatMap((entry) => {
190
+ const [key, value] = entry
191
+ if (!value) throw new Error(`\`bin.${key}\` field must have a value`)
192
+ return [
193
+ [key.replace('.src', ''), outFile(value, '.js')],
194
+ [key, value],
195
+ ]
196
+ }),
197
+ )
198
+ }
199
+ }
200
+
201
+ let exps = pkgJson.exports
202
+
203
+ // Support single entrypoint via `main` field
204
+ if (pkgJson.main) {
205
+ // Transform single `package.json#main` field. They
206
+ // must point to the source file. Otherwise, an error is thrown.
207
+ //
208
+ // main: "./src/index.ts"
209
+ // ↓ ↓ ↓
210
+ // main: "./src/index.ts"
211
+ // exports: {
212
+ // ".": {
213
+ // "src": "./src/index.ts",
214
+ // "types": "./dist/index.d.ts",
215
+ // "default": "./dist/index.js",
216
+ // },
217
+ // }
218
+ exps = {
219
+ '.': pkgJson.main,
220
+ ...(typeof exps === 'object' ? exps : {}),
221
+ }
222
+ }
223
+
224
+ type Exports = {
225
+ [key: string]: {
226
+ src: string
227
+ types: string
228
+ default: string
229
+ }
230
+ }
231
+ const exports = Object.fromEntries(
232
+ exps
233
+ ? Object.entries(exps).map(([key, value]) => {
234
+ function linkExports(entry: string) {
235
+ try {
236
+ const destJsAbsolute = path.resolve(cwd, outFile(entry, '.js'))
237
+ const destDtsAbsolute = path.resolve(cwd, outFile(entry, '.d.ts'))
238
+ const dir = path.dirname(destJsAbsolute)
239
+
240
+ if (!fsSync.existsSync(dir)) fsSync.mkdirSync(dir, { recursive: true })
241
+
242
+ const srcAbsolute = path.resolve(cwd, entry)
243
+ const srcRelativeJs = path.relative(path.dirname(destJsAbsolute), srcAbsolute)
244
+ const srcRelativeDts = path.relative(path.dirname(destDtsAbsolute), srcAbsolute)
245
+
246
+ fsSync.symlinkSync(srcRelativeJs, destJsAbsolute, 'file')
247
+ fsSync.symlinkSync(srcRelativeDts, destDtsAbsolute, 'file')
248
+ } catch {}
249
+ }
250
+
251
+ // Transform single `package.json#exports` entrypoints. They
252
+ // must point to the source file. Otherwise, an error is thrown.
253
+ //
254
+ // "./utils": "./src/utils.ts"
255
+ // ↓ ↓ ↓
256
+ // "./utils": {
257
+ // "src": "./src/utils.ts",
258
+ // "types": "./dist/utils.js",
259
+ // "default": "./dist/utils.d.ts"
260
+ // }
261
+ if (typeof value === 'string') {
262
+ if (value.startsWith(relativeOutDir)) return [key, value]
263
+ if (!/\.(m|c)?[jt]sx?$/.test(value)) return [key, value]
264
+ if (link) linkExports(value)
265
+ return [
266
+ key,
267
+ {
268
+ src: value,
269
+ types: outFile(value, '.d.ts'),
270
+ default: outFile(value, '.js'),
271
+ },
272
+ ]
273
+ }
274
+
275
+ // Transform object-like `package.json#exports` entrypoints. They
276
+ // must include a `src` field pointing to the source file, otherwise
277
+ // an error is thrown.
278
+ //
279
+ // "./utils": "./src/utils.ts"
280
+ // ↓ ↓ ↓
281
+ // "./utils": {
282
+ // "src": "./src/utils.ts",
283
+ // "types": "./dist/utils.js",
284
+ // "default": "./dist/utils.d.ts"
285
+ // }
286
+ if (
287
+ typeof value === 'object' &&
288
+ value &&
289
+ 'src' in value &&
290
+ typeof value.src === 'string'
291
+ ) {
292
+ if (value.src.startsWith(relativeOutDir)) return [key, value]
293
+ if (!/\.(m|c)?[jt]sx?$/.test(value.src)) return [key, value]
294
+ if (link) linkExports(value.src)
295
+ return [
296
+ key,
297
+ {
298
+ ...value,
299
+ types: outFile(value.src, '.d.ts'),
300
+ default: outFile(value.src, '.js'),
301
+ },
302
+ ]
303
+ }
304
+ throw new Error('`exports` field in package.json must be an object with a `src` field')
305
+ })
306
+ : [],
307
+ ) as Exports
308
+
309
+ const root = exports['.']
310
+
311
+ return {
312
+ ...pkgJson,
313
+ type: pkgJson.type ?? 'module',
314
+ sideEffects: pkgJson.sideEffects ?? false,
315
+ ...(bin ? { bin } : {}),
316
+ ...(root
317
+ ? {
318
+ main: root.default,
319
+ module: root.default,
320
+ types: root.types,
321
+ }
322
+ : {}),
323
+ exports,
324
+ } as PackageJson
325
+ }
326
+
327
+ export declare namespace decoratePackageJson {
328
+ type Options = {
329
+ /** Working directory. */
330
+ cwd: string
331
+ /** Whether to link output files to source files for development. */
332
+ link: boolean
333
+ /** Output directory. */
334
+ outDir: string
335
+ /** Source directory. */
336
+ sourceDir: string
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Gets entry files from package.json exports field or main field.
342
+ *
343
+ * @param options - Options for getting entry files.
344
+ * @returns Array of absolute paths to entry files.
345
+ */
346
+ export function getEntries(options: getEntries.Options): string[] {
347
+ const { cwd, pkgJson } = options
348
+
349
+ const entries: string[] = []
350
+
351
+ if (pkgJson.bin) {
352
+ if (typeof pkgJson.bin === 'string') entries.push(path.resolve(cwd, pkgJson.bin))
353
+ else
354
+ entries.push(
355
+ ...(Object.entries(pkgJson.bin)
356
+ .map(([key, value]) =>
357
+ // biome-ignore lint/style/noNonNullAssertion: _
358
+ key.endsWith('.src') ? path.resolve(cwd, value!) : undefined,
359
+ )
360
+ .filter(Boolean) as string[]),
361
+ )
362
+ }
363
+
364
+ if (pkgJson.exports)
365
+ entries.push(
366
+ ...Object.values(pkgJson.exports)
367
+ .map((entry) => {
368
+ if (typeof entry === 'string') return entry
369
+ if (typeof entry === 'object' && entry && 'src' in entry && typeof entry.src === 'string')
370
+ return entry.src
371
+ throw new Error('`exports` field in package.json must have a `src` field')
372
+ })
373
+ .map((entry) => path.resolve(cwd, entry))
374
+ .filter((entry) => /\.(m|c)?[jt]sx?$/.test(entry)),
375
+ )
376
+ else if (pkgJson.main) entries.push(path.resolve(cwd, pkgJson.main))
377
+
378
+ return entries
379
+ }
380
+
381
+ export declare namespace getEntries {
382
+ type Options = {
383
+ /** Working directory. */
384
+ cwd: string
385
+ /** Package.json file. */
386
+ pkgJson: PackageJson
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Gets the source directory from the entry files.
392
+ *
393
+ * @param options - Options for getting the source directory.
394
+ * @returns Source directory.
395
+ */
396
+ export function getSourceDir(options: getSourceDir.Options): string {
397
+ const { cwd = process.cwd(), entries } = options
398
+
399
+ if (entries.length === 0) return path.resolve(cwd, 'src')
400
+
401
+ // Get directories of all entries
402
+ const dirs = entries.map((entry) => path.dirname(entry))
403
+
404
+ // Split each directory into segments
405
+ const segments = dirs.map((dir) => dir.split(path.sep))
406
+
407
+ // Find common segments
408
+ const commonSegments: string[] = []
409
+ const minLength = Math.min(...segments.map((s) => s.length))
410
+
411
+ for (let i = 0; i < minLength; i++) {
412
+ const segment = segments[0][i]
413
+ if (segments.every((s) => s[i] === segment)) commonSegments.push(segment)
414
+ else break
415
+ }
416
+
417
+ const commonPath = commonSegments.join(path.sep)
418
+ return path.resolve(cwd, path.relative(cwd, commonPath).split(path.sep)[0])
419
+ }
420
+
421
+ export declare namespace getSourceDir {
422
+ type Options = {
423
+ /** Working directory. */
424
+ cwd?: string | undefined
425
+ /** Entry files. */
426
+ entries: string[]
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Reads the package.json file from the given working directory.
432
+ *
433
+ * @param cwd - Working directory to read the package.json file from.
434
+ * @returns Parsed package.json file as an object.
435
+ */
436
+ const packageJsonCache: Map<string, PackageJson> = new Map()
437
+ export async function readPackageJson(options: readPackageJson.Options) {
438
+ const { cwd } = options
439
+
440
+ if (packageJsonCache.has(cwd)) return packageJsonCache.get(cwd) as PackageJson
441
+ const packageJson = await fs.readFile(path.resolve(cwd, 'package.json'), 'utf-8').then(JSON.parse)
442
+ packageJsonCache.set(cwd, packageJson)
443
+ return packageJson
444
+ }
445
+
446
+ export declare namespace readPackageJson {
447
+ type Options = {
448
+ /** Working directory. */
449
+ cwd: string
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Reads the tsconfig.json file from the given working directory.
455
+ *
456
+ * @param cwd - Working directory to read the tsconfig.json file from.
457
+ * @param project - Path to tsconfig.json file, relative to the working directory. @default './tsconfig.json'
458
+ * @returns Parsed tsconfig.json file as an object.
459
+ */
460
+ const tsconfigJsonCache: Map<string, TsConfigJson> = new Map()
461
+ export async function readTsconfigJson(options: readTsconfigJson.Options): Promise<TsConfigJson> {
462
+ const { cwd, project = './tsconfig.json' } = options
463
+ if (tsconfigJsonCache.has(cwd)) return tsconfigJsonCache.get(cwd) as TsConfigJson
464
+ const result = await Tsconfig.parse(path.resolve(cwd, project))
465
+ tsconfigJsonCache.set(cwd, result.tsconfig)
466
+ return result.tsconfig
467
+ }
468
+
469
+ export declare namespace readTsconfigJson {
470
+ type Options = {
471
+ /** Working directory. */
472
+ cwd: string
473
+ /** Path to tsconfig.json file, relative to the working directory. @default './tsconfig.json' */
474
+ project?: string | undefined
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Transpiles a package.
480
+ *
481
+ * @param options - Options for transpiling a package.
482
+ * @returns Transpilation artifacts.
483
+ */
484
+ export async function transpile(options: transpile.Options): Promise<transpile.ReturnType> {
485
+ const { cwd = process.cwd(), entries, project = './tsconfig.json', tsgo } = options
486
+
487
+ const tsConfigJson = await readTsconfigJson({ cwd, project })
488
+ const tsconfigPath = path.resolve(cwd, project)
489
+ const { module: mod, moduleResolution: modRes } = tsConfigJson.compilerOptions ?? {}
490
+
491
+ // TODO: CLI `zile check --fix` command to add these and rewrite extensions in project (if needed).
492
+ // TODO: extract to Package.checkTsconfig()
493
+ const isNodeNext = (val?: string) => val === 'nodenext' || val === 'NodeNext'
494
+ const errors = []
495
+ if (!isNodeNext(mod))
496
+ errors.push(` - "module" must be "nodenext". Found: ${mod ? `"${mod}"` : 'undefined'}`)
497
+ if (!isNodeNext(modRes))
498
+ errors.push(
499
+ ` - "moduleResolution" must be "nodenext". Found: ${modRes ? `"${modRes}"` : 'undefined'}`,
500
+ )
501
+ if (errors.length > 0)
502
+ throw new Error(`${tsconfigPath} has invalid configuration:\n${errors.join('\n')}`)
503
+
504
+ const compilerOptions = {
505
+ ...tsConfigJson.compilerOptions,
506
+ composite: false,
507
+ declaration: true,
508
+ declarationDir: tsConfigJson.compilerOptions?.declarationDir,
509
+ declarationMap: true,
510
+ emitDeclarationOnly: false,
511
+ esModuleInterop: true,
512
+ noEmit: false,
513
+ outDir: tsConfigJson.compilerOptions?.outDir ?? path.resolve(cwd, 'dist'),
514
+ skipLibCheck: true,
515
+ sourceMap: true,
516
+ target: tsConfigJson.compilerOptions?.target ?? 'es2021',
517
+ } as const satisfies TsConfigJson['compilerOptions']
518
+
519
+ const tsConfig = {
520
+ compilerOptions,
521
+ exclude: tsConfigJson.exclude ?? [],
522
+ include: [...(tsConfigJson.include ?? []), ...entries] as string[],
523
+ } as const
524
+
525
+ const tmpProject = path.resolve(cwd, 'tsconfig.tmp.json')
526
+ await fs.writeFile(tmpProject, JSON.stringify(tsConfig, null, 2))
527
+
528
+ await fs.rm(compilerOptions.outDir, { recursive: true }).catch(() => {})
529
+ const tsc = findTsc({ bin: tsgo ? 'tsgo' : 'tsc', cwd })
530
+ const child = cp.spawn(tsc, ['--project', tmpProject], {
531
+ cwd,
532
+ stdio: 'inherit',
533
+ })
534
+
535
+ const promise = Promise.withResolvers<null>()
536
+
537
+ child.on('close', (code) => {
538
+ if (code === 0) promise.resolve(null)
539
+ else promise.reject(new Error(`tsgo exited with code ${code}`))
540
+ })
541
+ child.on('error', promise.reject)
542
+
543
+ await promise.promise.finally(() => fs.rm(tmpProject))
544
+
545
+ return { tsConfig }
546
+ }
547
+
548
+ export declare namespace transpile {
549
+ type Options = {
550
+ /** Working directory of the package to transpile. @default process.cwd() */
551
+ cwd?: string | undefined
552
+ /** Entry files to include in the transpilation. */
553
+ entries: string[]
554
+ /** Path to tsconfig.json file, relative to the working directory. @default './tsconfig.json' */
555
+ project?: string | undefined
556
+ /** Whether to use tsgo for transpilation. @default false */
557
+ tsgo?: boolean | undefined
558
+ }
559
+
560
+ type ReturnType = {
561
+ /** Transformed tsconfig.json file. */
562
+ tsConfig: TsConfigJson
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Writes the package.json file to the given working directory.
568
+ *
569
+ * @param cwd - Working directory to write the package.json file to.
570
+ * @param pkgJson - Package.json file to write.
571
+ */
572
+ export async function writePackageJson(cwd: string, pkgJson: PackageJson) {
573
+ await fs.writeFile(path.resolve(cwd, 'package.json'), JSON.stringify(pkgJson, null, 2), 'utf-8')
574
+ }
575
+
576
+ /**
577
+ * Finds the nearest node_modules/.bin binary by traversing up the directory tree.
578
+ *
579
+ * @param options - Options for finding the binary.
580
+ * @returns Absolute path to the binary.
581
+ * @internal
582
+ */
583
+ // biome-ignore lint/correctness/noUnusedVariables: _
584
+ function findTsc(options: findTsc.Options): string {
585
+ const { bin, cwd = import.meta.dirname } = options
586
+
587
+ let currentDir = path.resolve(cwd)
588
+ const root = path.parse(currentDir).root
589
+
590
+ while (currentDir !== root) {
591
+ const binPath = path.join(currentDir, 'node_modules', '.bin', bin)
592
+ if (fsSync.existsSync(binPath)) return binPath
593
+
594
+ const parentDir = path.dirname(currentDir)
595
+ if (parentDir === currentDir) break
596
+ currentDir = parentDir
597
+ }
598
+
599
+ throw new Error(`node_modules/.bin/${bin} not found`)
600
+ }
601
+
602
+ declare namespace findTsc {
603
+ type Options = {
604
+ /** Binary name to find */
605
+ bin: string
606
+ /** Working directory to start searching from. @default import.meta.dirname */
607
+ cwd?: string | undefined
608
+ }
609
+ }