dctc 1.1.2 → 1.1.3

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 CHANGED
@@ -21,7 +21,7 @@ dctc [options] <file>
21
21
  ```
22
22
  -h, --help display help for command
23
23
  -v, --version output the version number
24
- -c, --compiler <name> specify compiler: es, rollup, rolldown (default: es)
24
+ -c, --compiler <name> specify compiler: es, swc, rollup, rolldown (default: es)
25
25
  ```
26
26
 
27
27
  ## Installation
@@ -118,6 +118,7 @@ dctc generate-html.tsx
118
118
  Or specify a compiler:
119
119
 
120
120
  ```shell
121
+ dctc --compiler swc generate-html.tsx
121
122
  dctc --compiler rollup generate-html.tsx
122
123
  dctc -c rolldown generate-html.tsx
123
124
  ```
@@ -153,6 +154,54 @@ Some scenarios where you need to compile and execute tsx.
153
154
  - Developed using react, and needed to generate an html template for email.
154
155
  - When you want to preview a react component, but there is no suitable playground.
155
156
 
157
+ ## Performance (ASCII)
158
+
159
+ Benchmark setup:
160
+ - Entry: `test/generate-html.bench.tsx` (same as `test/generate-html.tsx`, but does **not** write files)
161
+ - Per compiler: **compile 1x + execute 10000x**
162
+
163
+ Raw numbers:
164
+
165
+ ```
166
+ compiler compile(ms) exec_total(ms) exec_avg(ms) ops(/s)
167
+ swc 13.5 1922.8 0.1923 5200.7
168
+ es 13.1 2438.2 0.2438 4101.4
169
+ rollup 850.4 6817.4 0.6817 1466.8
170
+ rolldown 318.1 7709.3 0.7709 1297.1
171
+ ```
172
+
173
+ Visuals (pure ASCII):
174
+
175
+ Compile time (ms) [lower is better] (scale: max=850.4ms => 40 cols)
176
+
177
+ ```
178
+ compiler value bar
179
+ es 13.1ms |# |
180
+ swc 13.5ms |# |
181
+ rolldown 318.1ms |############### |
182
+ rollup 850.4ms |########################################|
183
+ ```
184
+
185
+ Execution avg (ms) [lower is better] (scale: max=0.7709ms => 40 cols)
186
+
187
+ ```
188
+ compiler value bar
189
+ swc 0.1923ms |########## |
190
+ es 0.2438ms |############# |
191
+ rollup 0.6817ms |################################### |
192
+ rolldown 0.7709ms |########################################|
193
+ ```
194
+
195
+ Throughput (ops/s) [higher is better] (scale: max=5200.7/s => 40 cols)
196
+
197
+ ```
198
+ compiler value bar
199
+ swc 5200.7/s |########################################|
200
+ es 4101.4/s |############################### |
201
+ rollup 1466.8/s |########### |
202
+ rolldown 1297.1/s |########## |
203
+ ```
204
+
156
205
  ## Notice
157
206
  If you need to load the style file, perform an additional loader and eventually insert the style into the html template in the product, but the email template does not support external style import.
158
207
 
@@ -1,6 +1,7 @@
1
1
  const complie_es = require("../complie_es");
2
2
  const complie_rollup = require("../complie_rollup");
3
3
  const complie_rolldown = require("../complie_rolldown");
4
+ const complie_swc = require("../complie_swc");
4
5
  const execute = require("../execute");
5
6
  const chalk = require("chalk");
6
7
  const log = content => console.log(chalk.green(content));
