tsrx-typegen 0.0.0 → 0.1.0
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/README.md +120 -0
- package/package.json +23 -5
- package/src/cli.mjs +825 -0
- package/src/pack-check.mjs +594 -0
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import {
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
statSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from 'node:fs'
|
|
13
|
+
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from 'node:path'
|
|
16
|
+
import { checkPackedPackage } from './pack-check.mjs'
|
|
17
|
+
|
|
18
|
+
const manifestName = '.tsrx-typegen-manifest.json'
|
|
19
|
+
const defaultExtensions = {
|
|
20
|
+
'.ts': '.js',
|
|
21
|
+
'.tsx': '.js',
|
|
22
|
+
'.tsrx': '.js',
|
|
23
|
+
'.mts': '.mjs',
|
|
24
|
+
'.cts': '.cjs',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function usage() {
|
|
28
|
+
console.log(`Usage: tsrx-typegen --project <tsconfig> [--target <name>] [--check]
|
|
29
|
+
tsrx-typegen --target <name> [--check]
|
|
30
|
+
tsrx-typegen --pack-check [--target <name>]
|
|
31
|
+
|
|
32
|
+
Generate declarations with the consuming project's TypeScript and tsrx compiler.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--project <path> TypeScript config for declaration generation
|
|
36
|
+
--target <name> Target in tsrx-typegen.json (selects its project and output)
|
|
37
|
+
--check Compare generated declarations without writing files
|
|
38
|
+
--pack-check Check declarations, pack the package, and validate the tarball
|
|
39
|
+
-h, --help Show this help`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(args) {
|
|
43
|
+
let project
|
|
44
|
+
let target
|
|
45
|
+
let check = false
|
|
46
|
+
let packCheck = false
|
|
47
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
48
|
+
const arg = args[index]
|
|
49
|
+
if (arg === '--help' || arg === '-h') {
|
|
50
|
+
usage()
|
|
51
|
+
process.exit(0)
|
|
52
|
+
} else if (arg === '--check') {
|
|
53
|
+
check = true
|
|
54
|
+
} else if (arg === '--pack-check') {
|
|
55
|
+
packCheck = true
|
|
56
|
+
} else if (arg === '--project') {
|
|
57
|
+
if (!args[index + 1] || args[index + 1].startsWith('--')) {
|
|
58
|
+
throw new Error('Pass a path after --project.')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
project = args[++index]
|
|
62
|
+
} else if (arg === '--target') {
|
|
63
|
+
if (!args[index + 1] || args[index + 1].startsWith('--')) {
|
|
64
|
+
throw new Error('Pass a name after --target.')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
target = args[++index]
|
|
68
|
+
} else {
|
|
69
|
+
throw new Error(`Unknown argument: ${arg}`)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!project && !target && !packCheck) {
|
|
74
|
+
throw new Error('Pass --project <tsconfig>, --target <name>, or --pack-check.')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (packCheck && project) {
|
|
78
|
+
throw new Error('--pack-check uses targets from tsrx-typegen.json; do not pass --project.')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { project: project ? resolve(project) : undefined, target, check, packCheck }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function configuredTargetNames(projectRoot, selectedTarget) {
|
|
85
|
+
const path = join(projectRoot, 'tsrx-typegen.json')
|
|
86
|
+
let config
|
|
87
|
+
try {
|
|
88
|
+
config = JSON.parse(readFileSync(path, 'utf8'))
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new Error(`${path}: ${error.message}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const targets = config?.targets
|
|
94
|
+
if (!targets || typeof targets !== 'object' || Array.isArray(targets) || !Object.keys(targets).length) {
|
|
95
|
+
throw new Error(`${path}: --pack-check requires at least one configured target`)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (selectedTarget && !Object.hasOwn(targets, selectedTarget)) {
|
|
99
|
+
throw new Error(`${path}: unknown target ${selectedTarget}`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return selectedTarget ? [selectedTarget] : Object.keys(targets)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loadProjectModule(projectRoot, specifier) {
|
|
106
|
+
const require = createRequire(join(projectRoot, 'package.json'))
|
|
107
|
+
return require.resolve(specifier)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readProjectConfig(ts, configPath, projectRoot, target) {
|
|
111
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
|
|
112
|
+
if (configFile.error) {
|
|
113
|
+
throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n'))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parsed = ts.parseJsonConfigFileContent(
|
|
117
|
+
configFile.config,
|
|
118
|
+
{ ...ts.sys },
|
|
119
|
+
dirname(configPath),
|
|
120
|
+
{},
|
|
121
|
+
configPath,
|
|
122
|
+
undefined,
|
|
123
|
+
[{ extension: '.tsrx', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if (!parsed) {throw new Error(`Could not read ${configPath}`)}
|
|
127
|
+
const errors = parsed.errors.map((diagnostic) =>
|
|
128
|
+
ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if (errors.length) {throw new Error(errors.join('\n'))}
|
|
132
|
+
const options = parsed.options
|
|
133
|
+
const jsxRenderer =
|
|
134
|
+
options.jsxImportSource ??
|
|
135
|
+
(options.jsx === ts.JsxEmit.ReactJSX || options.jsx === ts.JsxEmit.ReactJSXDev ? 'react' : undefined)
|
|
136
|
+
|
|
137
|
+
if (target?.renderer && jsxRenderer && target.renderer !== jsxRenderer) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`target ${target.name} renderer ${target.renderer} does not match compilerOptions.jsxImportSource ${jsxRenderer}`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!options.declaration || !options.emitDeclarationOnly || options.noEmit) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${configPath} must set declaration: true, emitDeclarationOnly: true, and noEmit: false.`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const outputDirectory = target?.outDir ?? options.declarationDir ?? options.outDir
|
|
150
|
+
if (!outputDirectory) {
|
|
151
|
+
throw new Error(`${configPath} must set target outDir, compilerOptions.outDir, or declarationDir.`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
outDir: target?.outDir
|
|
156
|
+
? resolve(projectRoot, outputDirectory)
|
|
157
|
+
: resolve(dirname(configPath), outputDirectory),
|
|
158
|
+
outputFlag: options.declarationDir ? '--declarationDir' : '--outDir',
|
|
159
|
+
rootDir: options.rootDir ? resolve(options.rootDir) : commonDirectory(parsed.fileNames),
|
|
160
|
+
fileNames: parsed.fileNames,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function commonDirectory(paths) {
|
|
165
|
+
if (!paths.length) {throw new Error('TypeScript project has no root files.')}
|
|
166
|
+
const segments = paths.map((path) => resolve(dirname(path)).split(sep))
|
|
167
|
+
const common = []
|
|
168
|
+
for (let index = 0; segments.every((parts) => parts[index] === segments[0][index]); index += 1) {
|
|
169
|
+
common.push(segments[0][index])
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return common.join(sep) || sep
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function sourceDeclarationMap(rootDir, fileNames, extensions, overrides) {
|
|
176
|
+
const sourceOutputs = new Map()
|
|
177
|
+
const runtimeByDeclaration = new Map()
|
|
178
|
+
for (const source of fileNames) {
|
|
179
|
+
const sourceExtension = extname(source).toLowerCase()
|
|
180
|
+
if (!Object.hasOwn(extensions, sourceExtension) || /\.d\.[cm]?ts$/i.test(source)) {continue}
|
|
181
|
+
const sourcePath = relative(rootDir, source)
|
|
182
|
+
if (!sourcePath || sourcePath.startsWith(`..${sep}`) || isAbsolute(sourcePath)) {
|
|
183
|
+
throw new Error(`Source file is outside compilerOptions.rootDir: ${source}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const declarationExtension =
|
|
187
|
+
sourceExtension === '.mts' ? '.d.mts' : sourceExtension === '.cts' ? '.d.cts' : '.d.ts'
|
|
188
|
+
|
|
189
|
+
const declaration = normalize(sourcePath.slice(0, -sourceExtension.length) + declarationExtension)
|
|
190
|
+
const prior = sourceOutputs.get(declaration)
|
|
191
|
+
if (prior && !overrides.has(declaration)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`source output collision: ${prior} and ${source} both emit ${declaration}; rename one source or declare an override for ${declaration}`,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!prior) {
|
|
198
|
+
sourceOutputs.set(declaration, source)
|
|
199
|
+
runtimeByDeclaration.set(declaration, extensions[sourceExtension])
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { sourceOutputs, runtimeByDeclaration }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function configOptions(projectRoot, projectArgument, targetArgument) {
|
|
207
|
+
const path = join(projectRoot, 'tsrx-typegen.json')
|
|
208
|
+
let config
|
|
209
|
+
try {
|
|
210
|
+
config = JSON.parse(readFileSync(path, 'utf8'))
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code === 'ENOENT') {
|
|
213
|
+
if (targetArgument) {throw new Error(`No tsrx-typegen.json defines target ${targetArgument}.`)}
|
|
214
|
+
return {
|
|
215
|
+
project: projectArgument,
|
|
216
|
+
target: null,
|
|
217
|
+
sourceExtensions: defaultExtensions,
|
|
218
|
+
overrides: {},
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw new Error(`${path}: ${error.message}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
226
|
+
throw new Error(`${path}: expected a JSON object`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const targets = config.targets ?? {}
|
|
230
|
+
if (!targets || typeof targets !== 'object' || Array.isArray(targets)) {
|
|
231
|
+
throw new Error(`${path}: targets must be an object`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const resolveProject = (value) => resolve(projectRoot, value)
|
|
235
|
+
let selectedName = targetArgument
|
|
236
|
+
let selected = selectedName ? targets[selectedName] : undefined
|
|
237
|
+
if (selectedName && !selected) {throw new Error(`${path}: unknown target ${selectedName}`)}
|
|
238
|
+
if (!selected && projectArgument) {
|
|
239
|
+
const matches = Object.entries(targets).filter(
|
|
240
|
+
([, target]) => target?.project && resolveProject(target.project) === projectArgument,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if (matches.length > 1) {
|
|
244
|
+
throw new Error(`${path}: project matches multiple targets; pass --target <name>`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (matches.length === 1) {[selectedName, selected] = matches[0]}
|
|
248
|
+
else if (Object.keys(targets).length) {
|
|
249
|
+
throw new Error(`${path}: ${projectArgument} is not declared by a target`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (selected) {
|
|
254
|
+
if (typeof selected.project !== 'string' || !selected.project) {
|
|
255
|
+
throw new Error(`${path}: target ${selectedName} must name a project`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (projectArgument && resolveProject(selected.project) !== projectArgument) {
|
|
259
|
+
throw new Error(`${path}: --project does not match target ${selectedName}`)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof selected.renderer !== 'string' || !selected.renderer) {
|
|
263
|
+
throw new Error(`${path}: target ${selectedName} must name its renderer`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (
|
|
267
|
+
!selected.entrypoints ||
|
|
268
|
+
typeof selected.entrypoints !== 'object' ||
|
|
269
|
+
Array.isArray(selected.entrypoints) ||
|
|
270
|
+
!Object.keys(selected.entrypoints).length
|
|
271
|
+
) {
|
|
272
|
+
throw new Error(`${path}: target ${selectedName} must declare entrypoints`)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const [subpath, entry] of Object.entries(selected.entrypoints)) {
|
|
276
|
+
if (
|
|
277
|
+
!entry ||
|
|
278
|
+
typeof entry !== 'object' ||
|
|
279
|
+
typeof entry.source !== 'string' ||
|
|
280
|
+
!stringsIn(entry.runtime).length ||
|
|
281
|
+
!stringsIn(entry.types).length
|
|
282
|
+
) {
|
|
283
|
+
throw new Error(`${path}: target ${selectedName} entrypoint ${subpath} needs source, runtime, and types`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const publishedPath of [...stringsIn(entry.runtime), ...stringsIn(entry.types)]) {
|
|
287
|
+
if (
|
|
288
|
+
!publishedPath.startsWith('./') ||
|
|
289
|
+
!pathInside(projectRoot, resolve(projectRoot, publishedPath))
|
|
290
|
+
) {
|
|
291
|
+
throw new Error(`${path}: ${subpath} mapping escapes the package: ${publishedPath}`)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const source = resolve(projectRoot, entry.source)
|
|
296
|
+
let isFile = false
|
|
297
|
+
try {
|
|
298
|
+
isFile = statSync(source).isFile()
|
|
299
|
+
} catch {}
|
|
300
|
+
|
|
301
|
+
if (!pathInside(projectRoot, source) || !isFile) {
|
|
302
|
+
throw new Error(`${path}: target ${selectedName} entrypoint source is missing: ${entry.source}`)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (selected.outDir) {
|
|
307
|
+
if (typeof selected.outDir !== 'string') {
|
|
308
|
+
throw new Error(`${path}: target ${selectedName} outDir must be a package-relative path`)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const output = resolve(projectRoot, selected.outDir)
|
|
312
|
+
if (!pathInside(projectRoot, output)) {
|
|
313
|
+
throw new Error(`${path}: target ${selectedName} outDir escapes the package`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!selected && Object.keys(targets).length) {
|
|
319
|
+
throw new Error(`${path}: pass --target <name> or a matching --project`)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const project = projectArgument ?? resolveProject(selected.project)
|
|
323
|
+
const sourceExtensions = { ...defaultExtensions, ...config.sourceExtensions, ...selected?.sourceExtensions }
|
|
324
|
+
for (const [source, runtime] of Object.entries(sourceExtensions)) {
|
|
325
|
+
if (!source.startsWith('.') || typeof runtime !== 'string' || !runtime.startsWith('.')) {
|
|
326
|
+
throw new Error(`${path}: sourceExtensions must map dot-prefixed extensions to dot-prefixed runtime extensions`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const globalOverrides = config.overrides ?? {}
|
|
331
|
+
const targetOverrides = selected?.overrides ?? {}
|
|
332
|
+
for (const overrides of [globalOverrides, targetOverrides]) {
|
|
333
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
334
|
+
throw new Error(`${path}: overrides must map declaration output paths to source files`)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
project,
|
|
340
|
+
target: selected ? { name: selectedName, ...selected } : null,
|
|
341
|
+
sourceExtensions,
|
|
342
|
+
overrides: { ...globalOverrides, ...targetOverrides },
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function applyOverrides(ts, projectRoot, outputDir, generated, overrides) {
|
|
347
|
+
const normalized = new Map()
|
|
348
|
+
for (const [outputPath, sourcePath] of Object.entries(overrides)) {
|
|
349
|
+
if (typeof sourcePath !== 'string' || !sourcePath) {
|
|
350
|
+
throw new Error(`override for ${outputPath} must name a source declaration file`)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const outputFile = normalize(outputPath)
|
|
354
|
+
const output = resolve(outputDir, outputFile)
|
|
355
|
+
if (!pathInside(outputDir, output) || !/\.d\.[cm]?ts$/.test(outputFile)) {
|
|
356
|
+
throw new Error(`invalid override output path: ${outputPath}`)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const source = resolve(projectRoot, sourcePath)
|
|
360
|
+
if (!pathInside(projectRoot, source)) {
|
|
361
|
+
throw new Error(`override source escapes the package: ${sourcePath}`)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
let content
|
|
365
|
+
try {
|
|
366
|
+
content = readFileSync(source, 'utf8')
|
|
367
|
+
} catch (error) {
|
|
368
|
+
throw new Error(`override source ${sourcePath}: ${error.message}`)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
assertNoTsrxModuleReferences(ts, source, content)
|
|
372
|
+
|
|
373
|
+
normalized.set(outputFile, content)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
for (const [outputPath, content] of normalized) {generated.set(outputPath, content)}
|
|
377
|
+
return new Set(normalized.keys())
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function collectFiles(root, output = []) {
|
|
381
|
+
if (!statSync(root).isDirectory()) {
|
|
382
|
+
output.push(root)
|
|
383
|
+
return output
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
387
|
+
const path = join(root, entry.name)
|
|
388
|
+
if (entry.isDirectory()) {collectFiles(path, output)}
|
|
389
|
+
else if (entry.isFile()) {output.push(path)}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return output
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function moduleSpecifierNodes(ts, sourceFile) {
|
|
396
|
+
const nodes = []
|
|
397
|
+
const visit = (node) => {
|
|
398
|
+
if (
|
|
399
|
+
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
400
|
+
node.moduleSpecifier &&
|
|
401
|
+
ts.isStringLiteralLike(node.moduleSpecifier)
|
|
402
|
+
) {
|
|
403
|
+
nodes.push(node.moduleSpecifier)
|
|
404
|
+
} else if (ts.isImportTypeNode(node)) {
|
|
405
|
+
const argument = node.argument
|
|
406
|
+
if (ts.isLiteralTypeNode(argument) && ts.isStringLiteralLike(argument.literal)) {
|
|
407
|
+
nodes.push(argument.literal)
|
|
408
|
+
}
|
|
409
|
+
} else if (
|
|
410
|
+
ts.isExternalModuleReference(node) &&
|
|
411
|
+
node.expression &&
|
|
412
|
+
ts.isStringLiteral(node.expression)
|
|
413
|
+
) {
|
|
414
|
+
nodes.push(node.expression)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
ts.forEachChild(node, visit)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
visit(sourceFile)
|
|
421
|
+
return nodes
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function assertNoTsrxModuleReferences(ts, path, text) {
|
|
425
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
426
|
+
const unresolved = moduleSpecifierNodes(ts, source).find((node) => /\.tsrx(?:$|[?#])/.test(node.text))
|
|
427
|
+
if (unresolved) {
|
|
428
|
+
throw new Error(`${path}: unresolved .tsrx module specifier ${unresolved.text}`)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function rewriteDeclaration(ts, path, outputDir, extensions, generatedFiles, runtimeByDeclaration) {
|
|
433
|
+
const text = readFileSync(path, 'utf8')
|
|
434
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
435
|
+
const edits = []
|
|
436
|
+
for (const node of moduleSpecifierNodes(ts, source)) {
|
|
437
|
+
const specifier = node.text
|
|
438
|
+
if (!specifier.startsWith('.')) {continue}
|
|
439
|
+
if (/\.d\.[cm]?ts$/i.test(specifier)) {continue}
|
|
440
|
+
const extension = Object.keys(extensions).find(
|
|
441
|
+
(candidate) => !candidate.startsWith('.d.') && specifier.endsWith(candidate),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
let replacement
|
|
445
|
+
if (extension) {
|
|
446
|
+
replacement = specifier.slice(0, -extension.length) + extensions[extension]
|
|
447
|
+
} else if (!/\.[cm]?[jt]sx?$/.test(specifier)) {
|
|
448
|
+
const targetStem = resolve(dirname(path), specifier)
|
|
449
|
+
const candidates = [
|
|
450
|
+
`${targetStem}.d.ts`,
|
|
451
|
+
`${targetStem}.d.mts`,
|
|
452
|
+
`${targetStem}.d.cts`,
|
|
453
|
+
join(targetStem, 'index.d.ts'),
|
|
454
|
+
join(targetStem, 'index.d.mts'),
|
|
455
|
+
join(targetStem, 'index.d.cts'),
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
const target = candidates.find((candidate) =>
|
|
459
|
+
generatedFiles.has(relative(outputDir, candidate)),
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if (!target) {continue}
|
|
463
|
+
const declaration = relative(outputDir, target)
|
|
464
|
+
const runtimeExtension = runtimeByDeclaration.get(declaration) ?? '.js'
|
|
465
|
+
const isIndex = /[\\/]index\.d\.[cm]?ts$/.test(target)
|
|
466
|
+
const baseSpecifier = specifier.endsWith('/') ? specifier.slice(0, -1) : specifier
|
|
467
|
+
replacement = isIndex
|
|
468
|
+
? `${baseSpecifier}/index${runtimeExtension}`
|
|
469
|
+
: `${specifier}${runtimeExtension}`
|
|
470
|
+
} else {
|
|
471
|
+
continue
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
edits.push({
|
|
475
|
+
start: node.getStart(source),
|
|
476
|
+
end: node.end,
|
|
477
|
+
replacement: `${text[node.getStart(source)]}${replacement}${text[node.end - 1]}`,
|
|
478
|
+
})
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let result = text
|
|
482
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) {
|
|
483
|
+
result = result.slice(0, edit.start) + edit.replacement + result.slice(edit.end)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
assertNoTsrxModuleReferences(ts, path, result)
|
|
487
|
+
|
|
488
|
+
return result
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function normalizeOutput(ts, outputDir, extensions, runtimeByDeclaration) {
|
|
492
|
+
const files = collectFiles(outputDir)
|
|
493
|
+
.filter((path) => /\.d\.[cm]?ts$/.test(path))
|
|
494
|
+
.sort()
|
|
495
|
+
|
|
496
|
+
if (!files.length) {throw new Error('TypeScript emitted no declaration files.')}
|
|
497
|
+
const generatedFiles = new Set(files.map((path) => relative(outputDir, path)))
|
|
498
|
+
return new Map(
|
|
499
|
+
files.map((path) => [
|
|
500
|
+
relative(outputDir, path),
|
|
501
|
+
rewriteDeclaration(
|
|
502
|
+
ts,
|
|
503
|
+
path,
|
|
504
|
+
outputDir,
|
|
505
|
+
extensions,
|
|
506
|
+
generatedFiles,
|
|
507
|
+
runtimeByDeclaration,
|
|
508
|
+
),
|
|
509
|
+
]),
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function verifySourceDeclarations(generated, sourceOutputs) {
|
|
514
|
+
const missing = [...sourceOutputs.keys()].filter((declaration) => !generated.has(declaration))
|
|
515
|
+
if (missing.length) {
|
|
516
|
+
throw new Error(`TypeScript emitted no declarations for source file(s): ${missing.join(', ')}`)
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function previousManifest(outputDir) {
|
|
521
|
+
const path = join(outputDir, manifestName)
|
|
522
|
+
try {
|
|
523
|
+
const manifest = JSON.parse(readFileSync(path, 'utf8'))
|
|
524
|
+
if (manifest.version !== 1 || !Array.isArray(manifest.files)) {
|
|
525
|
+
throw new Error('unsupported manifest format')
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return new Set(manifest.files)
|
|
529
|
+
} catch (error) {
|
|
530
|
+
if (error.code === 'ENOENT') {return null}
|
|
531
|
+
throw new Error(`${path}: ${error.message}`)
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function pathInside(root, file) {
|
|
536
|
+
const relativePath = relative(root, file)
|
|
537
|
+
return (
|
|
538
|
+
relativePath &&
|
|
539
|
+
!relativePath.startsWith(`..${sep}`) &&
|
|
540
|
+
relativePath !== '..' &&
|
|
541
|
+
!isAbsolute(relativePath)
|
|
542
|
+
)
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function writeOutput(outputDir, generated, oldFiles, overrideFiles) {
|
|
546
|
+
for (const file of generated.keys()) {
|
|
547
|
+
if (oldFiles?.has(file) || overrideFiles.has(file)) {continue}
|
|
548
|
+
try {
|
|
549
|
+
statSync(join(outputDir, file))
|
|
550
|
+
throw new Error(`refusing to overwrite unmanaged declaration: ${file}`)
|
|
551
|
+
} catch (error) {
|
|
552
|
+
if (error.code !== 'ENOENT') {throw error}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (const file of oldFiles ?? []) {
|
|
557
|
+
const path = resolve(outputDir, file)
|
|
558
|
+
if (!pathInside(outputDir, path)) {throw new Error(`Invalid generated path in manifest: ${file}`)}
|
|
559
|
+
if (!generated.has(file)) {rmSync(path, { force: true })}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
for (const [file, content] of generated) {
|
|
563
|
+
const path = join(outputDir, file)
|
|
564
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
565
|
+
writeFileSync(path, content)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
mkdirSync(outputDir, { recursive: true })
|
|
569
|
+
writeFileSync(
|
|
570
|
+
join(outputDir, manifestName),
|
|
571
|
+
JSON.stringify({ version: 1, files: [...generated.keys()] }, null, 2) + '\n',
|
|
572
|
+
)
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function compareOutput(outputDir, generated, oldFiles, overrideFiles) {
|
|
576
|
+
const errors = []
|
|
577
|
+
if (!oldFiles) {errors.push(`missing ${manifestName}; run tsrx-typegen --project <tsconfig> first`)}
|
|
578
|
+
for (const file of generated.keys()) {
|
|
579
|
+
if (!oldFiles?.has(file) && !overrideFiles.has(file)) {
|
|
580
|
+
try {
|
|
581
|
+
statSync(join(outputDir, file))
|
|
582
|
+
errors.push(`${file} conflicts with unmanaged output`)
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (error.code !== 'ENOENT') {throw error}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
try {
|
|
589
|
+
if (readFileSync(join(outputDir, file), 'utf8') !== generated.get(file))
|
|
590
|
+
{errors.push(`${file} is stale`)}
|
|
591
|
+
} catch {
|
|
592
|
+
errors.push(`${file} is missing`)
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
for (const file of oldFiles ?? []) {
|
|
597
|
+
if (!generated.has(file)) {errors.push(`${file} is stale`)}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return errors
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function stringsIn(value) {
|
|
604
|
+
if (typeof value === 'string') {return [value]}
|
|
605
|
+
if (!value || typeof value !== 'object') {return []}
|
|
606
|
+
return Object.values(value).flatMap(stringsIn)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function typeTargets(value) {
|
|
610
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {return []}
|
|
611
|
+
return [
|
|
612
|
+
...(Object.hasOwn(value, 'types') ? stringsIn(value.types) : []),
|
|
613
|
+
...Object.entries(value)
|
|
614
|
+
.filter(([key]) => key !== 'types' && key !== 'default')
|
|
615
|
+
.flatMap(([, child]) => typeTargets(child)),
|
|
616
|
+
]
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function verifyPublicTypes(projectRoot, generated, outDir, target, sourceOutputs) {
|
|
620
|
+
const manifestPath = join(projectRoot, 'package.json')
|
|
621
|
+
let manifest
|
|
622
|
+
try {
|
|
623
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
624
|
+
} catch (error) {
|
|
625
|
+
throw new Error(`${manifestPath}: ${error.message}`)
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const exportsMap = manifest.publishConfig?.exports ?? manifest.exports
|
|
629
|
+
const failures = []
|
|
630
|
+
if (target && exportsMap && typeof exportsMap === 'object') {
|
|
631
|
+
const configuredSubpaths = new Set(Object.keys(target.entrypoints))
|
|
632
|
+
for (const [subpath, mapping] of Object.entries(exportsMap)) {
|
|
633
|
+
const runtimeTargets = stringsIn(mapping).filter((entry) =>
|
|
634
|
+
/\.(?:[cm]?[jt]sx?|tsrx)$/.test(entry),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
if (runtimeTargets.length && !configuredSubpaths.has(subpath)) {
|
|
638
|
+
failures.push(`${subpath} has code exports but no ${target.name} entrypoint config`)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
for (const [subpath, entry] of Object.entries(target.entrypoints)) {
|
|
643
|
+
const mapping = exportsMap[subpath]
|
|
644
|
+
if (!mapping) {
|
|
645
|
+
failures.push(`${subpath} is configured for ${target.name} but missing from effective package exports`)
|
|
646
|
+
continue
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const source = resolve(projectRoot, entry.source)
|
|
650
|
+
if (![...sourceOutputs.values()].includes(source)) {
|
|
651
|
+
failures.push(`${subpath} source is not included by its TypeScript project: ${entry.source}`)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const declaredRuntime = stringsIn(entry.runtime)
|
|
655
|
+
const publishedRuntime = stringsIn(mapping)
|
|
656
|
+
for (const runtime of declaredRuntime) {
|
|
657
|
+
if (!publishedRuntime.includes(runtime)) {
|
|
658
|
+
failures.push(`${subpath} runtime mapping is not present in package exports: ${runtime}`)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const declaredTypes = stringsIn(entry.types)
|
|
663
|
+
const publishedTypes = typeTargets(mapping)
|
|
664
|
+
for (const typePath of declaredTypes) {
|
|
665
|
+
if (!publishedTypes.includes(typePath)) {
|
|
666
|
+
failures.push(`${subpath} types mapping is not present in package exports: ${typePath}`)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (exportsMap && typeof exportsMap === 'object') {
|
|
673
|
+
for (const [subpath, mapping] of Object.entries(exportsMap)) {
|
|
674
|
+
const runtimeTargets = stringsIn(mapping).filter((target) =>
|
|
675
|
+
/\.(?:[cm]?[jt]sx?|tsrx)$/.test(target),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
if (!runtimeTargets.length) {continue}
|
|
679
|
+
const targets = typeTargets(mapping)
|
|
680
|
+
if (!targets.length) {
|
|
681
|
+
failures.push(`${subpath} has code exports but no types condition`)
|
|
682
|
+
continue
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
for (const target of targets) {
|
|
686
|
+
if (!target.startsWith('./') || target.includes('*')) {continue}
|
|
687
|
+
const absolute = resolve(projectRoot, target)
|
|
688
|
+
if (!pathInside(projectRoot, absolute)) {
|
|
689
|
+
failures.push(`${subpath} types target escapes the package: ${target}`)
|
|
690
|
+
continue
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const generatedPath = relative(outDir, absolute)
|
|
694
|
+
if (pathInside(outDir, absolute) && generated.has(generatedPath)) {continue}
|
|
695
|
+
try {
|
|
696
|
+
if (!statSync(absolute).isFile())
|
|
697
|
+
{failures.push(`${subpath} types target is not a file: ${target}`)}
|
|
698
|
+
} catch {
|
|
699
|
+
failures.push(`${subpath} types target is missing: ${target}`)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
} else if (manifest.types ?? manifest.typings) {
|
|
704
|
+
const targetPath = manifest.types ?? manifest.typings
|
|
705
|
+
const target = resolve(projectRoot, targetPath)
|
|
706
|
+
if (!pathInside(projectRoot, target)) {
|
|
707
|
+
failures.push(`package types target escapes the package: ${targetPath}`)
|
|
708
|
+
} else if (!(pathInside(outDir, target) && generated.has(relative(outDir, target)))) {
|
|
709
|
+
try {
|
|
710
|
+
if (!statSync(target).isFile()) {failures.push(`package types target is not a file: ${targetPath}`)}
|
|
711
|
+
} catch {
|
|
712
|
+
failures.push(`package types target is missing: ${targetPath}`)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (failures.length) {throw new Error(failures.join('\n'))}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function runTarget(projectRoot, args) {
|
|
721
|
+
const { project, target, sourceExtensions, overrides } = configOptions(
|
|
722
|
+
projectRoot,
|
|
723
|
+
args.project,
|
|
724
|
+
args.target,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
const tsPath = loadProjectModule(projectRoot, 'typescript')
|
|
728
|
+
const ts = createRequire(import.meta.url)(tsPath)
|
|
729
|
+
const { outDir, outputFlag, rootDir, fileNames } = readProjectConfig(
|
|
730
|
+
ts,
|
|
731
|
+
project,
|
|
732
|
+
projectRoot,
|
|
733
|
+
target,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
const overrideFiles = new Set(Object.keys(overrides).map(normalize))
|
|
737
|
+
const { sourceOutputs, runtimeByDeclaration } = sourceDeclarationMap(
|
|
738
|
+
rootDir,
|
|
739
|
+
fileNames,
|
|
740
|
+
sourceExtensions,
|
|
741
|
+
overrideFiles,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
const compilerPath = loadProjectModule(projectRoot, '@tsrx/typescript-plugin/dist/tsc.js')
|
|
745
|
+
const temporaryRoot = mkdtempSync(join(tmpdir(), 'tsrx-typegen-'))
|
|
746
|
+
const temporaryOut = join(temporaryRoot, 'types')
|
|
747
|
+
const outputDir = temporaryOut
|
|
748
|
+
try {
|
|
749
|
+
const compilerArgs = [compilerPath, '--pretty', 'false', '--project', project, outputFlag, temporaryOut]
|
|
750
|
+
const result = spawnSync(process.execPath, compilerArgs, {
|
|
751
|
+
cwd: projectRoot,
|
|
752
|
+
encoding: 'utf8',
|
|
753
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
if (result.stdout) {process.stdout.write(result.stdout)}
|
|
757
|
+
if (result.stderr) {process.stderr.write(result.stderr)}
|
|
758
|
+
if (result.error) {throw result.error}
|
|
759
|
+
if (/\[tsrx-tsc\]/.test(result.stderr ?? '')) {
|
|
760
|
+
throw new Error(
|
|
761
|
+
'tsrx compiler reported transform diagnostics; declarations were not written. Fix the source or configure an explicit declaration override.',
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (result.status !== 0) {
|
|
766
|
+
console.error(
|
|
767
|
+
'tsrx-typegen: declaration emit failed; generated files were not changed. Fix compiler diagnostics or configure an explicit declaration override for an unsupported public signature.',
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
return false
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const generated = normalizeOutput(ts, outputDir, sourceExtensions, runtimeByDeclaration)
|
|
774
|
+
const managedOverrides = applyOverrides(ts, projectRoot, outDir, generated, overrides)
|
|
775
|
+
verifySourceDeclarations(generated, sourceOutputs)
|
|
776
|
+
const oldFiles = previousManifest(outDir)
|
|
777
|
+
verifyPublicTypes(projectRoot, generated, outDir, target, sourceOutputs)
|
|
778
|
+
if (args.check) {
|
|
779
|
+
const errors = compareOutput(outDir, generated, oldFiles, managedOverrides)
|
|
780
|
+
if (errors.length) {
|
|
781
|
+
for (const error of errors) {console.error(`tsrx-typegen: ${error}`)}
|
|
782
|
+
return false
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
console.log(`tsrx-typegen: ${generated.size} declaration file(s) are current`)
|
|
786
|
+
} else {
|
|
787
|
+
writeOutput(outDir, generated, oldFiles, managedOverrides)
|
|
788
|
+
console.log(
|
|
789
|
+
`tsrx-typegen: wrote ${generated.size} declaration file(s) to ${relative(projectRoot, outDir) || outDir}`,
|
|
790
|
+
)
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return true
|
|
794
|
+
} finally {
|
|
795
|
+
rmSync(temporaryRoot, { recursive: true, force: true })
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function run() {
|
|
800
|
+
const args = parseArgs(process.argv.slice(2))
|
|
801
|
+
const projectRoot = process.cwd()
|
|
802
|
+
if (!args.packCheck) {
|
|
803
|
+
if (!runTarget(projectRoot, args)) {process.exitCode = 1}
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const targets = configuredTargetNames(projectRoot, args.target)
|
|
808
|
+
for (const target of targets) {
|
|
809
|
+
if (!runTarget(projectRoot, { ...args, target, check: true })) {
|
|
810
|
+
process.exitCode = 1
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const tsPath = loadProjectModule(projectRoot, 'typescript')
|
|
816
|
+
const ts = createRequire(import.meta.url)(tsPath)
|
|
817
|
+
checkPackedPackage(projectRoot, ts)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
run()
|
|
822
|
+
} catch (error) {
|
|
823
|
+
console.error(`tsrx-typegen: ${error.message}`)
|
|
824
|
+
process.exitCode = 1
|
|
825
|
+
}
|