@knighted/jsx 1.0.0-rc.0 → 1.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 +46 -4
- package/dist/cjs/loader/jsx.cjs +352 -0
- package/dist/cjs/loader/jsx.d.cts +14 -0
- package/dist/loader/jsx.d.ts +14 -0
- package/dist/loader/jsx.js +346 -0
- package/package.json +17 -2
package/README.md
CHANGED
|
@@ -21,7 +21,8 @@ The parser automatically uses native bindings when it runs in Node.js. To enable
|
|
|
21
21
|
npm_config_ignore_platform=true npm install @oxc-parser/binding-wasm32-wasi
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
>
|
|
24
|
+
> [!TIP]
|
|
25
|
+
> Public CDNs such as `esm.sh` or `jsdelivr` already publish bundles that include the WASM binding, so you can import this package directly from those endpoints in `<script type="module">` blocks without any extra setup.
|
|
25
26
|
|
|
26
27
|
## Usage
|
|
27
28
|
|
|
@@ -40,6 +41,35 @@ const button = jsx`
|
|
|
40
41
|
document.body.append(button)
|
|
41
42
|
```
|
|
42
43
|
|
|
44
|
+
## Loader integration
|
|
45
|
+
|
|
46
|
+
Use the published loader entry (`@knighted/jsx/loader`) when you want your bundler to rewrite tagged template literals at build time. The loader finds every `jsx\`…\`` invocation, rebuilds the template with real JSX semantics, and hands back transformed source that can run in any environment.
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
// rspack.config.js / webpack.config.js
|
|
50
|
+
export default {
|
|
51
|
+
module: {
|
|
52
|
+
rules: [
|
|
53
|
+
{
|
|
54
|
+
test: /\.[jt]sx?$/,
|
|
55
|
+
include: path.resolve(__dirname, 'src'),
|
|
56
|
+
use: [
|
|
57
|
+
{
|
|
58
|
+
loader: '@knighted/jsx/loader',
|
|
59
|
+
options: {
|
|
60
|
+
// Optional: rename the tagged template identifier (defaults to `jsx`).
|
|
61
|
+
tag: 'jsx',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Pair the loader with your existing TypeScript/JSX transpiler (SWC, Babel, Rspack’s builtin loader, etc.) so regular React components and the tagged templates can live side by side. The demo fixture under `test/fixtures/rspack-app` shows a full setup that mixes Lit and React—run `npm run build`, `npm run setup:wasm`, and `npm run build:fixture`, then serve the folder to see the output in a browser.
|
|
72
|
+
|
|
43
73
|
### Interpolations
|
|
44
74
|
|
|
45
75
|
- All dynamic values are provided through standard template literal expressions (`${...}`). Wrap them in JSX braces to keep the syntax valid: `className={${value}}`, `{${items}}`, etc.
|
|
@@ -94,7 +124,19 @@ When you are not using a bundler, load the module directly from a CDN that under
|
|
|
94
124
|
</script>
|
|
95
125
|
```
|
|
96
126
|
|
|
97
|
-
If you are building locally with Vite/Rollup/Webpack make sure the WASM binding is installable
|
|
127
|
+
If you are building locally with Vite/Rollup/Webpack make sure the WASM binding is installable so the bundler can resolve `@oxc-parser/binding-wasm32-wasi` (details below).
|
|
128
|
+
|
|
129
|
+
### Installing the WASM binding locally
|
|
130
|
+
|
|
131
|
+
`@oxc-parser/binding-wasm32-wasi` publishes with `"cpu": ["wasm32"]`, so npm/yarn/pnpm skip it on macOS and Linux unless you override the platform guard. Run the helper script after cloning (or whenever you clean `node_modules`) to pull the binding into place for the Vite demo and any other local bundler builds:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
npm run setup:wasm
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The script downloads the published tarball via `npm pack`, extracts it into `node_modules/@oxc-parser/binding-wasm32-wasi`, and removes the temporary archive so your lockfile stays untouched. If you need to test a different binding build, set `WASM_BINDING_PACKAGE` before running the script (for example, `WASM_BINDING_PACKAGE=@oxc-parser/binding-wasm32-wasi@0.100.0 npm run setup:wasm`).
|
|
138
|
+
|
|
139
|
+
Prefer the manual route? You can still run `npm_config_ignore_platform=true npm install --no-save @oxc-parser/binding-wasm32-wasi@^0.99.0`, but the script above replicates the vendored behavior with less ceremony.
|
|
98
140
|
|
|
99
141
|
### Lite bundle entry
|
|
100
142
|
|
|
@@ -118,7 +160,7 @@ Tests live in `test/jsx.test.ts` and cover DOM props/events, custom components,
|
|
|
118
160
|
|
|
119
161
|
## Browser demo / Vite build
|
|
120
162
|
|
|
121
|
-
This repo ships with a ready-to-run Vite demo under `examples/browser` that bundles the library (
|
|
163
|
+
This repo ships with a ready-to-run Vite demo under `examples/browser` that bundles the library (make sure you have installed the WASM binding via the command above first). Use it for a full end-to-end verification in a real browser (the demo imports `@knighted/jsx/lite` so you can confirm the lighter entry behaves identically):
|
|
122
164
|
|
|
123
165
|
```sh
|
|
124
166
|
# Start a dev server at http://localhost:5173
|
|
@@ -129,7 +171,7 @@ npm run build:demo
|
|
|
129
171
|
npm run preview
|
|
130
172
|
```
|
|
131
173
|
|
|
132
|
-
|
|
174
|
+
For a zero-build verification of the lite bundle, open `examples/esm-demo-lite.html` locally (double-click or run `open examples/esm-demo-lite.html`) or visit the deployed GitHub Pages build produced by `.github/workflows/deploy-demo.yml` (it serves that same lite HTML demo).
|
|
133
175
|
|
|
134
176
|
## Limitations
|
|
135
177
|
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = jsxLoader;
|
|
7
|
+
const magic_string_1 = __importDefault(require("magic-string"));
|
|
8
|
+
const oxc_parser_1 = require("oxc-parser");
|
|
9
|
+
const stripTrailingWhitespace = (value) => value.replace(/\s+$/g, '');
|
|
10
|
+
const stripLeadingWhitespace = (value) => value.replace(/^\s+/g, '');
|
|
11
|
+
const getTemplateExpressionContext = (left, right) => {
|
|
12
|
+
const trimmedLeft = stripTrailingWhitespace(left);
|
|
13
|
+
const trimmedRight = stripLeadingWhitespace(right);
|
|
14
|
+
if (trimmedLeft.endsWith('<') || trimmedLeft.endsWith('</')) {
|
|
15
|
+
return { type: 'tag' };
|
|
16
|
+
}
|
|
17
|
+
if (/{\s*\.\.\.$/.test(trimmedLeft) && trimmedRight.startsWith('}')) {
|
|
18
|
+
return { type: 'spread' };
|
|
19
|
+
}
|
|
20
|
+
const attrStringMatch = trimmedLeft.match(/=\s*(["'])$/);
|
|
21
|
+
if (attrStringMatch) {
|
|
22
|
+
const quoteChar = attrStringMatch[1];
|
|
23
|
+
if (trimmedRight.startsWith(quoteChar)) {
|
|
24
|
+
return { type: 'attributeString', quote: quoteChar };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (trimmedLeft.endsWith('={') && trimmedRight.startsWith('}')) {
|
|
28
|
+
return { type: 'attributeExisting' };
|
|
29
|
+
}
|
|
30
|
+
if (/=\s*$/.test(trimmedLeft)) {
|
|
31
|
+
return { type: 'attributeUnquoted' };
|
|
32
|
+
}
|
|
33
|
+
if (trimmedLeft.endsWith('{') && trimmedRight.startsWith('}')) {
|
|
34
|
+
return { type: 'childExisting' };
|
|
35
|
+
}
|
|
36
|
+
return { type: 'childText' };
|
|
37
|
+
};
|
|
38
|
+
const TEMPLATE_EXPR_PLACEHOLDER_PREFIX = '__JSX_LOADER_TEMPLATE_EXPR_';
|
|
39
|
+
const MODULE_PARSER_OPTIONS = {
|
|
40
|
+
lang: 'tsx',
|
|
41
|
+
sourceType: 'module',
|
|
42
|
+
range: true,
|
|
43
|
+
preserveParens: true,
|
|
44
|
+
};
|
|
45
|
+
const TEMPLATE_PARSER_OPTIONS = {
|
|
46
|
+
lang: 'tsx',
|
|
47
|
+
sourceType: 'module',
|
|
48
|
+
range: true,
|
|
49
|
+
preserveParens: true,
|
|
50
|
+
};
|
|
51
|
+
const DEFAULT_TAG = 'jsx';
|
|
52
|
+
const escapeTemplateChunk = (chunk) => chunk.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
|
53
|
+
const formatParserError = (error) => {
|
|
54
|
+
let message = `[jsx-loader] ${error.message}`;
|
|
55
|
+
if (error.labels?.length) {
|
|
56
|
+
const label = error.labels[0];
|
|
57
|
+
if (label.message) {
|
|
58
|
+
message += `\n${label.message}`;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (error.codeframe) {
|
|
62
|
+
message += `\n${error.codeframe}`;
|
|
63
|
+
}
|
|
64
|
+
return message;
|
|
65
|
+
};
|
|
66
|
+
const walkAst = (node, visitor) => {
|
|
67
|
+
if (!node || typeof node !== 'object') {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const current = node;
|
|
71
|
+
if (typeof current.type === 'string') {
|
|
72
|
+
visitor(current);
|
|
73
|
+
}
|
|
74
|
+
for (const value of Object.values(current)) {
|
|
75
|
+
if (!value) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (Array.isArray(value)) {
|
|
79
|
+
value.forEach(child => walkAst(child, visitor));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (typeof value === 'object') {
|
|
83
|
+
walkAst(value, visitor);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const shouldInterpolateName = (name) => /^[A-Z]/.test(name.name);
|
|
88
|
+
const addSlot = (slots, source, range) => {
|
|
89
|
+
if (!range) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const [start, end] = range;
|
|
93
|
+
if (start === end) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
slots.push({
|
|
97
|
+
start,
|
|
98
|
+
end,
|
|
99
|
+
code: source.slice(start, end),
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
const collectSlots = (program, source) => {
|
|
103
|
+
const slots = [];
|
|
104
|
+
const recordComponentName = (name) => {
|
|
105
|
+
if (!name) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
switch (name.type) {
|
|
109
|
+
case 'JSXIdentifier': {
|
|
110
|
+
if (!shouldInterpolateName(name)) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
addSlot(slots, source, name.range);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'JSXMemberExpression': {
|
|
117
|
+
addSlot(slots, source, name.range);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walkAst(program, node => {
|
|
125
|
+
switch (node.type) {
|
|
126
|
+
case 'JSXExpressionContainer': {
|
|
127
|
+
const expression = node.expression;
|
|
128
|
+
if (expression.type === 'JSXEmptyExpression') {
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
if (isLoaderPlaceholderIdentifier(expression)) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
addSlot(slots, source, (expression.range ?? node.range));
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'JSXSpreadAttribute': {
|
|
138
|
+
const argument = node.argument;
|
|
139
|
+
if (isLoaderPlaceholderIdentifier(argument)) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
addSlot(slots, source, argument?.range);
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case 'JSXSpreadChild': {
|
|
146
|
+
const expression = node.expression;
|
|
147
|
+
if (isLoaderPlaceholderIdentifier(expression)) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
addSlot(slots, source, expression?.range);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'JSXElement': {
|
|
154
|
+
const opening = node.openingElement;
|
|
155
|
+
recordComponentName(opening.name);
|
|
156
|
+
const closing = node.closingElement;
|
|
157
|
+
if (closing?.name) {
|
|
158
|
+
recordComponentName(closing.name);
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
default:
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
return slots.sort((a, b) => a.start - b.start);
|
|
167
|
+
};
|
|
168
|
+
const renderTemplateWithSlots = (source, slots) => {
|
|
169
|
+
let cursor = 0;
|
|
170
|
+
let output = '';
|
|
171
|
+
slots.forEach(slot => {
|
|
172
|
+
if (slot.start < cursor) {
|
|
173
|
+
throw new Error('Overlapping JSX expressions detected inside template literal.');
|
|
174
|
+
}
|
|
175
|
+
output += escapeTemplateChunk(source.slice(cursor, slot.start));
|
|
176
|
+
output += `\${${slot.code}}`;
|
|
177
|
+
cursor = slot.end;
|
|
178
|
+
});
|
|
179
|
+
output += escapeTemplateChunk(source.slice(cursor));
|
|
180
|
+
return { code: output, changed: slots.length > 0 };
|
|
181
|
+
};
|
|
182
|
+
const transformTemplateLiteral = (templateSource, resourcePath) => {
|
|
183
|
+
const result = (0, oxc_parser_1.parseSync)(`${resourcePath}?jsx-template`, templateSource, TEMPLATE_PARSER_OPTIONS);
|
|
184
|
+
if (result.errors.length > 0) {
|
|
185
|
+
throw new Error(formatParserError(result.errors[0]));
|
|
186
|
+
}
|
|
187
|
+
const slots = collectSlots(result.program, templateSource);
|
|
188
|
+
return renderTemplateWithSlots(templateSource, slots);
|
|
189
|
+
};
|
|
190
|
+
const isTargetTaggedTemplate = (node, source, tag) => {
|
|
191
|
+
if (node.type !== 'TaggedTemplateExpression') {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const tagNode = node.tag;
|
|
195
|
+
if (tagNode.type !== 'Identifier') {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
return tagNode.name === tag;
|
|
199
|
+
};
|
|
200
|
+
const TAG_PLACEHOLDER_PREFIX = '__JSX_LOADER_TAG_EXPR_';
|
|
201
|
+
const buildTemplateSource = (quasis, expressions, source, tag) => {
|
|
202
|
+
const placeholderMap = new Map();
|
|
203
|
+
const tagPlaceholderMap = new Map();
|
|
204
|
+
let template = '';
|
|
205
|
+
let placeholderIndex = 0;
|
|
206
|
+
let trimStartNext = 0;
|
|
207
|
+
let mutated = false;
|
|
208
|
+
const registerMarker = (code, isTag) => {
|
|
209
|
+
if (isTag) {
|
|
210
|
+
const existing = tagPlaceholderMap.get(code);
|
|
211
|
+
if (existing) {
|
|
212
|
+
return existing;
|
|
213
|
+
}
|
|
214
|
+
const marker = `${TAG_PLACEHOLDER_PREFIX}${tagPlaceholderMap.size}__`;
|
|
215
|
+
tagPlaceholderMap.set(code, marker);
|
|
216
|
+
placeholderMap.set(marker, code);
|
|
217
|
+
return marker;
|
|
218
|
+
}
|
|
219
|
+
const marker = `${TEMPLATE_EXPR_PLACEHOLDER_PREFIX}${placeholderIndex++}__`;
|
|
220
|
+
placeholderMap.set(marker, code);
|
|
221
|
+
return marker;
|
|
222
|
+
};
|
|
223
|
+
quasis.forEach((quasi, index) => {
|
|
224
|
+
let chunk = quasi.value.cooked;
|
|
225
|
+
if (typeof chunk !== 'string') {
|
|
226
|
+
chunk = quasi.value.raw ?? '';
|
|
227
|
+
}
|
|
228
|
+
if (trimStartNext > 0) {
|
|
229
|
+
chunk = chunk.slice(trimStartNext);
|
|
230
|
+
trimStartNext = 0;
|
|
231
|
+
}
|
|
232
|
+
template += chunk;
|
|
233
|
+
const expression = expressions[index];
|
|
234
|
+
if (!expression) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const start = expression.start ?? null;
|
|
238
|
+
const end = expression.end ?? null;
|
|
239
|
+
if (start === null || end === null) {
|
|
240
|
+
throw new Error('Unable to read template expression source range.');
|
|
241
|
+
}
|
|
242
|
+
const nextChunk = quasis[index + 1];
|
|
243
|
+
const nextValue = nextChunk?.value;
|
|
244
|
+
const rightText = nextValue?.cooked ?? nextValue?.raw ?? '';
|
|
245
|
+
const context = getTemplateExpressionContext(chunk, rightText);
|
|
246
|
+
const code = source.slice(start, end);
|
|
247
|
+
const marker = registerMarker(code, context.type === 'tag');
|
|
248
|
+
const appendMarker = (wrapper) => {
|
|
249
|
+
template += wrapper ? wrapper(marker) : marker;
|
|
250
|
+
};
|
|
251
|
+
switch (context.type) {
|
|
252
|
+
case 'tag':
|
|
253
|
+
case 'spread':
|
|
254
|
+
case 'attributeExisting':
|
|
255
|
+
case 'childExisting': {
|
|
256
|
+
appendMarker();
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
case 'attributeString': {
|
|
260
|
+
const quoteChar = context.quote;
|
|
261
|
+
if (!template.endsWith(quoteChar)) {
|
|
262
|
+
throw new Error(`[jsx-loader] Expected attribute quote ${quoteChar} before template expression inside ${tag}\`\` block.`);
|
|
263
|
+
}
|
|
264
|
+
template = template.slice(0, -1);
|
|
265
|
+
appendMarker(identifier => `{${identifier}}`);
|
|
266
|
+
mutated = true;
|
|
267
|
+
if (rightText.startsWith(quoteChar)) {
|
|
268
|
+
trimStartNext = 1;
|
|
269
|
+
}
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
case 'attributeUnquoted': {
|
|
273
|
+
appendMarker(identifier => `{${identifier}}`);
|
|
274
|
+
mutated = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'childText': {
|
|
278
|
+
appendMarker(identifier => `{${identifier}}`);
|
|
279
|
+
mutated = true;
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
return {
|
|
285
|
+
source: template,
|
|
286
|
+
mutated,
|
|
287
|
+
placeholders: Array.from(placeholderMap.entries()).map(([marker, code]) => ({
|
|
288
|
+
marker,
|
|
289
|
+
code,
|
|
290
|
+
})),
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
const restoreTemplatePlaceholders = (code, placeholders) => placeholders.reduce((result, placeholder) => {
|
|
294
|
+
return result.split(placeholder.marker).join(`\${${placeholder.code}}`);
|
|
295
|
+
}, code);
|
|
296
|
+
const isLoaderPlaceholderIdentifier = (node) => {
|
|
297
|
+
if (node?.type !== 'Identifier' || typeof node.name !== 'string') {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return (node.name.startsWith(TEMPLATE_EXPR_PLACEHOLDER_PREFIX) ||
|
|
301
|
+
node.name.startsWith(TAG_PLACEHOLDER_PREFIX));
|
|
302
|
+
};
|
|
303
|
+
const transformSource = (source, config) => {
|
|
304
|
+
const ast = (0, oxc_parser_1.parseSync)(config.resourcePath, source, MODULE_PARSER_OPTIONS);
|
|
305
|
+
if (ast.errors.length > 0) {
|
|
306
|
+
throw new Error(formatParserError(ast.errors[0]));
|
|
307
|
+
}
|
|
308
|
+
const taggedTemplates = [];
|
|
309
|
+
walkAst(ast.program, node => {
|
|
310
|
+
if (isTargetTaggedTemplate(node, source, config.tag)) {
|
|
311
|
+
taggedTemplates.push(node);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
if (!taggedTemplates.length) {
|
|
315
|
+
return source;
|
|
316
|
+
}
|
|
317
|
+
const magic = new magic_string_1.default(source);
|
|
318
|
+
let mutated = false;
|
|
319
|
+
taggedTemplates
|
|
320
|
+
.sort((a, b) => b.start - a.start)
|
|
321
|
+
.forEach(node => {
|
|
322
|
+
const quasi = node.quasi;
|
|
323
|
+
const templateSource = buildTemplateSource(quasi.quasis, quasi.expressions, source, config.tag);
|
|
324
|
+
const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath);
|
|
325
|
+
const restored = restoreTemplatePlaceholders(code, templateSource.placeholders);
|
|
326
|
+
const templateChanged = changed || templateSource.mutated;
|
|
327
|
+
if (!templateChanged) {
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const tagSource = source.slice(node.tag.start, node.tag.end);
|
|
331
|
+
const replacement = `${tagSource}\`${restored}\``;
|
|
332
|
+
magic.overwrite(node.start, node.end, replacement);
|
|
333
|
+
mutated = true;
|
|
334
|
+
});
|
|
335
|
+
return mutated ? magic.toString() : source;
|
|
336
|
+
};
|
|
337
|
+
function jsxLoader(input) {
|
|
338
|
+
const callback = this.async();
|
|
339
|
+
try {
|
|
340
|
+
const options = this.getOptions?.() ?? {};
|
|
341
|
+
const tag = options.tag ?? DEFAULT_TAG;
|
|
342
|
+
const source = typeof input === 'string' ? input : input.toString('utf8');
|
|
343
|
+
const output = transformSource(source, {
|
|
344
|
+
resourcePath: this.resourcePath,
|
|
345
|
+
tag,
|
|
346
|
+
});
|
|
347
|
+
callback(null, output);
|
|
348
|
+
}
|
|
349
|
+
catch (error) {
|
|
350
|
+
callback(error);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type LoaderCallback = (err: Error | null, content?: string) => void;
|
|
2
|
+
type LoaderContext<TOptions> = {
|
|
3
|
+
resourcePath: string;
|
|
4
|
+
async(): LoaderCallback;
|
|
5
|
+
getOptions?: () => Partial<TOptions>;
|
|
6
|
+
};
|
|
7
|
+
type LoaderOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* Name of the tagged template function. Defaults to `jsx`.
|
|
10
|
+
*/
|
|
11
|
+
tag?: string;
|
|
12
|
+
};
|
|
13
|
+
export default function jsxLoader(this: LoaderContext<LoaderOptions>, input: string | Buffer): void;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type LoaderCallback = (err: Error | null, content?: string) => void;
|
|
2
|
+
type LoaderContext<TOptions> = {
|
|
3
|
+
resourcePath: string;
|
|
4
|
+
async(): LoaderCallback;
|
|
5
|
+
getOptions?: () => Partial<TOptions>;
|
|
6
|
+
};
|
|
7
|
+
type LoaderOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* Name of the tagged template function. Defaults to `jsx`.
|
|
10
|
+
*/
|
|
11
|
+
tag?: string;
|
|
12
|
+
};
|
|
13
|
+
export default function jsxLoader(this: LoaderContext<LoaderOptions>, input: string | Buffer): void;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
import { parseSync } from 'oxc-parser';
|
|
3
|
+
const stripTrailingWhitespace = (value) => value.replace(/\s+$/g, '');
|
|
4
|
+
const stripLeadingWhitespace = (value) => value.replace(/^\s+/g, '');
|
|
5
|
+
const getTemplateExpressionContext = (left, right) => {
|
|
6
|
+
const trimmedLeft = stripTrailingWhitespace(left);
|
|
7
|
+
const trimmedRight = stripLeadingWhitespace(right);
|
|
8
|
+
if (trimmedLeft.endsWith('<') || trimmedLeft.endsWith('</')) {
|
|
9
|
+
return { type: 'tag' };
|
|
10
|
+
}
|
|
11
|
+
if (/{\s*\.\.\.$/.test(trimmedLeft) && trimmedRight.startsWith('}')) {
|
|
12
|
+
return { type: 'spread' };
|
|
13
|
+
}
|
|
14
|
+
const attrStringMatch = trimmedLeft.match(/=\s*(["'])$/);
|
|
15
|
+
if (attrStringMatch) {
|
|
16
|
+
const quoteChar = attrStringMatch[1];
|
|
17
|
+
if (trimmedRight.startsWith(quoteChar)) {
|
|
18
|
+
return { type: 'attributeString', quote: quoteChar };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (trimmedLeft.endsWith('={') && trimmedRight.startsWith('}')) {
|
|
22
|
+
return { type: 'attributeExisting' };
|
|
23
|
+
}
|
|
24
|
+
if (/=\s*$/.test(trimmedLeft)) {
|
|
25
|
+
return { type: 'attributeUnquoted' };
|
|
26
|
+
}
|
|
27
|
+
if (trimmedLeft.endsWith('{') && trimmedRight.startsWith('}')) {
|
|
28
|
+
return { type: 'childExisting' };
|
|
29
|
+
}
|
|
30
|
+
return { type: 'childText' };
|
|
31
|
+
};
|
|
32
|
+
const TEMPLATE_EXPR_PLACEHOLDER_PREFIX = '__JSX_LOADER_TEMPLATE_EXPR_';
|
|
33
|
+
const MODULE_PARSER_OPTIONS = {
|
|
34
|
+
lang: 'tsx',
|
|
35
|
+
sourceType: 'module',
|
|
36
|
+
range: true,
|
|
37
|
+
preserveParens: true,
|
|
38
|
+
};
|
|
39
|
+
const TEMPLATE_PARSER_OPTIONS = {
|
|
40
|
+
lang: 'tsx',
|
|
41
|
+
sourceType: 'module',
|
|
42
|
+
range: true,
|
|
43
|
+
preserveParens: true,
|
|
44
|
+
};
|
|
45
|
+
const DEFAULT_TAG = 'jsx';
|
|
46
|
+
const escapeTemplateChunk = (chunk) => chunk.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${');
|
|
47
|
+
const formatParserError = (error) => {
|
|
48
|
+
let message = `[jsx-loader] ${error.message}`;
|
|
49
|
+
if (error.labels?.length) {
|
|
50
|
+
const label = error.labels[0];
|
|
51
|
+
if (label.message) {
|
|
52
|
+
message += `\n${label.message}`;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (error.codeframe) {
|
|
56
|
+
message += `\n${error.codeframe}`;
|
|
57
|
+
}
|
|
58
|
+
return message;
|
|
59
|
+
};
|
|
60
|
+
const walkAst = (node, visitor) => {
|
|
61
|
+
if (!node || typeof node !== 'object') {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const current = node;
|
|
65
|
+
if (typeof current.type === 'string') {
|
|
66
|
+
visitor(current);
|
|
67
|
+
}
|
|
68
|
+
for (const value of Object.values(current)) {
|
|
69
|
+
if (!value) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(value)) {
|
|
73
|
+
value.forEach(child => walkAst(child, visitor));
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (typeof value === 'object') {
|
|
77
|
+
walkAst(value, visitor);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const shouldInterpolateName = (name) => /^[A-Z]/.test(name.name);
|
|
82
|
+
const addSlot = (slots, source, range) => {
|
|
83
|
+
if (!range) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const [start, end] = range;
|
|
87
|
+
if (start === end) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
slots.push({
|
|
91
|
+
start,
|
|
92
|
+
end,
|
|
93
|
+
code: source.slice(start, end),
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
const collectSlots = (program, source) => {
|
|
97
|
+
const slots = [];
|
|
98
|
+
const recordComponentName = (name) => {
|
|
99
|
+
if (!name) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
switch (name.type) {
|
|
103
|
+
case 'JSXIdentifier': {
|
|
104
|
+
if (!shouldInterpolateName(name)) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
addSlot(slots, source, name.range);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
case 'JSXMemberExpression': {
|
|
111
|
+
addSlot(slots, source, name.range);
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
walkAst(program, node => {
|
|
119
|
+
switch (node.type) {
|
|
120
|
+
case 'JSXExpressionContainer': {
|
|
121
|
+
const expression = node.expression;
|
|
122
|
+
if (expression.type === 'JSXEmptyExpression') {
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
if (isLoaderPlaceholderIdentifier(expression)) {
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
addSlot(slots, source, (expression.range ?? node.range));
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case 'JSXSpreadAttribute': {
|
|
132
|
+
const argument = node.argument;
|
|
133
|
+
if (isLoaderPlaceholderIdentifier(argument)) {
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
addSlot(slots, source, argument?.range);
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
case 'JSXSpreadChild': {
|
|
140
|
+
const expression = node.expression;
|
|
141
|
+
if (isLoaderPlaceholderIdentifier(expression)) {
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
addSlot(slots, source, expression?.range);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'JSXElement': {
|
|
148
|
+
const opening = node.openingElement;
|
|
149
|
+
recordComponentName(opening.name);
|
|
150
|
+
const closing = node.closingElement;
|
|
151
|
+
if (closing?.name) {
|
|
152
|
+
recordComponentName(closing.name);
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
return slots.sort((a, b) => a.start - b.start);
|
|
161
|
+
};
|
|
162
|
+
const renderTemplateWithSlots = (source, slots) => {
|
|
163
|
+
let cursor = 0;
|
|
164
|
+
let output = '';
|
|
165
|
+
slots.forEach(slot => {
|
|
166
|
+
if (slot.start < cursor) {
|
|
167
|
+
throw new Error('Overlapping JSX expressions detected inside template literal.');
|
|
168
|
+
}
|
|
169
|
+
output += escapeTemplateChunk(source.slice(cursor, slot.start));
|
|
170
|
+
output += `\${${slot.code}}`;
|
|
171
|
+
cursor = slot.end;
|
|
172
|
+
});
|
|
173
|
+
output += escapeTemplateChunk(source.slice(cursor));
|
|
174
|
+
return { code: output, changed: slots.length > 0 };
|
|
175
|
+
};
|
|
176
|
+
const transformTemplateLiteral = (templateSource, resourcePath) => {
|
|
177
|
+
const result = parseSync(`${resourcePath}?jsx-template`, templateSource, TEMPLATE_PARSER_OPTIONS);
|
|
178
|
+
if (result.errors.length > 0) {
|
|
179
|
+
throw new Error(formatParserError(result.errors[0]));
|
|
180
|
+
}
|
|
181
|
+
const slots = collectSlots(result.program, templateSource);
|
|
182
|
+
return renderTemplateWithSlots(templateSource, slots);
|
|
183
|
+
};
|
|
184
|
+
const isTargetTaggedTemplate = (node, source, tag) => {
|
|
185
|
+
if (node.type !== 'TaggedTemplateExpression') {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const tagNode = node.tag;
|
|
189
|
+
if (tagNode.type !== 'Identifier') {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
return tagNode.name === tag;
|
|
193
|
+
};
|
|
194
|
+
const TAG_PLACEHOLDER_PREFIX = '__JSX_LOADER_TAG_EXPR_';
|
|
195
|
+
const buildTemplateSource = (quasis, expressions, source, tag) => {
|
|
196
|
+
const placeholderMap = new Map();
|
|
197
|
+
const tagPlaceholderMap = new Map();
|
|
198
|
+
let template = '';
|
|
199
|
+
let placeholderIndex = 0;
|
|
200
|
+
let trimStartNext = 0;
|
|
201
|
+
let mutated = false;
|
|
202
|
+
const registerMarker = (code, isTag) => {
|
|
203
|
+
if (isTag) {
|
|
204
|
+
const existing = tagPlaceholderMap.get(code);
|
|
205
|
+
if (existing) {
|
|
206
|
+
return existing;
|
|
207
|
+
}
|
|
208
|
+
const marker = `${TAG_PLACEHOLDER_PREFIX}${tagPlaceholderMap.size}__`;
|
|
209
|
+
tagPlaceholderMap.set(code, marker);
|
|
210
|
+
placeholderMap.set(marker, code);
|
|
211
|
+
return marker;
|
|
212
|
+
}
|
|
213
|
+
const marker = `${TEMPLATE_EXPR_PLACEHOLDER_PREFIX}${placeholderIndex++}__`;
|
|
214
|
+
placeholderMap.set(marker, code);
|
|
215
|
+
return marker;
|
|
216
|
+
};
|
|
217
|
+
quasis.forEach((quasi, index) => {
|
|
218
|
+
let chunk = quasi.value.cooked;
|
|
219
|
+
if (typeof chunk !== 'string') {
|
|
220
|
+
chunk = quasi.value.raw ?? '';
|
|
221
|
+
}
|
|
222
|
+
if (trimStartNext > 0) {
|
|
223
|
+
chunk = chunk.slice(trimStartNext);
|
|
224
|
+
trimStartNext = 0;
|
|
225
|
+
}
|
|
226
|
+
template += chunk;
|
|
227
|
+
const expression = expressions[index];
|
|
228
|
+
if (!expression) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const start = expression.start ?? null;
|
|
232
|
+
const end = expression.end ?? null;
|
|
233
|
+
if (start === null || end === null) {
|
|
234
|
+
throw new Error('Unable to read template expression source range.');
|
|
235
|
+
}
|
|
236
|
+
const nextChunk = quasis[index + 1];
|
|
237
|
+
const nextValue = nextChunk?.value;
|
|
238
|
+
const rightText = nextValue?.cooked ?? nextValue?.raw ?? '';
|
|
239
|
+
const context = getTemplateExpressionContext(chunk, rightText);
|
|
240
|
+
const code = source.slice(start, end);
|
|
241
|
+
const marker = registerMarker(code, context.type === 'tag');
|
|
242
|
+
const appendMarker = (wrapper) => {
|
|
243
|
+
template += wrapper ? wrapper(marker) : marker;
|
|
244
|
+
};
|
|
245
|
+
switch (context.type) {
|
|
246
|
+
case 'tag':
|
|
247
|
+
case 'spread':
|
|
248
|
+
case 'attributeExisting':
|
|
249
|
+
case 'childExisting': {
|
|
250
|
+
appendMarker();
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
case 'attributeString': {
|
|
254
|
+
const quoteChar = context.quote;
|
|
255
|
+
if (!template.endsWith(quoteChar)) {
|
|
256
|
+
throw new Error(`[jsx-loader] Expected attribute quote ${quoteChar} before template expression inside ${tag}\`\` block.`);
|
|
257
|
+
}
|
|
258
|
+
template = template.slice(0, -1);
|
|
259
|
+
appendMarker(identifier => `{${identifier}}`);
|
|
260
|
+
mutated = true;
|
|
261
|
+
if (rightText.startsWith(quoteChar)) {
|
|
262
|
+
trimStartNext = 1;
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case 'attributeUnquoted': {
|
|
267
|
+
appendMarker(identifier => `{${identifier}}`);
|
|
268
|
+
mutated = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
case 'childText': {
|
|
272
|
+
appendMarker(identifier => `{${identifier}}`);
|
|
273
|
+
mutated = true;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
return {
|
|
279
|
+
source: template,
|
|
280
|
+
mutated,
|
|
281
|
+
placeholders: Array.from(placeholderMap.entries()).map(([marker, code]) => ({
|
|
282
|
+
marker,
|
|
283
|
+
code,
|
|
284
|
+
})),
|
|
285
|
+
};
|
|
286
|
+
};
|
|
287
|
+
const restoreTemplatePlaceholders = (code, placeholders) => placeholders.reduce((result, placeholder) => {
|
|
288
|
+
return result.split(placeholder.marker).join(`\${${placeholder.code}}`);
|
|
289
|
+
}, code);
|
|
290
|
+
const isLoaderPlaceholderIdentifier = (node) => {
|
|
291
|
+
if (node?.type !== 'Identifier' || typeof node.name !== 'string') {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
return (node.name.startsWith(TEMPLATE_EXPR_PLACEHOLDER_PREFIX) ||
|
|
295
|
+
node.name.startsWith(TAG_PLACEHOLDER_PREFIX));
|
|
296
|
+
};
|
|
297
|
+
const transformSource = (source, config) => {
|
|
298
|
+
const ast = parseSync(config.resourcePath, source, MODULE_PARSER_OPTIONS);
|
|
299
|
+
if (ast.errors.length > 0) {
|
|
300
|
+
throw new Error(formatParserError(ast.errors[0]));
|
|
301
|
+
}
|
|
302
|
+
const taggedTemplates = [];
|
|
303
|
+
walkAst(ast.program, node => {
|
|
304
|
+
if (isTargetTaggedTemplate(node, source, config.tag)) {
|
|
305
|
+
taggedTemplates.push(node);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
if (!taggedTemplates.length) {
|
|
309
|
+
return source;
|
|
310
|
+
}
|
|
311
|
+
const magic = new MagicString(source);
|
|
312
|
+
let mutated = false;
|
|
313
|
+
taggedTemplates
|
|
314
|
+
.sort((a, b) => b.start - a.start)
|
|
315
|
+
.forEach(node => {
|
|
316
|
+
const quasi = node.quasi;
|
|
317
|
+
const templateSource = buildTemplateSource(quasi.quasis, quasi.expressions, source, config.tag);
|
|
318
|
+
const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath);
|
|
319
|
+
const restored = restoreTemplatePlaceholders(code, templateSource.placeholders);
|
|
320
|
+
const templateChanged = changed || templateSource.mutated;
|
|
321
|
+
if (!templateChanged) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const tagSource = source.slice(node.tag.start, node.tag.end);
|
|
325
|
+
const replacement = `${tagSource}\`${restored}\``;
|
|
326
|
+
magic.overwrite(node.start, node.end, replacement);
|
|
327
|
+
mutated = true;
|
|
328
|
+
});
|
|
329
|
+
return mutated ? magic.toString() : source;
|
|
330
|
+
};
|
|
331
|
+
export default function jsxLoader(input) {
|
|
332
|
+
const callback = this.async();
|
|
333
|
+
try {
|
|
334
|
+
const options = this.getOptions?.() ?? {};
|
|
335
|
+
const tag = options.tag ?? DEFAULT_TAG;
|
|
336
|
+
const source = typeof input === 'string' ? input : input.toString('utf8');
|
|
337
|
+
const output = transformSource(source, {
|
|
338
|
+
resourcePath: this.resourcePath,
|
|
339
|
+
tag,
|
|
340
|
+
});
|
|
341
|
+
callback(null, output);
|
|
342
|
+
}
|
|
343
|
+
catch (error) {
|
|
344
|
+
callback(error);
|
|
345
|
+
}
|
|
346
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knighted/jsx",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A simple JSX transpiler that runs in node.js or the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jsx browser transform",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"import": "./dist/lite/index.js",
|
|
26
26
|
"default": "./dist/lite/index.js"
|
|
27
27
|
},
|
|
28
|
+
"./loader": {
|
|
29
|
+
"import": "./dist/loader/jsx.js",
|
|
30
|
+
"default": "./dist/loader/jsx.js"
|
|
31
|
+
},
|
|
28
32
|
"./package.json": "./package.json"
|
|
29
33
|
},
|
|
30
34
|
"engines": {
|
|
@@ -41,27 +45,38 @@
|
|
|
41
45
|
"prettier": "prettier -w .",
|
|
42
46
|
"test": "vitest run --coverage",
|
|
43
47
|
"test:watch": "vitest",
|
|
48
|
+
"build:fixture": "node scripts/build-rspack-fixture.mjs",
|
|
44
49
|
"dev": "vite dev --config vite.config.ts",
|
|
45
50
|
"build:demo": "vite build --config vite.config.ts",
|
|
46
51
|
"preview": "vite preview --config vite.config.ts",
|
|
47
52
|
"build:lite": "tsup --config tsup.config.ts",
|
|
53
|
+
"setup:wasm": "node scripts/setup-wasm.mjs",
|
|
48
54
|
"prepack": "npm run build"
|
|
49
55
|
},
|
|
50
56
|
"devDependencies": {
|
|
51
57
|
"@eslint/js": "^9.39.1",
|
|
52
58
|
"@knighted/duel": "^2.1.6",
|
|
59
|
+
"@rspack/core": "^1.0.5",
|
|
53
60
|
"@types/jsdom": "^27.0.0",
|
|
61
|
+
"@types/react": "^19.2.7",
|
|
62
|
+
"@types/react-dom": "^19.2.3",
|
|
54
63
|
"@vitest/coverage-v8": "^4.0.14",
|
|
55
64
|
"eslint": "^9.39.1",
|
|
65
|
+
"http-server": "^14.1.1",
|
|
56
66
|
"jsdom": "^27.2.0",
|
|
67
|
+
"lit": "^3.2.1",
|
|
57
68
|
"prettier": "^3.7.3",
|
|
58
|
-
"
|
|
69
|
+
"react": "^19.0.0",
|
|
70
|
+
"react-dom": "^19.0.0",
|
|
71
|
+
"tar": "^7.4.3",
|
|
72
|
+
"tsup": "^8.5.1",
|
|
59
73
|
"typescript": "^5.9.3",
|
|
60
74
|
"typescript-eslint": "^8.48.0",
|
|
61
75
|
"vite": "^7.2.4",
|
|
62
76
|
"vitest": "^4.0.14"
|
|
63
77
|
},
|
|
64
78
|
"dependencies": {
|
|
79
|
+
"magic-string": "^0.30.21",
|
|
65
80
|
"oxc-parser": "^0.99.0"
|
|
66
81
|
},
|
|
67
82
|
"optionalDependencies": {
|