@@ -16,7 +17,7 @@ function applyHelp() {
16
17
  logInfo("Options:");
17
18
  logInfo(` -v, --version Print the version number`);
18
19
  logInfo(` -h, --help Print this help message`);
19
- logInfo(` -c, --compiler <name> Specify compiler: es, rollup, rolldown (default: es)`);
20
+ logInfo(` -c, --compiler <name> Specify compiler: es, swc, rollup, rolldown (default: es)`);
20
21
  logInfo("Examples:");
21
22
  logInfo(` dctc src/index.tsx`);
22
23
  logInfo(` dctc src/index.ts`);
@@ -32,6 +33,9 @@ async function applyDctc(inputFile, compiler = 'es') {
32
33
  case 'esbuild':
33
34
  code = await complie_es(inputFile);
34
35
  break;
36
+ case 'swc':
37
+ code = await complie_swc(inputFile);
38
+ break;
35
39
  case 'rollup':
36
40
  code = await complie_rollup(inputFile);
37
41
  break;
@@ -0,0 +1,478 @@
1
+ /**
2
+ * Compile a given file to CommonJS format using SWC.
3
+ * This compiler bundles local (relative) TS/TSX/JS/JSX/JSON modules into a single CJS string
4
+ * so it can be executed via vm the same way as other compilers in this repo.
5
+ *
6
+ * Notes:
7
+ * - Non-relative imports (e.g. react, react-dom/server, node built-ins) remain as runtime requires.
8
+ * - Relative imports are resolved and bundled, with a tiny module loader injected.
9
+ *
10
+ * @param {string} filePath - The path to the file to be compiled.
11
+ * @returns {Promise<string>} - The compiled (bundled) code.
12
+ * @author pipi
13
+ */
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const chalk = require("chalk");
17
+ const swc = require("@swc/core");
18
+
19
+ const logErr = (content) => console.log(chalk.red(content));
20
+
21
+ const LOCAL_EXTS = [".ts", ".tsx", ".js", ".jsx", ".json"];
22
+
23
+ /**
24
+ * Determine whether an import/require specifier should be treated as "local".
25
+ *
26
+ * In this compiler, only local specifiers are bundled into the output:
27
+ * - Relative paths like "./x" or "../x"
28
+ * - Absolute file paths like "/Users/.../x"
29
+ *
30
+ * Everything else (e.g. "react", "react-dom/server", "fs") is considered external and will be
31
+ * resolved at runtime via Node's `require`.
32
+ *
33
+ * @param {string} spec - The module specifier as written in source code.
34
+ * @returns {boolean} True if the specifier is local and should be bundled.
35
+ */
36
+ function isLocalSpecifier(spec) {
37
+ return typeof spec === "string" && (spec.startsWith(".") || spec.startsWith("/"));
38
+ }
39
+
40
+ /**
41
+ * Best-effort file existence check.
42
+ *
43
+ * We wrap `fs.existsSync` + `fs.statSync` to avoid throwing (e.g. permission issues, broken symlinks)
44
+ * and to ensure the path is a regular file.
45
+ *
46
+ * @param {string} p - Path to check.
47
+ * @returns {boolean} True if the path exists and is a file.
48
+ */
49
+ function fileExists(p) {
50
+ try {
51
+ return fs.existsSync(p) && fs.statSync(p).isFile();
52
+ } catch {
53
+ return false;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Best-effort directory existence check.
59
+ *
60
+ * @param {string} p - Path to check.
61
+ * @returns {boolean} True if the path exists and is a directory.
62
+ */
63
+ function dirExists(p) {
64
+ try {
65
+ return fs.existsSync(p) && fs.statSync(p).isDirectory();
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Resolve a module path by trying common extensions and `index.*` fallbacks.
73
+ *
74
+ * This mimics typical TS/Node resolution for local imports:
75
+ * - If `basePath` is an existing file, return it as-is.
76
+ * - Otherwise, try appending known extensions: `.ts`, `.tsx`, `.js`, `.jsx`, `.json`.
77
+ * - If `basePath` is a directory, try `basePath/index.<ext>` in the same extension order.
78
+ *
79
+ * @param {string} basePath - Absolute path without extension (or a candidate path).
80
+ * @returns {string|null} The resolved file path, or null if not found.
81
+ */
82
+ function resolveWithExt(basePath) {
83
+ if (fileExists(basePath)) return basePath;
84
+
85
+ for (const ext of LOCAL_EXTS) {
86
+ const candidate = basePath + ext;
87
+ if (fileExists(candidate)) return candidate;
88
+ }
89
+
90
+ if (dirExists(basePath)) {
91
+ for (const ext of LOCAL_EXTS) {
92
+ const candidate = path.join(basePath, "index" + ext);
93
+ if (fileExists(candidate)) return candidate;
94
+ }
95
+ }
96
+
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Resolve a local specifier (relative or absolute) into an absolute file path.
102
+ *
103
+ * @param {string} fromFile - Absolute path of the importing file.
104
+ * @param {string} spec - Import specifier found in `fromFile` (must be local).
105
+ * @returns {string} Absolute path to the resolved target file.
106
+ * @throws {Error} If the target cannot be resolved.
107
+ */
108
+ function resolveLocal(fromFile, spec) {
109
+ const baseDir = path.dirname(fromFile);
110
+ const abs = spec.startsWith("/") ? path.resolve(spec) : path.resolve(baseDir, spec);
111
+ const resolved = resolveWithExt(abs);
112
+ if (!resolved) {
113
+ throw new Error(`Cannot resolve import '${spec}' from '${fromFile}'`);
114
+ }
115
+ return resolved;
116
+ }
117
+
118
+ /**
119
+ * Normalize a file path to a stable, absolute module id used as the bundle key.
120
+ *
121
+ * Using absolute, normalized paths as ids:
122
+ * - avoids duplicates (e.g. `a/../b` vs `b`)
123
+ * - makes dependency maps deterministic
124
+ *
125
+ * @param {string} p - Any file path.
126
+ * @returns {string} Normalized absolute path.
127
+ */
128
+ function normalizeId(p) {
129
+ // Keep absolute path as module id for stable caching/resolution.
130
+ return path.resolve(p);
131
+ }
132
+
133
+ /**
134
+ * Replace `import.meta.url` with a CommonJS-compatible equivalent.
135
+ *
136
+ * The execution environment for dctc is CommonJS in a VM context. `import.meta` does not exist
137
+ * there, so we rewrite it to:
138
+ * require("url").pathToFileURL(__filename).href
139
+ *
140
+ * This matches the behavior of the existing esbuild compiler in this repo.
141
+ *
142
+ * @param {string} source - Original source code.
143
+ * @returns {string} Transformed source code.
144
+ */
145
+ function patchImportMetaUrl(source) {
146
+ // Keep parity with esbuild compiler behavior in this repo.
147
+ return source.replace(/import\.meta\.url/g, 'require("url").pathToFileURL(__filename).href');
148
+ }
149
+
150
+ /**
151
+ * Collect module specifiers from a SWC AST.
152
+ *
153
+ * We intentionally only collect:
154
+ * - ESM imports: `import ... from "x"`
155
+ * - Re-exports: `export ... from "x"` / `export * from "x"`
156
+ * - CommonJS requires with string literal arguments: `require("x")`
157
+ *
158
+ * Dynamic or non-literal requires are NOT collected (e.g. `require(name)`), because we cannot
159
+ * reliably bundle those.
160
+ *
161
+ * @param {object} ast - AST returned by `swc.parse`.
162
+ * @returns {string[]} Deduplicated list of specifier strings.
163
+ */
164
+ function collectSpecifiersFromAst(ast) {
165
+ const specs = new Set();
166
+
167
+ const visit = (node) => {
168
+ if (!node) return;
169
+ if (Array.isArray(node)) {
170
+ for (const n of node) visit(n);
171
+ return;
172
+ }
173
+ if (typeof node !== "object") return;
174
+
175
+ // import ... from "x"
176
+ if (node.type === "ImportDeclaration" && node.source && typeof node.source.value === "string") {
177
+ specs.add(node.source.value);
178
+ }
179
+
180
+ // export ... from "x"
181
+ if (
182
+ (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") &&
183
+ node.source &&
184
+ typeof node.source.value === "string"
185
+ ) {
186
+ specs.add(node.source.value);
187
+ }
188
+
189
+ // require("x")
190
+ if (
191
+ node.type === "CallExpression" &&
192
+ node.callee &&
193
+ node.callee.type === "Identifier" &&
194
+ node.callee.value === "require" &&
195
+ Array.isArray(node.arguments) &&
196
+ node.arguments.length === 1
197
+ ) {
198
+ const arg = node.arguments[0];
199
+ // SWC AST uses ExprOrSpread; string literal is { expression: { type: 'StringLiteral', value: '...' } }
200
+ const expr = arg && (arg.expression || arg);
201
+ if (expr && expr.type === "StringLiteral" && typeof expr.value === "string") {
202
+ specs.add(expr.value);
203
+ }
204
+ }
205
+
206
+ for (const key of Object.keys(node)) {
207
+ if (key === "span") continue;
208
+ visit(node[key]);
209
+ }
210
+ };
211
+
212
+ visit(ast);
213
+ return Array.from(specs);
214
+ }
215
+
216
+ /**
217
+ * Parse a file and extract local dependency specifiers.
218
+ *
219
+ * This function uses SWC to parse the source into an AST, collects all static module specifiers,
220
+ * and then filters down to only local specifiers (relative or absolute paths) that we intend to bundle.
221
+ *
222
+ * @param {string} absPath - Absolute file path (used to choose parser options).
223
+ * @param {string} source - File contents (possibly preprocessed).
224
+ * @returns {Promise<string[]>} Local specifiers only (e.g. ["./src", "../util"]).
225
+ */
226
+ async function parseForImports(absPath, source) {
227
+ const ext = path.extname(absPath).toLowerCase();
228
+ const isTs = ext === ".ts" || ext === ".tsx";
229
+ const isTsx = ext === ".tsx";
230
+ const isJsx = ext === ".jsx";
231
+
232
+ const ast = await swc.parse(source, {
233
+ syntax: isTs ? "typescript" : "ecmascript",
234
+ tsx: isTsx,
235
+ jsx: isJsx,
236
+ decorators: false,
237
+ dynamicImport: true,
238
+ });
239
+
240
+ return collectSpecifiersFromAst(ast).filter(isLocalSpecifier);
241
+ }
242
+
243
+ /**
244
+ * Transform a single module to CommonJS using SWC.
245
+ *
246
+ * Important configuration choices:
247
+ * - React JSX transform uses "classic" runtime => outputs `React.createElement(...)`.
248
+ * This matches dctc's VM context which injects a `React` global.
249
+ * - Module output is CommonJS to align with the VM execution strategy.
250
+ *
251
+ * @param {string} absPath - Absolute file path (passed to SWC for better diagnostics).
252
+ * @param {string} source - File contents (possibly preprocessed).
253
+ * @returns {Promise<string>} Transformed JavaScript code in CJS format.
254
+ */
255
+ async function transformToCjs(absPath, source) {
256
+ const ext = path.extname(absPath).toLowerCase();
257
+ const isTs = ext === ".ts" || ext === ".tsx";
258
+ const isTsx = ext === ".tsx";
259
+ const isJsx = ext === ".jsx";
260
+
261
+ const out = await swc.transform(source, {
262
+ filename: absPath,
263
+ sourceMaps: false,
264
+ jsc: {
265
+ target: "es2015",
266
+ externalHelpers: false,
267
+ parser: {
268
+ syntax: isTs ? "typescript" : "ecmascript",
269
+ tsx: isTsx,
270
+ jsx: isJsx,
271
+ decorators: false,
272
+ dynamicImport: true,
273
+ },
274
+ transform: {
275
+ react: {
276
+ runtime: "classic",
277
+ pragma: "React.createElement",
278
+ pragmaFrag: "React.Fragment",
279
+ throwIfNamespace: true,
280
+ useBuiltins: false,
281
+ },
282
+ },
283
+ },
284
+ module: {
285
+ type: "commonjs",
286
+ strict: true,
287
+ strictMode: true,
288
+ lazy: false,
289
+ noInterop: false,
290
+ },
291
+ });
292
+
293
+ return out.code || "";
294
+ }
295
+
296
+ /**
297
+ * Escape a string so it can be embedded inside a double-quoted JavaScript string literal
298
+ * in the generated bundle code.
299
+ *
300
+ * @param {unknown} str - Any value convertible to string.
301
+ * @returns {string} Escaped string.
302
+ */
303
+ function escapeForJsString(str) {
304
+ return String(str).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
305
+ }
306
+
307
+ /**
308
+ * Rewrite local `require(...)` calls inside transformed CJS code to route through the bundle loader.
309
+ *
310
+ * After SWC transforms ESM imports to CJS, local imports typically become `require("./x")`.
311
+ * In a single-string VM bundle, `require("./x")` would be resolved relative to runtime state and
312
+ * would bypass our in-bundle module table.
313
+ *
314
+ * So we replace:
315
+ * require("./x") -> __dctc_require("<absolute-module-id>")
316
+ *
317
+ * Only specifiers present in `requireMap` are rewritten.
318
+ *
319
+ * @param {string} transformedCode - Output from `transformToCjs`.
320
+ * @param {Record<string, string>} requireMap - Map: original specifier -> normalized absolute module id.
321
+ * @returns {string} Rewritten code.
322
+ */
323
+ function rewriteLocalRequires(transformedCode, requireMap) {
324
+ let code = transformedCode;
325
+ for (const [spec, resolvedId] of Object.entries(requireMap)) {
326
+ const quoted = escapeForJsString(spec);
327
+ const idQuoted = escapeForJsString(resolvedId);
328
+
329
+ // Replace require("spec") and require('spec')
330
+ const reDouble = new RegExp(`\\brequire\\(\\"${quoted}\\"\\)`, "g");
331
+ const reSingle = new RegExp(`\\brequire\\(\\'${quoted.replace(/'/g, "\\'")}\\'\\)`, "g");
332
+
333
+ code = code
334
+ .replace(reDouble, `__dctc_require("${idQuoted}")`)
335
+ .replace(reSingle, `__dctc_require("${idQuoted}")`);
336
+ }
337
+ return code;
338
+ }
339
+
340
+ /**
341
+ * Compile an entry file using SWC and bundle local dependencies into a single CJS string.
342
+ *
343
+ * High-level flow:
344
+ * - Resolve the entry file to an absolute path.
345
+ * - Recursively walk local imports/re-exports/requires.
346
+ * - For each module:
347
+ * - JSON: inline as `module.exports = <parsed-json>`
348
+ * - Others: preprocess `import.meta.url`, SWC-transform to CJS, rewrite local requires.
349
+ * - Emit a small runtime module system:
350
+ * - `__dctc_modules`: module table keyed by absolute ids
351
+ * - `__dctc_require`: loads from table and falls back to Node `require` for externals
352
+ * - `__dctc_cache`: require cache to avoid double execution
353
+ * - Execute the entry module.
354
+ *
355
+ * @param {string} filePath - Entry file path (relative or absolute).
356
+ * @returns {Promise<string>} A single JavaScript string in CommonJS style ready for vm execution.
357
+ */
358
+ module.exports = async function complie_swc(filePath) {
359
+ if (!fs.existsSync(filePath)) {
360
+ console.error(`File does not exist: ${filePath}`);
361
+ process.exit(1);
362
+ }
363
+
364
+ const entryAbs = path.resolve(filePath);
365
+
366
+ try {
367
+ const modules = {}; // id -> { code, filename, dirname }
368
+ const requireMaps = {}; // id -> { spec: resolvedId }
369
+ const visiting = new Set();
370
+ const visited = new Set();
371
+
372
+ /**
373
+ * Load (and compile) one module into the in-memory bundle tables.
374
+ *
375
+ * This function is intentionally DFS:
376
+ * - It records the dependency map for the current module.
377
+ * - Then loads dependencies first.
378
+ * - Finally transforms and stores the current module.
379
+ *
380
+ * `visiting`/`visited` are used to prevent infinite recursion for cyclic imports.
381
+ * This is a pragmatic cycle guard; it does not fully emulate Node's nuanced cyclic export timing,
382
+ * but is sufficient for common project structures.
383
+ *
384
+ * @param {string} absPath - Absolute file path of the module.
385
+ * @returns {Promise<void>}
386
+ */
387
+ const loadModule = async (absPath) => {
388
+ const id = normalizeId(absPath);
389
+ if (visited.has(id)) return;
390
+ if (visiting.has(id)) return; // avoid cycles
391
+ visiting.add(id);
392
+
393
+ const ext = path.extname(absPath).toLowerCase();
394
+ if (ext === ".json") {
395
+ const jsonText = await fs.promises.readFile(absPath, "utf8");
396
+ const jsonValue = JSON.parse(jsonText);
397
+ modules[id] = {
398
+ filename: absPath,
399
+ dirname: path.dirname(absPath),
400
+ code: `module.exports = ${JSON.stringify(jsonValue)};`,
401
+ };
402
+ requireMaps[id] = {};
403
+ visited.add(id);
404
+ visiting.delete(id);
405
+ return;
406
+ }
407
+
408
+ let source = await fs.promises.readFile(absPath, "utf8");
409
+ source = patchImportMetaUrl(source);
410
+
411
+ const localImports = await parseForImports(absPath, source);
412
+ const map = {};
413
+ for (const spec of localImports) {
414
+ const resolved = resolveLocal(absPath, spec);
415
+ const resolvedId = normalizeId(resolved);
416
+ map[spec] = resolvedId;
417
+ }
418
+ requireMaps[id] = map;
419
+
420
+ // Load deps first
421
+ for (const spec of localImports) {
422
+ await loadModule(resolveLocal(absPath, spec));
423
+ }
424
+
425
+ const transformed = await transformToCjs(absPath, source);
426
+ const rewritten = rewriteLocalRequires(transformed, map);
427
+
428
+ modules[id] = {
429
+ filename: absPath,
430
+ dirname: path.dirname(absPath),
431
+ code: rewritten,
432
+ };
433
+
434
+ visited.add(id);
435
+ visiting.delete(id);
436
+ };
437
+
438
+ await loadModule(entryAbs);
439
+
440
+ const entries = Object.entries(modules);
441
+ const moduleTable = entries
442
+ .map(([id, m]) => {
443
+ const idStr = escapeForJsString(id);
444
+ const filenameStr = escapeForJsString(m.filename);
445
+ const dirnameStr = escapeForJsString(m.dirname);
446
+ // Wrap each module in a function to emulate Node's per-module __filename/__dirname.
447
+ return `"${idStr}": { filename: "${filenameStr}", dirname: "${dirnameStr}", fn: function(module, exports, __dctc_require, require, __filename, __dirname) {\n${m.code}\n} }`;
448
+ })
449
+ .join(",\n");
450
+
451
+ const entryId = escapeForJsString(normalizeId(entryAbs));
452
+
453
+ const bundle = `(function () {
454
+ "use strict";
455
+ var __dctc_modules = {
456
+ ${moduleTable}
457
+ };
458
+ var __dctc_cache = Object.create(null);
459
+ function __dctc_require(id) {
460
+ if (__dctc_cache[id]) return __dctc_cache[id].exports;
461
+ var record = __dctc_modules[id];
462
+ if (!record) return require(id);
463
+ var module = { exports: {} };
464
+ __dctc_cache[id] = module;
465
+ record.fn(module, module.exports, __dctc_require, require, record.filename, record.dirname);
466
+ return module.exports;
467
+ }
468
+ __dctc_require("${entryId}");
469
+ })();`;
470
+
471
+ return bundle;
472
+ } catch (error) {
473
+ logErr("Build failed:");
474
+ console.error(error);
475
+ throw error;
476
+ }
477
+ };
478
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dctc",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "Dynamically compile TSX/TS files and execute them.",
5
5
  "main": "./lib/index.js",
6
6
  "bin": {
@@ -44,6 +44,7 @@
44
44
  "@rollup/plugin-commonjs": "^28.0.1",
45
45
  "@rollup/plugin-node-resolve": "^15.3.0",
46
46
  "@rollup/plugin-typescript": "^12.1.0",
47
+ "@swc/core": "^1.15.7",
47
48
  "@types/node": "^22.0.0",
48
49
  "chalk": "^4.1.2",
49
50
  "commander": "^13.1.0",