@knighted/jsx 1.0.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 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
- > Tip: 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.
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 (see the `npm_config_ignore_platform` tip above) so the bundler can resolve `@oxc-parser/binding-wasm32-wasi`.
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 (and the WASM binding vendored in `vendor/binding-wasm32-wasi`). Use it for a full end-to-end verification in a real browser (the demo now imports `@knighted/jsx/lite` so you can confirm the lighter entry behaves identically):
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
- The Vite config aliases `@oxc-parser/binding-wasm32-wasi` to the vendored copy so you don't have to perform any extra install tricks locally, while production consumers can still rely on the published package. 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).
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.0.0",
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
- "tsup": "^8.3.0",
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": {