@knighted/jsx 1.9.1 → 1.10.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 +33 -13
- package/dist/cjs/transform.cjs +208 -0
- package/dist/cjs/transform.d.cts +34 -0
- package/dist/cjs/transpile.cjs +56 -13
- package/dist/transform.d.ts +34 -0
- package/dist/transform.js +205 -0
- package/dist/transpile.js +56 -13
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -68,39 +68,52 @@ const button = jsx`
|
|
|
68
68
|
document.body.append(button)
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
### Source transpilation (`transpileJsxSource`)
|
|
71
|
+
### Source transpilation (`transpileJsxSource` + `transformJsxSource`)
|
|
72
72
|
|
|
73
|
-
Need to transform raw JSX source text (
|
|
73
|
+
Need to transform raw JSX source text (for example, code typed in an editor) without Babel?
|
|
74
|
+
Use one of these subpath exports:
|
|
75
|
+
|
|
76
|
+
- `@knighted/jsx/transpile` for code-only output (`{ code, changed }`).
|
|
77
|
+
- `@knighted/jsx/transform` for code plus parser-backed import metadata and diagnostics
|
|
78
|
+
(`{ code, changed, imports, diagnostics }`).
|
|
74
79
|
|
|
75
80
|
```ts
|
|
76
81
|
import { transpileJsxSource } from '@knighted/jsx/transpile'
|
|
82
|
+
import { transformJsxSource } from '@knighted/jsx/transform'
|
|
77
83
|
|
|
78
84
|
const input = `
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
85
|
+
import React from 'react'
|
|
86
|
+
const App = () => <button>click me</button>
|
|
82
87
|
`
|
|
83
88
|
|
|
84
|
-
const
|
|
85
|
-
// ->
|
|
89
|
+
const transpiled = transpileJsxSource(input)
|
|
90
|
+
// -> { code, changed }
|
|
91
|
+
|
|
92
|
+
const transformed = transformJsxSource(input, { typescript: 'strip' })
|
|
93
|
+
// -> { code, changed, imports, diagnostics }
|
|
86
94
|
```
|
|
87
95
|
|
|
88
|
-
|
|
96
|
+
Both entrypoints emit `React.createElement(...)` and `React.Fragment` by default. Override
|
|
97
|
+
them when needed:
|
|
89
98
|
|
|
90
99
|
```ts
|
|
91
100
|
transpileJsxSource(input, {
|
|
92
101
|
createElement: '__jsx',
|
|
93
102
|
fragment: '__fragment',
|
|
94
103
|
})
|
|
104
|
+
|
|
105
|
+
transformJsxSource(input, {
|
|
106
|
+
createElement: '__jsx',
|
|
107
|
+
fragment: '__fragment',
|
|
108
|
+
})
|
|
95
109
|
```
|
|
96
110
|
|
|
97
|
-
By default, TypeScript syntax is preserved in
|
|
98
|
-
|
|
111
|
+
By default, TypeScript syntax is preserved in output. If your source needs to run directly as
|
|
112
|
+
JavaScript, enable type stripping:
|
|
99
113
|
|
|
100
114
|
```ts
|
|
101
|
-
transpileJsxSource(input, {
|
|
102
|
-
|
|
103
|
-
})
|
|
115
|
+
transpileJsxSource(input, { typescript: 'strip' })
|
|
116
|
+
transformJsxSource(input, { typescript: 'strip' })
|
|
104
117
|
```
|
|
105
118
|
|
|
106
119
|
Supported `typescript` modes:
|
|
@@ -109,6 +122,13 @@ Supported `typescript` modes:
|
|
|
109
122
|
- `'strip'`: remove type-only declarations and erase inline type syntax (`: T`, `as T`,
|
|
110
123
|
`satisfies T`, non-null assertions, and type assertions) while still transpiling JSX.
|
|
111
124
|
|
|
125
|
+
Environment note:
|
|
126
|
+
|
|
127
|
+
- Both APIs are ESM-first and work in Node.
|
|
128
|
+
- For direct browser usage of `@knighted/jsx/transform`, use a CDN/runtime that can resolve
|
|
129
|
+
`oxc-transform` for the browser build (for example, modern ESM CDNs that bundle or map
|
|
130
|
+
WASM/native bindings automatically).
|
|
131
|
+
|
|
112
132
|
### React runtime (`reactJsx`)
|
|
113
133
|
|
|
114
134
|
Need to compose React elements instead of DOM nodes? Import the dedicated helper from the `@knighted/jsx/react` subpath (React 18+ and `react-dom` are still required to mount the tree):
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.transformJsxSource = transformJsxSource;
|
|
4
|
+
const oxc_parser_1 = require("oxc-parser");
|
|
5
|
+
const oxc_transform_1 = require("oxc-transform");
|
|
6
|
+
const transpile_js_1 = require("./transpile.cjs");
|
|
7
|
+
const createParserOptions = (sourceType) => ({
|
|
8
|
+
lang: 'tsx',
|
|
9
|
+
sourceType,
|
|
10
|
+
range: true,
|
|
11
|
+
preserveParens: true,
|
|
12
|
+
});
|
|
13
|
+
const isObjectRecord = (value) => typeof value === 'object' && value !== null;
|
|
14
|
+
const isSourceRange = (value) => Array.isArray(value) &&
|
|
15
|
+
value.length === 2 &&
|
|
16
|
+
typeof value[0] === 'number' &&
|
|
17
|
+
typeof value[1] === 'number';
|
|
18
|
+
const toSourceRange = (value) => {
|
|
19
|
+
if (!isObjectRecord(value) || !isSourceRange(value.range)) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
return value.range;
|
|
23
|
+
};
|
|
24
|
+
const asImportKind = (value) => value === 'type' ? 'type' : 'value';
|
|
25
|
+
const toDiagnostic = (source, diagnostic) => {
|
|
26
|
+
const firstLabel = diagnostic.labels?.[0];
|
|
27
|
+
const range = firstLabel &&
|
|
28
|
+
typeof firstLabel.start === 'number' &&
|
|
29
|
+
typeof firstLabel.end === 'number'
|
|
30
|
+
? [firstLabel.start, firstLabel.end]
|
|
31
|
+
: null;
|
|
32
|
+
return {
|
|
33
|
+
source,
|
|
34
|
+
severity: diagnostic.severity,
|
|
35
|
+
message: diagnostic.message,
|
|
36
|
+
range,
|
|
37
|
+
codeframe: diagnostic.codeframe,
|
|
38
|
+
helpMessage: diagnostic.helpMessage,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
const toImportBinding = (specifier, declarationImportKind) => {
|
|
42
|
+
if (!isObjectRecord(specifier) || typeof specifier.type !== 'string') {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (specifier.type === 'ImportDefaultSpecifier') {
|
|
46
|
+
const localName = isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
|
|
47
|
+
? specifier.local.name
|
|
48
|
+
: null;
|
|
49
|
+
if (!localName) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
kind: 'default',
|
|
54
|
+
local: localName,
|
|
55
|
+
imported: 'default',
|
|
56
|
+
isTypeOnly: declarationImportKind === 'type',
|
|
57
|
+
range: toSourceRange(specifier),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
61
|
+
const localName = isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
|
|
62
|
+
? specifier.local.name
|
|
63
|
+
: null;
|
|
64
|
+
if (!localName) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
kind: 'namespace',
|
|
69
|
+
local: localName,
|
|
70
|
+
imported: '*',
|
|
71
|
+
isTypeOnly: declarationImportKind === 'type',
|
|
72
|
+
range: toSourceRange(specifier),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
76
|
+
const importedName = isObjectRecord(specifier.imported) && typeof specifier.imported.name === 'string'
|
|
77
|
+
? specifier.imported.name
|
|
78
|
+
: null;
|
|
79
|
+
const localName = isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
|
|
80
|
+
? specifier.local.name
|
|
81
|
+
: null;
|
|
82
|
+
if (!importedName || !localName) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
kind: 'named',
|
|
87
|
+
local: localName,
|
|
88
|
+
imported: importedName,
|
|
89
|
+
isTypeOnly: declarationImportKind === 'type' || asImportKind(specifier.importKind) === 'type',
|
|
90
|
+
range: toSourceRange(specifier),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
};
|
|
95
|
+
const collectImportMetadata = (body) => {
|
|
96
|
+
if (!Array.isArray(body)) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
const imports = [];
|
|
100
|
+
body.forEach(statement => {
|
|
101
|
+
if (!isObjectRecord(statement) ||
|
|
102
|
+
statement.type !== 'ImportDeclaration' ||
|
|
103
|
+
!isObjectRecord(statement.source) ||
|
|
104
|
+
typeof statement.source.value !== 'string') {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const importKind = asImportKind(statement.importKind);
|
|
108
|
+
const bindings = Array.isArray(statement.specifiers)
|
|
109
|
+
? statement.specifiers
|
|
110
|
+
.map(specifier => toImportBinding(specifier, importKind))
|
|
111
|
+
.filter((binding) => binding !== null)
|
|
112
|
+
: [];
|
|
113
|
+
imports.push({
|
|
114
|
+
source: statement.source.value,
|
|
115
|
+
importKind,
|
|
116
|
+
sideEffectOnly: bindings.length === 0 && importKind === 'value',
|
|
117
|
+
bindings,
|
|
118
|
+
range: toSourceRange(statement),
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
return imports;
|
|
122
|
+
};
|
|
123
|
+
const ensureSupportedOptions = (options) => {
|
|
124
|
+
if (options.sourceType !== undefined &&
|
|
125
|
+
options.sourceType !== 'module' &&
|
|
126
|
+
options.sourceType !== 'script') {
|
|
127
|
+
throw new Error(`[jsx] Unsupported sourceType "${String(options.sourceType)}". Use "module" or "script".`);
|
|
128
|
+
}
|
|
129
|
+
if (options.typescript !== undefined &&
|
|
130
|
+
options.typescript !== 'preserve' &&
|
|
131
|
+
options.typescript !== 'strip') {
|
|
132
|
+
throw new Error(`[jsx] Unsupported typescript mode "${String(options.typescript)}". Use "preserve" or "strip".`);
|
|
133
|
+
}
|
|
134
|
+
if (options.typescriptStripBackend !== undefined &&
|
|
135
|
+
options.typescriptStripBackend !== 'oxc-transform' &&
|
|
136
|
+
options.typescriptStripBackend !== 'transpile-manual') {
|
|
137
|
+
throw new Error(`[jsx] Unsupported typescriptStripBackend "${String(options.typescriptStripBackend)}". Use "oxc-transform" or "transpile-manual".`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
function transformJsxSource(source, options = {}) {
|
|
141
|
+
const internalOptions = options;
|
|
142
|
+
ensureSupportedOptions(internalOptions);
|
|
143
|
+
const sourceType = internalOptions.sourceType ?? 'module';
|
|
144
|
+
const typescriptMode = internalOptions.typescript ?? 'preserve';
|
|
145
|
+
const typescriptStripBackend = internalOptions.typescriptStripBackend ?? 'oxc-transform';
|
|
146
|
+
const parsed = (0, oxc_parser_1.parseSync)('transform-jsx-source.tsx', source, createParserOptions(sourceType));
|
|
147
|
+
const parserDiagnostics = parsed.errors.map(error => toDiagnostic('parser', error));
|
|
148
|
+
const imports = collectImportMetadata(parsed.program.body);
|
|
149
|
+
if (parserDiagnostics.length) {
|
|
150
|
+
return {
|
|
151
|
+
code: source,
|
|
152
|
+
changed: false,
|
|
153
|
+
imports,
|
|
154
|
+
diagnostics: parserDiagnostics,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const transpileBaseOptions = {
|
|
158
|
+
sourceType,
|
|
159
|
+
createElement: internalOptions.createElement,
|
|
160
|
+
fragment: internalOptions.fragment,
|
|
161
|
+
typescript: 'preserve',
|
|
162
|
+
};
|
|
163
|
+
if (typescriptMode !== 'strip') {
|
|
164
|
+
const result = (0, transpile_js_1.transpileJsxSource)(source, transpileBaseOptions);
|
|
165
|
+
return {
|
|
166
|
+
code: result.code,
|
|
167
|
+
changed: result.changed,
|
|
168
|
+
imports,
|
|
169
|
+
diagnostics: parserDiagnostics,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (typescriptStripBackend === 'transpile-manual') {
|
|
173
|
+
const result = (0, transpile_js_1.transpileJsxSource)(source, {
|
|
174
|
+
...transpileBaseOptions,
|
|
175
|
+
typescript: 'strip',
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
code: result.code,
|
|
179
|
+
changed: result.changed,
|
|
180
|
+
imports,
|
|
181
|
+
diagnostics: parserDiagnostics,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const transformed = (0, oxc_transform_1.transformSync)('transform-jsx-source.tsx', source, {
|
|
185
|
+
lang: 'tsx',
|
|
186
|
+
sourceType,
|
|
187
|
+
jsx: 'preserve',
|
|
188
|
+
typescript: {},
|
|
189
|
+
});
|
|
190
|
+
const transformDiagnostics = transformed.errors.map(error => toDiagnostic('transform', error));
|
|
191
|
+
const diagnostics = [...parserDiagnostics, ...transformDiagnostics];
|
|
192
|
+
if (transformDiagnostics.length) {
|
|
193
|
+
const fallbackCode = transformed.code || source;
|
|
194
|
+
return {
|
|
195
|
+
code: fallbackCode,
|
|
196
|
+
changed: fallbackCode !== source,
|
|
197
|
+
imports,
|
|
198
|
+
diagnostics,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const jsxResult = (0, transpile_js_1.transpileJsxSource)(transformed.code, transpileBaseOptions);
|
|
202
|
+
return {
|
|
203
|
+
code: jsxResult.code,
|
|
204
|
+
changed: jsxResult.code !== source,
|
|
205
|
+
imports,
|
|
206
|
+
diagnostics,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type TranspileJsxSourceOptions } from './transpile.cjs';
|
|
2
|
+
type SourceRange = [number, number];
|
|
3
|
+
type DiagnosticSource = 'parser' | 'transform';
|
|
4
|
+
export type TransformDiagnostic = {
|
|
5
|
+
source: DiagnosticSource;
|
|
6
|
+
severity: string;
|
|
7
|
+
message: string;
|
|
8
|
+
range: SourceRange | null;
|
|
9
|
+
codeframe: string | null;
|
|
10
|
+
helpMessage: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type TransformImportBinding = {
|
|
13
|
+
kind: 'default' | 'named' | 'namespace';
|
|
14
|
+
local: string;
|
|
15
|
+
imported: string | null;
|
|
16
|
+
isTypeOnly: boolean;
|
|
17
|
+
range: SourceRange | null;
|
|
18
|
+
};
|
|
19
|
+
export type TransformImport = {
|
|
20
|
+
source: string;
|
|
21
|
+
importKind: 'type' | 'value';
|
|
22
|
+
sideEffectOnly: boolean;
|
|
23
|
+
bindings: TransformImportBinding[];
|
|
24
|
+
range: SourceRange | null;
|
|
25
|
+
};
|
|
26
|
+
export type TransformJsxSourceOptions = TranspileJsxSourceOptions;
|
|
27
|
+
export type TransformJsxSourceResult = {
|
|
28
|
+
code: string;
|
|
29
|
+
changed: boolean;
|
|
30
|
+
imports: TransformImport[];
|
|
31
|
+
diagnostics: TransformDiagnostic[];
|
|
32
|
+
};
|
|
33
|
+
export declare function transformJsxSource(source: string, options?: TransformJsxSourceOptions): TransformJsxSourceResult;
|
|
34
|
+
export {};
|
package/dist/cjs/transpile.cjs
CHANGED
|
@@ -250,6 +250,7 @@ const collectRootJsxNodes = (root) => {
|
|
|
250
250
|
walk(root, false);
|
|
251
251
|
return nodes;
|
|
252
252
|
};
|
|
253
|
+
const MAX_TYPESCRIPT_STRIP_PASSES = 5;
|
|
253
254
|
const hasStringProperty = (value, key) => isObjectRecord(value) && typeof value[key] === 'string';
|
|
254
255
|
const hasSourceAndExpressionRanges = (value) => isObjectRecord(value) &&
|
|
255
256
|
typeof value.type === 'string' &&
|
|
@@ -356,6 +357,46 @@ const applyStripEdits = (magic, edits) => {
|
|
|
356
357
|
});
|
|
357
358
|
return changed;
|
|
358
359
|
};
|
|
360
|
+
const stripTypeScriptSyntax = (source, sourceType) => {
|
|
361
|
+
let currentCode = source;
|
|
362
|
+
let changed = false;
|
|
363
|
+
let reachedStripPassLimit = true;
|
|
364
|
+
for (let pass = 0; pass < MAX_TYPESCRIPT_STRIP_PASSES; pass += 1) {
|
|
365
|
+
const parsed = (0, oxc_parser_1.parseSync)('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
|
|
366
|
+
const error = parsed.errors[0];
|
|
367
|
+
if (error) {
|
|
368
|
+
throw new Error(formatParserError(error));
|
|
369
|
+
}
|
|
370
|
+
const edits = collectTypeScriptStripEdits(currentCode, parsed.program);
|
|
371
|
+
if (!edits.length) {
|
|
372
|
+
reachedStripPassLimit = false;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
const magic = new magic_string_1.default(currentCode);
|
|
376
|
+
const passChanged = applyStripEdits(magic, edits);
|
|
377
|
+
if (!passChanged) {
|
|
378
|
+
reachedStripPassLimit = false;
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
currentCode = magic.toString();
|
|
382
|
+
changed = true;
|
|
383
|
+
}
|
|
384
|
+
if (reachedStripPassLimit) {
|
|
385
|
+
const parsed = (0, oxc_parser_1.parseSync)('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
|
|
386
|
+
const error = parsed.errors[0];
|
|
387
|
+
if (error) {
|
|
388
|
+
throw new Error(formatParserError(error));
|
|
389
|
+
}
|
|
390
|
+
const remainingEdits = collectTypeScriptStripEdits(currentCode, parsed.program);
|
|
391
|
+
if (remainingEdits.length) {
|
|
392
|
+
throw new Error(`[jsx] TypeScript strip did not converge after ${MAX_TYPESCRIPT_STRIP_PASSES} passes (${remainingEdits.length} removable TypeScript nodes remain).`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
code: currentCode,
|
|
397
|
+
changed,
|
|
398
|
+
};
|
|
399
|
+
};
|
|
359
400
|
function transpileJsxSource(source, options = {}) {
|
|
360
401
|
const sourceType = options.sourceType ?? 'module';
|
|
361
402
|
const createElementRef = options.createElement ?? 'React.createElement';
|
|
@@ -366,23 +407,25 @@ function transpileJsxSource(source, options = {}) {
|
|
|
366
407
|
if (firstError) {
|
|
367
408
|
throw new Error(formatParserError(firstError));
|
|
368
409
|
}
|
|
369
|
-
const magic = new magic_string_1.default(source);
|
|
370
|
-
const stripChanged = typescriptMode === 'strip'
|
|
371
|
-
? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program))
|
|
372
|
-
: false;
|
|
373
410
|
const jsxRoots = collectRootJsxNodes(parsed.program);
|
|
374
|
-
|
|
411
|
+
const jsxMagic = new magic_string_1.default(source);
|
|
412
|
+
if (jsxRoots.length) {
|
|
413
|
+
const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef, typescriptMode === 'strip');
|
|
414
|
+
jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
|
|
415
|
+
jsxMagic.overwrite(node.range[0], node.range[1], builder.compile(node));
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
const jsxCode = jsxRoots.length ? jsxMagic.toString() : source;
|
|
419
|
+
const jsxChanged = jsxRoots.length > 0;
|
|
420
|
+
if (typescriptMode !== 'strip') {
|
|
375
421
|
return {
|
|
376
|
-
code:
|
|
377
|
-
changed:
|
|
422
|
+
code: jsxCode,
|
|
423
|
+
changed: jsxChanged,
|
|
378
424
|
};
|
|
379
425
|
}
|
|
380
|
-
const
|
|
381
|
-
jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
|
|
382
|
-
magic.overwrite(node.range[0], node.range[1], builder.compile(node));
|
|
383
|
-
});
|
|
426
|
+
const stripResult = stripTypeScriptSyntax(jsxCode, sourceType);
|
|
384
427
|
return {
|
|
385
|
-
code:
|
|
386
|
-
changed:
|
|
428
|
+
code: stripResult.code,
|
|
429
|
+
changed: jsxChanged || stripResult.changed,
|
|
387
430
|
};
|
|
388
431
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type TranspileJsxSourceOptions } from './transpile.js';
|
|
2
|
+
type SourceRange = [number, number];
|
|
3
|
+
type DiagnosticSource = 'parser' | 'transform';
|
|
4
|
+
export type TransformDiagnostic = {
|
|
5
|
+
source: DiagnosticSource;
|
|
6
|
+
severity: string;
|
|
7
|
+
message: string;
|
|
8
|
+
range: SourceRange | null;
|
|
9
|
+
codeframe: string | null;
|
|
10
|
+
helpMessage: string | null;
|
|
11
|
+
};
|
|
12
|
+
export type TransformImportBinding = {
|
|
13
|
+
kind: 'default' | 'named' | 'namespace';
|
|
14
|
+
local: string;
|
|
15
|
+
imported: string | null;
|
|
16
|
+
isTypeOnly: boolean;
|
|
17
|
+
range: SourceRange | null;
|
|
18
|
+
};
|
|
19
|
+
export type TransformImport = {
|
|
20
|
+
source: string;
|
|
21
|
+
importKind: 'type' | 'value';
|
|
22
|
+
sideEffectOnly: boolean;
|
|
23
|
+
bindings: TransformImportBinding[];
|
|
24
|
+
range: SourceRange | null;
|
|
25
|
+
};
|
|
26
|
+
export type TransformJsxSourceOptions = TranspileJsxSourceOptions;
|
|
27
|
+
export type TransformJsxSourceResult = {
|
|
28
|
+
code: string;
|
|
29
|
+
changed: boolean;
|
|
30
|
+
imports: TransformImport[];
|
|
31
|
+
diagnostics: TransformDiagnostic[];
|
|
32
|
+
};
|
|
33
|
+
export declare function transformJsxSource(source: string, options?: TransformJsxSourceOptions): TransformJsxSourceResult;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { parseSync } from 'oxc-parser';
|
|
2
|
+
import { transformSync } from 'oxc-transform';
|
|
3
|
+
import { transpileJsxSource } from './transpile.js';
|
|
4
|
+
const createParserOptions = (sourceType) => ({
|
|
5
|
+
lang: 'tsx',
|
|
6
|
+
sourceType,
|
|
7
|
+
range: true,
|
|
8
|
+
preserveParens: true,
|
|
9
|
+
});
|
|
10
|
+
const isObjectRecord = (value) => typeof value === 'object' && value !== null;
|
|
11
|
+
const isSourceRange = (value) => Array.isArray(value) &&
|
|
12
|
+
value.length === 2 &&
|
|
13
|
+
typeof value[0] === 'number' &&
|
|
14
|
+
typeof value[1] === 'number';
|
|
15
|
+
const toSourceRange = (value) => {
|
|
16
|
+
if (!isObjectRecord(value) || !isSourceRange(value.range)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return value.range;
|
|
20
|
+
};
|
|
21
|
+
const asImportKind = (value) => value === 'type' ? 'type' : 'value';
|
|
22
|
+
const toDiagnostic = (source, diagnostic) => {
|
|
23
|
+
const firstLabel = diagnostic.labels?.[0];
|
|
24
|
+
const range = firstLabel &&
|
|
25
|
+
typeof firstLabel.start === 'number' &&
|
|
26
|
+
typeof firstLabel.end === 'number'
|
|
27
|
+
? [firstLabel.start, firstLabel.end]
|
|
28
|
+
: null;
|
|
29
|
+
return {
|
|
30
|
+
source,
|
|
31
|
+
severity: diagnostic.severity,
|
|
32
|
+
message: diagnostic.message,
|
|
33
|
+
range,
|
|
34
|
+
codeframe: diagnostic.codeframe,
|
|
35
|
+
helpMessage: diagnostic.helpMessage,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const toImportBinding = (specifier, declarationImportKind) => {
|
|
39
|
+
if (!isObjectRecord(specifier) || typeof specifier.type !== 'string') {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (specifier.type === 'ImportDefaultSpecifier') {
|
|
43
|
+
const localName = isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
|
|
44
|
+
? specifier.local.name
|
|
45
|
+
: null;
|
|
46
|
+
if (!localName) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
kind: 'default',
|
|
51
|
+
local: localName,
|
|
52
|
+
imported: 'default',
|
|
53
|
+
isTypeOnly: declarationImportKind === 'type',
|
|
54
|
+
range: toSourceRange(specifier),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (specifier.type === 'ImportNamespaceSpecifier') {
|
|
58
|
+
const localName = isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
|
|
59
|
+
? specifier.local.name
|
|
60
|
+
: null;
|
|
61
|
+
if (!localName) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
kind: 'namespace',
|
|
66
|
+
local: localName,
|
|
67
|
+
imported: '*',
|
|
68
|
+
isTypeOnly: declarationImportKind === 'type',
|
|
69
|
+
range: toSourceRange(specifier),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (specifier.type === 'ImportSpecifier') {
|
|
73
|
+
const importedName = isObjectRecord(specifier.imported) && typeof specifier.imported.name === 'string'
|
|
74
|
+
? specifier.imported.name
|
|
75
|
+
: null;
|
|
76
|
+
const localName = isObjectRecord(specifier.local) && typeof specifier.local.name === 'string'
|
|
77
|
+
? specifier.local.name
|
|
78
|
+
: null;
|
|
79
|
+
if (!importedName || !localName) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
kind: 'named',
|
|
84
|
+
local: localName,
|
|
85
|
+
imported: importedName,
|
|
86
|
+
isTypeOnly: declarationImportKind === 'type' || asImportKind(specifier.importKind) === 'type',
|
|
87
|
+
range: toSourceRange(specifier),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
};
|
|
92
|
+
const collectImportMetadata = (body) => {
|
|
93
|
+
if (!Array.isArray(body)) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const imports = [];
|
|
97
|
+
body.forEach(statement => {
|
|
98
|
+
if (!isObjectRecord(statement) ||
|
|
99
|
+
statement.type !== 'ImportDeclaration' ||
|
|
100
|
+
!isObjectRecord(statement.source) ||
|
|
101
|
+
typeof statement.source.value !== 'string') {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const importKind = asImportKind(statement.importKind);
|
|
105
|
+
const bindings = Array.isArray(statement.specifiers)
|
|
106
|
+
? statement.specifiers
|
|
107
|
+
.map(specifier => toImportBinding(specifier, importKind))
|
|
108
|
+
.filter((binding) => binding !== null)
|
|
109
|
+
: [];
|
|
110
|
+
imports.push({
|
|
111
|
+
source: statement.source.value,
|
|
112
|
+
importKind,
|
|
113
|
+
sideEffectOnly: bindings.length === 0 && importKind === 'value',
|
|
114
|
+
bindings,
|
|
115
|
+
range: toSourceRange(statement),
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return imports;
|
|
119
|
+
};
|
|
120
|
+
const ensureSupportedOptions = (options) => {
|
|
121
|
+
if (options.sourceType !== undefined &&
|
|
122
|
+
options.sourceType !== 'module' &&
|
|
123
|
+
options.sourceType !== 'script') {
|
|
124
|
+
throw new Error(`[jsx] Unsupported sourceType "${String(options.sourceType)}". Use "module" or "script".`);
|
|
125
|
+
}
|
|
126
|
+
if (options.typescript !== undefined &&
|
|
127
|
+
options.typescript !== 'preserve' &&
|
|
128
|
+
options.typescript !== 'strip') {
|
|
129
|
+
throw new Error(`[jsx] Unsupported typescript mode "${String(options.typescript)}". Use "preserve" or "strip".`);
|
|
130
|
+
}
|
|
131
|
+
if (options.typescriptStripBackend !== undefined &&
|
|
132
|
+
options.typescriptStripBackend !== 'oxc-transform' &&
|
|
133
|
+
options.typescriptStripBackend !== 'transpile-manual') {
|
|
134
|
+
throw new Error(`[jsx] Unsupported typescriptStripBackend "${String(options.typescriptStripBackend)}". Use "oxc-transform" or "transpile-manual".`);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
export function transformJsxSource(source, options = {}) {
|
|
138
|
+
const internalOptions = options;
|
|
139
|
+
ensureSupportedOptions(internalOptions);
|
|
140
|
+
const sourceType = internalOptions.sourceType ?? 'module';
|
|
141
|
+
const typescriptMode = internalOptions.typescript ?? 'preserve';
|
|
142
|
+
const typescriptStripBackend = internalOptions.typescriptStripBackend ?? 'oxc-transform';
|
|
143
|
+
const parsed = parseSync('transform-jsx-source.tsx', source, createParserOptions(sourceType));
|
|
144
|
+
const parserDiagnostics = parsed.errors.map(error => toDiagnostic('parser', error));
|
|
145
|
+
const imports = collectImportMetadata(parsed.program.body);
|
|
146
|
+
if (parserDiagnostics.length) {
|
|
147
|
+
return {
|
|
148
|
+
code: source,
|
|
149
|
+
changed: false,
|
|
150
|
+
imports,
|
|
151
|
+
diagnostics: parserDiagnostics,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const transpileBaseOptions = {
|
|
155
|
+
sourceType,
|
|
156
|
+
createElement: internalOptions.createElement,
|
|
157
|
+
fragment: internalOptions.fragment,
|
|
158
|
+
typescript: 'preserve',
|
|
159
|
+
};
|
|
160
|
+
if (typescriptMode !== 'strip') {
|
|
161
|
+
const result = transpileJsxSource(source, transpileBaseOptions);
|
|
162
|
+
return {
|
|
163
|
+
code: result.code,
|
|
164
|
+
changed: result.changed,
|
|
165
|
+
imports,
|
|
166
|
+
diagnostics: parserDiagnostics,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
if (typescriptStripBackend === 'transpile-manual') {
|
|
170
|
+
const result = transpileJsxSource(source, {
|
|
171
|
+
...transpileBaseOptions,
|
|
172
|
+
typescript: 'strip',
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
code: result.code,
|
|
176
|
+
changed: result.changed,
|
|
177
|
+
imports,
|
|
178
|
+
diagnostics: parserDiagnostics,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const transformed = transformSync('transform-jsx-source.tsx', source, {
|
|
182
|
+
lang: 'tsx',
|
|
183
|
+
sourceType,
|
|
184
|
+
jsx: 'preserve',
|
|
185
|
+
typescript: {},
|
|
186
|
+
});
|
|
187
|
+
const transformDiagnostics = transformed.errors.map(error => toDiagnostic('transform', error));
|
|
188
|
+
const diagnostics = [...parserDiagnostics, ...transformDiagnostics];
|
|
189
|
+
if (transformDiagnostics.length) {
|
|
190
|
+
const fallbackCode = transformed.code || source;
|
|
191
|
+
return {
|
|
192
|
+
code: fallbackCode,
|
|
193
|
+
changed: fallbackCode !== source,
|
|
194
|
+
imports,
|
|
195
|
+
diagnostics,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const jsxResult = transpileJsxSource(transformed.code, transpileBaseOptions);
|
|
199
|
+
return {
|
|
200
|
+
code: jsxResult.code,
|
|
201
|
+
changed: jsxResult.code !== source,
|
|
202
|
+
imports,
|
|
203
|
+
diagnostics,
|
|
204
|
+
};
|
|
205
|
+
}
|
package/dist/transpile.js
CHANGED
|
@@ -244,6 +244,7 @@ const collectRootJsxNodes = (root) => {
|
|
|
244
244
|
walk(root, false);
|
|
245
245
|
return nodes;
|
|
246
246
|
};
|
|
247
|
+
const MAX_TYPESCRIPT_STRIP_PASSES = 5;
|
|
247
248
|
const hasStringProperty = (value, key) => isObjectRecord(value) && typeof value[key] === 'string';
|
|
248
249
|
const hasSourceAndExpressionRanges = (value) => isObjectRecord(value) &&
|
|
249
250
|
typeof value.type === 'string' &&
|
|
@@ -350,6 +351,46 @@ const applyStripEdits = (magic, edits) => {
|
|
|
350
351
|
});
|
|
351
352
|
return changed;
|
|
352
353
|
};
|
|
354
|
+
const stripTypeScriptSyntax = (source, sourceType) => {
|
|
355
|
+
let currentCode = source;
|
|
356
|
+
let changed = false;
|
|
357
|
+
let reachedStripPassLimit = true;
|
|
358
|
+
for (let pass = 0; pass < MAX_TYPESCRIPT_STRIP_PASSES; pass += 1) {
|
|
359
|
+
const parsed = parseSync('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
|
|
360
|
+
const error = parsed.errors[0];
|
|
361
|
+
if (error) {
|
|
362
|
+
throw new Error(formatParserError(error));
|
|
363
|
+
}
|
|
364
|
+
const edits = collectTypeScriptStripEdits(currentCode, parsed.program);
|
|
365
|
+
if (!edits.length) {
|
|
366
|
+
reachedStripPassLimit = false;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
const magic = new MagicString(currentCode);
|
|
370
|
+
const passChanged = applyStripEdits(magic, edits);
|
|
371
|
+
if (!passChanged) {
|
|
372
|
+
reachedStripPassLimit = false;
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
currentCode = magic.toString();
|
|
376
|
+
changed = true;
|
|
377
|
+
}
|
|
378
|
+
if (reachedStripPassLimit) {
|
|
379
|
+
const parsed = parseSync('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
|
|
380
|
+
const error = parsed.errors[0];
|
|
381
|
+
if (error) {
|
|
382
|
+
throw new Error(formatParserError(error));
|
|
383
|
+
}
|
|
384
|
+
const remainingEdits = collectTypeScriptStripEdits(currentCode, parsed.program);
|
|
385
|
+
if (remainingEdits.length) {
|
|
386
|
+
throw new Error(`[jsx] TypeScript strip did not converge after ${MAX_TYPESCRIPT_STRIP_PASSES} passes (${remainingEdits.length} removable TypeScript nodes remain).`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
code: currentCode,
|
|
391
|
+
changed,
|
|
392
|
+
};
|
|
393
|
+
};
|
|
353
394
|
export function transpileJsxSource(source, options = {}) {
|
|
354
395
|
const sourceType = options.sourceType ?? 'module';
|
|
355
396
|
const createElementRef = options.createElement ?? 'React.createElement';
|
|
@@ -360,23 +401,25 @@ export function transpileJsxSource(source, options = {}) {
|
|
|
360
401
|
if (firstError) {
|
|
361
402
|
throw new Error(formatParserError(firstError));
|
|
362
403
|
}
|
|
363
|
-
const magic = new MagicString(source);
|
|
364
|
-
const stripChanged = typescriptMode === 'strip'
|
|
365
|
-
? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program))
|
|
366
|
-
: false;
|
|
367
404
|
const jsxRoots = collectRootJsxNodes(parsed.program);
|
|
368
|
-
|
|
405
|
+
const jsxMagic = new MagicString(source);
|
|
406
|
+
if (jsxRoots.length) {
|
|
407
|
+
const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef, typescriptMode === 'strip');
|
|
408
|
+
jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
|
|
409
|
+
jsxMagic.overwrite(node.range[0], node.range[1], builder.compile(node));
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
const jsxCode = jsxRoots.length ? jsxMagic.toString() : source;
|
|
413
|
+
const jsxChanged = jsxRoots.length > 0;
|
|
414
|
+
if (typescriptMode !== 'strip') {
|
|
369
415
|
return {
|
|
370
|
-
code:
|
|
371
|
-
changed:
|
|
416
|
+
code: jsxCode,
|
|
417
|
+
changed: jsxChanged,
|
|
372
418
|
};
|
|
373
419
|
}
|
|
374
|
-
const
|
|
375
|
-
jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
|
|
376
|
-
magic.overwrite(node.range[0], node.range[1], builder.compile(node));
|
|
377
|
-
});
|
|
420
|
+
const stripResult = stripTypeScriptSyntax(jsxCode, sourceType);
|
|
378
421
|
return {
|
|
379
|
-
code:
|
|
380
|
-
changed:
|
|
422
|
+
code: stripResult.code,
|
|
423
|
+
changed: jsxChanged || stripResult.changed,
|
|
381
424
|
};
|
|
382
425
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knighted/jsx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jsx runtime",
|
|
@@ -106,6 +106,11 @@
|
|
|
106
106
|
"import": "./dist/transpile.js",
|
|
107
107
|
"default": "./dist/transpile.js"
|
|
108
108
|
},
|
|
109
|
+
"./transform": {
|
|
110
|
+
"types": "./dist/transform.d.ts",
|
|
111
|
+
"import": "./dist/transform.js",
|
|
112
|
+
"default": "./dist/transform.js"
|
|
113
|
+
},
|
|
109
114
|
"./loader": {
|
|
110
115
|
"import": "./dist/loader/jsx.js",
|
|
111
116
|
"default": "./dist/loader/jsx.js"
|
|
@@ -185,6 +190,7 @@
|
|
|
185
190
|
"@napi-rs/wasm-runtime": "^1.1.1",
|
|
186
191
|
"magic-string": "^0.30.21",
|
|
187
192
|
"oxc-parser": "^0.116.0",
|
|
193
|
+
"oxc-transform": "^0.116.0",
|
|
188
194
|
"property-information": "^7.1.0",
|
|
189
195
|
"tar": "^7.5.11"
|
|
190
196
|
},
|