@knighted/jsx 1.2.0-rc.1 → 1.2.0-rc.2

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
@@ -127,14 +127,14 @@ npm run setup:wasm
127
127
  npm run build:fixture
128
128
  ```
129
129
 
130
- Then point a static server at the fixture root (which serves `index.html` and the bundled `dist/bundle.js`) to see it in a browser:
130
+ Then point a static server at the fixture root (which serves `index.html` plus the bundled `dist/hybrid.js` and `dist/reactMode.js`) to see it in a browser:
131
131
 
132
132
  ```sh
133
133
  # Serve the rspack fixture from the repo root
134
134
  npx http-server test/fixtures/rspack-app -p 4173
135
135
  ```
136
136
 
137
- Visit `http://localhost:4173` (or whichever port you pick) to interact with the Lit + React demo.
137
+ Visit `http://localhost:4173` (or whichever port you pick) to interact with both the Lit + React hybrid demo and the React-mode bundle.
138
138
 
139
139
  ## Node / SSR usage
140
140
 
@@ -6,6 +6,168 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.default = jsxLoader;
7
7
  const magic_string_1 = __importDefault(require("magic-string"));
8
8
  const oxc_parser_1 = require("oxc-parser");
9
+ const createPlaceholderMap = (placeholders) => new Map(placeholders.map(entry => [entry.marker, entry.code]));
10
+ class ReactTemplateBuilder {
11
+ placeholderMap;
12
+ constructor(placeholderSource) {
13
+ this.placeholderMap = createPlaceholderMap(placeholderSource);
14
+ }
15
+ compile(node) {
16
+ return this.compileNode(node);
17
+ }
18
+ compileNode(node) {
19
+ if (node.type === 'JSXFragment') {
20
+ const children = this.compileChildren(node.children);
21
+ return this.buildCreateElement('React.Fragment', 'null', children);
22
+ }
23
+ const opening = node.openingElement;
24
+ const tagExpr = this.compileTagName(opening.name);
25
+ const propsExpr = this.compileProps(opening.attributes);
26
+ const children = this.compileChildren(node.children);
27
+ return this.buildCreateElement(tagExpr, propsExpr, children);
28
+ }
29
+ compileChildren(children) {
30
+ const compiled = [];
31
+ children.forEach(child => {
32
+ switch (child.type) {
33
+ case 'JSXText': {
34
+ const text = normalizeJsxTextValue(child.value);
35
+ if (text) {
36
+ compiled.push(JSON.stringify(text));
37
+ }
38
+ break;
39
+ }
40
+ case 'JSXExpressionContainer': {
41
+ if (child.expression.type === 'JSXEmptyExpression') {
42
+ break;
43
+ }
44
+ compiled.push(this.compileExpression(child.expression));
45
+ break;
46
+ }
47
+ case 'JSXSpreadChild': {
48
+ compiled.push(this.compileExpression(child.expression));
49
+ break;
50
+ }
51
+ case 'JSXElement':
52
+ case 'JSXFragment': {
53
+ compiled.push(this.compileNode(child));
54
+ break;
55
+ }
56
+ }
57
+ });
58
+ return compiled;
59
+ }
60
+ compileProps(attributes) {
61
+ const segments = [];
62
+ let staticEntries = [];
63
+ const flushStatics = () => {
64
+ if (!staticEntries.length) {
65
+ return;
66
+ }
67
+ segments.push(`{ ${staticEntries.join(', ')} }`);
68
+ staticEntries = [];
69
+ };
70
+ attributes.forEach(attribute => {
71
+ if (attribute.type === 'JSXSpreadAttribute') {
72
+ flushStatics();
73
+ segments.push(this.compileExpression(attribute.argument));
74
+ return;
75
+ }
76
+ const name = this.compileAttributeName(attribute.name);
77
+ let value;
78
+ if (!attribute.value) {
79
+ value = 'true';
80
+ }
81
+ else if (attribute.value.type === 'Literal') {
82
+ value = JSON.stringify(attribute.value.value);
83
+ }
84
+ else if (attribute.value.type === 'JSXExpressionContainer') {
85
+ if (attribute.value.expression.type === 'JSXEmptyExpression') {
86
+ return;
87
+ }
88
+ value = this.compileExpression(attribute.value.expression);
89
+ }
90
+ else {
91
+ value = 'undefined';
92
+ }
93
+ staticEntries.push(`${JSON.stringify(name)}: ${value}`);
94
+ });
95
+ flushStatics();
96
+ if (!segments.length) {
97
+ return 'null';
98
+ }
99
+ if (segments.length === 1) {
100
+ return segments[0];
101
+ }
102
+ return `__jsxReactMergeProps(${segments.join(', ')})`;
103
+ }
104
+ compileAttributeName(name) {
105
+ switch (name.type) {
106
+ case 'JSXIdentifier':
107
+ return name.name;
108
+ case 'JSXNamespacedName':
109
+ return `${name.namespace.name}:${name.name.name}`;
110
+ case 'JSXMemberExpression':
111
+ return `${this.compileAttributeName(name.object)}.${name.property.name}`;
112
+ default:
113
+ /* c8 ignore next */
114
+ return '';
115
+ }
116
+ }
117
+ compileTagName(name) {
118
+ if (!name) {
119
+ /* c8 ignore next */
120
+ throw new Error('[jsx-loader] Encountered JSX element without a tag name.');
121
+ }
122
+ if (name.type === 'JSXIdentifier') {
123
+ if (isLoaderPlaceholderIdentifier(name) && name.name) {
124
+ const resolved = this.placeholderMap.get(name.name);
125
+ if (!resolved) {
126
+ /* c8 ignore next 3 */
127
+ throw new Error('[jsx-loader] Unable to resolve placeholder for tag expression.');
128
+ }
129
+ return resolved;
130
+ }
131
+ if (/^[A-Z]/.test(name.name)) {
132
+ return name.name;
133
+ }
134
+ return JSON.stringify(name.name);
135
+ }
136
+ if (name.type === 'JSXMemberExpression') {
137
+ const object = this.compileTagName(name.object);
138
+ return `${object}.${name.property.name}`;
139
+ }
140
+ if (name.type === 'JSXNamespacedName') {
141
+ return JSON.stringify(`${name.namespace.name}:${name.name.name}`);
142
+ }
143
+ /* c8 ignore next */
144
+ throw new Error('[jsx-loader] Unsupported tag expression in react mode.');
145
+ }
146
+ compileExpression(node) {
147
+ if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
148
+ return this.compileNode(node);
149
+ }
150
+ if (node.type === 'Identifier') {
151
+ const resolved = this.placeholderMap.get(node.name);
152
+ if (resolved) {
153
+ return resolved;
154
+ }
155
+ return node.name;
156
+ }
157
+ if ('range' in node && Array.isArray(node.range)) {
158
+ throw new Error('[jsx-loader] Unable to inline complex expressions in react mode.');
159
+ }
160
+ /* c8 ignore next */
161
+ throw new Error('[jsx-loader] Unable to compile expression for react mode.');
162
+ }
163
+ buildCreateElement(type, props, children) {
164
+ const args = [type, props];
165
+ if (children.length) {
166
+ args.push(children.join(', '));
167
+ }
168
+ return `__jsxReact(${args.join(', ')})`;
169
+ }
170
+ }
9
171
  const stripTrailingWhitespace = (value) => value.replace(/\s+$/g, '');
10
172
  const stripLeadingWhitespace = (value) => value.replace(/^\s+/g, '');
11
173
  const getTemplateExpressionContext = (left, right) => {
@@ -49,6 +211,24 @@ const TEMPLATE_PARSER_OPTIONS = {
49
211
  preserveParens: true,
50
212
  };
51
213
  const DEFAULT_TAGS = ['jsx', 'reactJsx'];
214
+ const DEFAULT_MODE = 'runtime';
215
+ const HELPER_SNIPPETS = {
216
+ react: `const __jsxReactMergeProps = (...sources) => Object.assign({}, ...sources)
217
+ const __jsxReact = (type, props, ...children) => React.createElement(type, props, ...children)
218
+ `,
219
+ };
220
+ const parseLoaderMode = (value) => {
221
+ if (typeof value !== 'string') {
222
+ return null;
223
+ }
224
+ switch (value) {
225
+ case 'runtime':
226
+ case 'react':
227
+ return value;
228
+ default:
229
+ return null;
230
+ }
231
+ };
52
232
  const escapeTemplateChunk = (chunk) => chunk.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${');
53
233
  const formatParserError = (error) => {
54
234
  let message = `[jsx-loader] ${error.message}`;
@@ -87,10 +267,12 @@ const walkAst = (node, visitor) => {
87
267
  const shouldInterpolateName = (name) => /^[A-Z]/.test(name.name);
88
268
  const addSlot = (slots, source, range) => {
89
269
  if (!range) {
270
+ /* c8 ignore next */
90
271
  return;
91
272
  }
92
273
  const [start, end] = range;
93
274
  if (start === end) {
275
+ /* c8 ignore next */
94
276
  return;
95
277
  }
96
278
  slots.push({
@@ -118,6 +300,7 @@ const collectSlots = (program, source) => {
118
300
  break;
119
301
  }
120
302
  default:
303
+ /* c8 ignore next */
121
304
  break;
122
305
  }
123
306
  };
@@ -170,6 +353,7 @@ const renderTemplateWithSlots = (source, slots) => {
170
353
  let output = '';
171
354
  slots.forEach(slot => {
172
355
  if (slot.start < cursor) {
356
+ /* c8 ignore next */
173
357
  throw new Error('Overlapping JSX expressions detected inside template literal.');
174
358
  }
175
359
  output += escapeTemplateChunk(source.slice(cursor, slot.start));
@@ -197,6 +381,22 @@ const getTaggedTemplateName = (node) => {
197
381
  }
198
382
  return tagNode.name;
199
383
  };
384
+ const extractJsxRoot = (program) => {
385
+ for (const statement of program.body) {
386
+ if (statement.type === 'ExpressionStatement') {
387
+ const expression = statement.expression;
388
+ if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
389
+ return expression;
390
+ }
391
+ }
392
+ }
393
+ throw new Error('[jsx-loader] Expected the template to contain a single JSX root node.');
394
+ };
395
+ const normalizeJsxTextValue = (value) => {
396
+ const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
397
+ const trimmed = collapsed.trim();
398
+ return trimmed.length > 0 ? trimmed : '';
399
+ };
200
400
  const TAG_PLACEHOLDER_PREFIX = '__JSX_LOADER_TAG_EXPR_';
201
401
  const buildTemplateSource = (quasis, expressions, source, tag) => {
202
402
  const placeholderMap = new Map();
@@ -223,6 +423,7 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
223
423
  quasis.forEach((quasi, index) => {
224
424
  let chunk = quasi.value.cooked;
225
425
  if (typeof chunk !== 'string') {
426
+ /* c8 ignore next */
226
427
  chunk = quasi.value.raw ?? '';
227
428
  }
228
429
  if (trimStartNext > 0) {
@@ -237,6 +438,7 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
237
438
  const start = expression.start ?? null;
238
439
  const end = expression.end ?? null;
239
440
  if (start === null || end === null) {
441
+ /* c8 ignore next */
240
442
  throw new Error('Unable to read template expression source range.');
241
443
  }
242
444
  const nextChunk = quasis[index + 1];
@@ -293,8 +495,20 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
293
495
  const restoreTemplatePlaceholders = (code, placeholders) => placeholders.reduce((result, placeholder) => {
294
496
  return result.split(placeholder.marker).join(`\${${placeholder.code}}`);
295
497
  }, code);
498
+ const compileReactTemplate = (templateSource, placeholders, resourcePath) => {
499
+ const parsed = (0, oxc_parser_1.parseSync)(`${resourcePath}?jsx-react-template`, templateSource, TEMPLATE_PARSER_OPTIONS);
500
+ if (parsed.errors.length > 0) {
501
+ throw new Error(formatParserError(parsed.errors[0]));
502
+ }
503
+ const root = extractJsxRoot(parsed.program);
504
+ const builder = new ReactTemplateBuilder(placeholders);
505
+ return builder.compile(root);
506
+ };
296
507
  const isLoaderPlaceholderIdentifier = (node) => {
297
- if (node?.type !== 'Identifier' || typeof node.name !== 'string') {
508
+ if (!node ||
509
+ (node.type !== 'Identifier' && node.type !== 'JSXIdentifier') ||
510
+ typeof node.name !== 'string') {
511
+ /* c8 ignore next */
298
512
  return false;
299
513
  }
300
514
  return (node.name.startsWith(TEMPLATE_EXPR_PLACEHOLDER_PREFIX) ||
@@ -313,28 +527,47 @@ const transformSource = (source, config) => {
313
527
  }
314
528
  });
315
529
  if (!taggedTemplates.length) {
316
- return source;
530
+ return { code: source, helpers: [] };
317
531
  }
318
532
  const magic = new magic_string_1.default(source);
319
533
  let mutated = false;
534
+ const helperKinds = new Set();
320
535
  taggedTemplates
321
536
  .sort((a, b) => b.node.start - a.node.start)
322
537
  .forEach(entry => {
323
538
  const { node, tagName } = entry;
539
+ const mode = config.tagModes.get(tagName) ?? DEFAULT_MODE;
324
540
  const quasi = node.quasi;
325
541
  const templateSource = buildTemplateSource(quasi.quasis, quasi.expressions, source, tagName);
326
- const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath);
327
- const restored = restoreTemplatePlaceholders(code, templateSource.placeholders);
328
- const templateChanged = changed || templateSource.mutated;
329
- if (!templateChanged) {
542
+ if (mode === 'runtime') {
543
+ const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath);
544
+ const restored = restoreTemplatePlaceholders(code, templateSource.placeholders);
545
+ const templateChanged = changed || templateSource.mutated;
546
+ if (!templateChanged) {
547
+ return;
548
+ }
549
+ const tagSource = source.slice(node.tag.start, node.tag.end);
550
+ const replacement = `${tagSource}\`${restored}\``;
551
+ magic.overwrite(node.start, node.end, replacement);
552
+ mutated = true;
553
+ return;
554
+ }
555
+ if (mode === 'react') {
556
+ const compiled = compileReactTemplate(templateSource.source, templateSource.placeholders, config.resourcePath);
557
+ helperKinds.add('react');
558
+ magic.overwrite(node.start, node.end, compiled);
559
+ mutated = true;
330
560
  return;
331
561
  }
332
- const tagSource = source.slice(node.tag.start, node.tag.end);
333
- const replacement = `${tagSource}\`${restored}\``;
334
- magic.overwrite(node.start, node.end, replacement);
335
- mutated = true;
562
+ /* c8 ignore next */
563
+ throw new Error(`[jsx-loader] Transformation mode "${mode}" not implemented yet for tag "${tagName}".`);
336
564
  });
337
- return mutated ? magic.toString() : source;
565
+ return {
566
+ code: mutated ? magic.toString() : source,
567
+ helpers: Array.from(helperKinds)
568
+ .map(kind => HELPER_SNIPPETS[kind])
569
+ .filter(Boolean),
570
+ };
338
571
  };
339
572
  function jsxLoader(input) {
340
573
  const callback = this.async();
@@ -349,13 +582,37 @@ function jsxLoader(input) {
349
582
  : legacyTag
350
583
  ? [legacyTag]
351
584
  : DEFAULT_TAGS;
352
- const tags = Array.from(new Set(tagList));
585
+ const tagModes = new Map();
586
+ const configuredTagModes = options.tagModes && typeof options.tagModes === 'object'
587
+ ? options.tagModes
588
+ : undefined;
589
+ if (configuredTagModes) {
590
+ Object.entries(configuredTagModes).forEach(([tagName, mode]) => {
591
+ const parsed = parseLoaderMode(mode);
592
+ if (!parsed || typeof tagName !== 'string' || !tagName.length) {
593
+ return;
594
+ }
595
+ tagModes.set(tagName, parsed);
596
+ });
597
+ }
598
+ const defaultMode = parseLoaderMode(options.mode) ?? DEFAULT_MODE;
599
+ const tags = Array.from(new Set([...tagList, ...tagModes.keys()]));
600
+ tags.forEach(tagName => {
601
+ if (!tagModes.has(tagName)) {
602
+ tagModes.set(tagName, defaultMode);
603
+ }
604
+ });
353
605
  const source = typeof input === 'string' ? input : input.toString('utf8');
354
- const output = transformSource(source, {
606
+ const { code, helpers } = transformSource(source, {
355
607
  resourcePath: this.resourcePath,
356
608
  tags,
609
+ tagModes,
357
610
  });
358
- callback(null, output);
611
+ if (helpers.length) {
612
+ callback(null, `${code}\n${helpers.join('\n')}`);
613
+ return;
614
+ }
615
+ callback(null, code);
359
616
  }
360
617
  catch (error) {
361
618
  callback(error);
@@ -4,16 +4,26 @@ type LoaderContext<TOptions> = {
4
4
  async(): LoaderCallback;
5
5
  getOptions?: () => Partial<TOptions>;
6
6
  };
7
+ type LoaderMode = 'runtime' | 'react';
7
8
  type LoaderOptions = {
8
9
  /**
9
10
  * Name of the tagged template function. Defaults to `jsx`.
10
11
  * Deprecated in favor of `tags`.
12
+ * @deprecated Use `tags` instead.
11
13
  */
12
14
  tag?: string;
13
15
  /**
14
16
  * List of tagged template function names to transform. Defaults to `['jsx', 'reactJsx']`.
15
17
  */
16
18
  tags?: string[];
19
+ /**
20
+ * Global transformation mode for every tag. Defaults to `runtime`.
21
+ */
22
+ mode?: LoaderMode;
23
+ /**
24
+ * Optional per-tag override of the transformation mode. Keys map to tag names.
25
+ */
26
+ tagModes?: Record<string, LoaderMode | undefined>;
17
27
  };
18
28
  export default function jsxLoader(this: LoaderContext<LoaderOptions>, input: string | Buffer): void;
19
29
  export {};
@@ -4,16 +4,26 @@ type LoaderContext<TOptions> = {
4
4
  async(): LoaderCallback;
5
5
  getOptions?: () => Partial<TOptions>;
6
6
  };
7
+ type LoaderMode = 'runtime' | 'react';
7
8
  type LoaderOptions = {
8
9
  /**
9
10
  * Name of the tagged template function. Defaults to `jsx`.
10
11
  * Deprecated in favor of `tags`.
12
+ * @deprecated Use `tags` instead.
11
13
  */
12
14
  tag?: string;
13
15
  /**
14
16
  * List of tagged template function names to transform. Defaults to `['jsx', 'reactJsx']`.
15
17
  */
16
18
  tags?: string[];
19
+ /**
20
+ * Global transformation mode for every tag. Defaults to `runtime`.
21
+ */
22
+ mode?: LoaderMode;
23
+ /**
24
+ * Optional per-tag override of the transformation mode. Keys map to tag names.
25
+ */
26
+ tagModes?: Record<string, LoaderMode | undefined>;
17
27
  };
18
28
  export default function jsxLoader(this: LoaderContext<LoaderOptions>, input: string | Buffer): void;
19
29
  export {};
@@ -1,5 +1,167 @@
1
1
  import MagicString from 'magic-string';
2
2
  import { parseSync } from 'oxc-parser';
3
+ const createPlaceholderMap = (placeholders) => new Map(placeholders.map(entry => [entry.marker, entry.code]));
4
+ class ReactTemplateBuilder {
5
+ placeholderMap;
6
+ constructor(placeholderSource) {
7
+ this.placeholderMap = createPlaceholderMap(placeholderSource);
8
+ }
9
+ compile(node) {
10
+ return this.compileNode(node);
11
+ }
12
+ compileNode(node) {
13
+ if (node.type === 'JSXFragment') {
14
+ const children = this.compileChildren(node.children);
15
+ return this.buildCreateElement('React.Fragment', 'null', children);
16
+ }
17
+ const opening = node.openingElement;
18
+ const tagExpr = this.compileTagName(opening.name);
19
+ const propsExpr = this.compileProps(opening.attributes);
20
+ const children = this.compileChildren(node.children);
21
+ return this.buildCreateElement(tagExpr, propsExpr, children);
22
+ }
23
+ compileChildren(children) {
24
+ const compiled = [];
25
+ children.forEach(child => {
26
+ switch (child.type) {
27
+ case 'JSXText': {
28
+ const text = normalizeJsxTextValue(child.value);
29
+ if (text) {
30
+ compiled.push(JSON.stringify(text));
31
+ }
32
+ break;
33
+ }
34
+ case 'JSXExpressionContainer': {
35
+ if (child.expression.type === 'JSXEmptyExpression') {
36
+ break;
37
+ }
38
+ compiled.push(this.compileExpression(child.expression));
39
+ break;
40
+ }
41
+ case 'JSXSpreadChild': {
42
+ compiled.push(this.compileExpression(child.expression));
43
+ break;
44
+ }
45
+ case 'JSXElement':
46
+ case 'JSXFragment': {
47
+ compiled.push(this.compileNode(child));
48
+ break;
49
+ }
50
+ }
51
+ });
52
+ return compiled;
53
+ }
54
+ compileProps(attributes) {
55
+ const segments = [];
56
+ let staticEntries = [];
57
+ const flushStatics = () => {
58
+ if (!staticEntries.length) {
59
+ return;
60
+ }
61
+ segments.push(`{ ${staticEntries.join(', ')} }`);
62
+ staticEntries = [];
63
+ };
64
+ attributes.forEach(attribute => {
65
+ if (attribute.type === 'JSXSpreadAttribute') {
66
+ flushStatics();
67
+ segments.push(this.compileExpression(attribute.argument));
68
+ return;
69
+ }
70
+ const name = this.compileAttributeName(attribute.name);
71
+ let value;
72
+ if (!attribute.value) {
73
+ value = 'true';
74
+ }
75
+ else if (attribute.value.type === 'Literal') {
76
+ value = JSON.stringify(attribute.value.value);
77
+ }
78
+ else if (attribute.value.type === 'JSXExpressionContainer') {
79
+ if (attribute.value.expression.type === 'JSXEmptyExpression') {
80
+ return;
81
+ }
82
+ value = this.compileExpression(attribute.value.expression);
83
+ }
84
+ else {
85
+ value = 'undefined';
86
+ }
87
+ staticEntries.push(`${JSON.stringify(name)}: ${value}`);
88
+ });
89
+ flushStatics();
90
+ if (!segments.length) {
91
+ return 'null';
92
+ }
93
+ if (segments.length === 1) {
94
+ return segments[0];
95
+ }
96
+ return `__jsxReactMergeProps(${segments.join(', ')})`;
97
+ }
98
+ compileAttributeName(name) {
99
+ switch (name.type) {
100
+ case 'JSXIdentifier':
101
+ return name.name;
102
+ case 'JSXNamespacedName':
103
+ return `${name.namespace.name}:${name.name.name}`;
104
+ case 'JSXMemberExpression':
105
+ return `${this.compileAttributeName(name.object)}.${name.property.name}`;
106
+ default:
107
+ /* c8 ignore next */
108
+ return '';
109
+ }
110
+ }
111
+ compileTagName(name) {
112
+ if (!name) {
113
+ /* c8 ignore next */
114
+ throw new Error('[jsx-loader] Encountered JSX element without a tag name.');
115
+ }
116
+ if (name.type === 'JSXIdentifier') {
117
+ if (isLoaderPlaceholderIdentifier(name) && name.name) {
118
+ const resolved = this.placeholderMap.get(name.name);
119
+ if (!resolved) {
120
+ /* c8 ignore next 3 */
121
+ throw new Error('[jsx-loader] Unable to resolve placeholder for tag expression.');
122
+ }
123
+ return resolved;
124
+ }
125
+ if (/^[A-Z]/.test(name.name)) {
126
+ return name.name;
127
+ }
128
+ return JSON.stringify(name.name);
129
+ }
130
+ if (name.type === 'JSXMemberExpression') {
131
+ const object = this.compileTagName(name.object);
132
+ return `${object}.${name.property.name}`;
133
+ }
134
+ if (name.type === 'JSXNamespacedName') {
135
+ return JSON.stringify(`${name.namespace.name}:${name.name.name}`);
136
+ }
137
+ /* c8 ignore next */
138
+ throw new Error('[jsx-loader] Unsupported tag expression in react mode.');
139
+ }
140
+ compileExpression(node) {
141
+ if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
142
+ return this.compileNode(node);
143
+ }
144
+ if (node.type === 'Identifier') {
145
+ const resolved = this.placeholderMap.get(node.name);
146
+ if (resolved) {
147
+ return resolved;
148
+ }
149
+ return node.name;
150
+ }
151
+ if ('range' in node && Array.isArray(node.range)) {
152
+ throw new Error('[jsx-loader] Unable to inline complex expressions in react mode.');
153
+ }
154
+ /* c8 ignore next */
155
+ throw new Error('[jsx-loader] Unable to compile expression for react mode.');
156
+ }
157
+ buildCreateElement(type, props, children) {
158
+ const args = [type, props];
159
+ if (children.length) {
160
+ args.push(children.join(', '));
161
+ }
162
+ return `__jsxReact(${args.join(', ')})`;
163
+ }
164
+ }
3
165
  const stripTrailingWhitespace = (value) => value.replace(/\s+$/g, '');
4
166
  const stripLeadingWhitespace = (value) => value.replace(/^\s+/g, '');
5
167
  const getTemplateExpressionContext = (left, right) => {
@@ -43,6 +205,24 @@ const TEMPLATE_PARSER_OPTIONS = {
43
205
  preserveParens: true,
44
206
  };
45
207
  const DEFAULT_TAGS = ['jsx', 'reactJsx'];
208
+ const DEFAULT_MODE = 'runtime';
209
+ const HELPER_SNIPPETS = {
210
+ react: `const __jsxReactMergeProps = (...sources) => Object.assign({}, ...sources)
211
+ const __jsxReact = (type, props, ...children) => React.createElement(type, props, ...children)
212
+ `,
213
+ };
214
+ const parseLoaderMode = (value) => {
215
+ if (typeof value !== 'string') {
216
+ return null;
217
+ }
218
+ switch (value) {
219
+ case 'runtime':
220
+ case 'react':
221
+ return value;
222
+ default:
223
+ return null;
224
+ }
225
+ };
46
226
  const escapeTemplateChunk = (chunk) => chunk.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${');
47
227
  const formatParserError = (error) => {
48
228
  let message = `[jsx-loader] ${error.message}`;
@@ -81,10 +261,12 @@ const walkAst = (node, visitor) => {
81
261
  const shouldInterpolateName = (name) => /^[A-Z]/.test(name.name);
82
262
  const addSlot = (slots, source, range) => {
83
263
  if (!range) {
264
+ /* c8 ignore next */
84
265
  return;
85
266
  }
86
267
  const [start, end] = range;
87
268
  if (start === end) {
269
+ /* c8 ignore next */
88
270
  return;
89
271
  }
90
272
  slots.push({
@@ -112,6 +294,7 @@ const collectSlots = (program, source) => {
112
294
  break;
113
295
  }
114
296
  default:
297
+ /* c8 ignore next */
115
298
  break;
116
299
  }
117
300
  };
@@ -164,6 +347,7 @@ const renderTemplateWithSlots = (source, slots) => {
164
347
  let output = '';
165
348
  slots.forEach(slot => {
166
349
  if (slot.start < cursor) {
350
+ /* c8 ignore next */
167
351
  throw new Error('Overlapping JSX expressions detected inside template literal.');
168
352
  }
169
353
  output += escapeTemplateChunk(source.slice(cursor, slot.start));
@@ -191,6 +375,22 @@ const getTaggedTemplateName = (node) => {
191
375
  }
192
376
  return tagNode.name;
193
377
  };
378
+ const extractJsxRoot = (program) => {
379
+ for (const statement of program.body) {
380
+ if (statement.type === 'ExpressionStatement') {
381
+ const expression = statement.expression;
382
+ if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
383
+ return expression;
384
+ }
385
+ }
386
+ }
387
+ throw new Error('[jsx-loader] Expected the template to contain a single JSX root node.');
388
+ };
389
+ const normalizeJsxTextValue = (value) => {
390
+ const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
391
+ const trimmed = collapsed.trim();
392
+ return trimmed.length > 0 ? trimmed : '';
393
+ };
194
394
  const TAG_PLACEHOLDER_PREFIX = '__JSX_LOADER_TAG_EXPR_';
195
395
  const buildTemplateSource = (quasis, expressions, source, tag) => {
196
396
  const placeholderMap = new Map();
@@ -217,6 +417,7 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
217
417
  quasis.forEach((quasi, index) => {
218
418
  let chunk = quasi.value.cooked;
219
419
  if (typeof chunk !== 'string') {
420
+ /* c8 ignore next */
220
421
  chunk = quasi.value.raw ?? '';
221
422
  }
222
423
  if (trimStartNext > 0) {
@@ -231,6 +432,7 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
231
432
  const start = expression.start ?? null;
232
433
  const end = expression.end ?? null;
233
434
  if (start === null || end === null) {
435
+ /* c8 ignore next */
234
436
  throw new Error('Unable to read template expression source range.');
235
437
  }
236
438
  const nextChunk = quasis[index + 1];
@@ -287,8 +489,20 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
287
489
  const restoreTemplatePlaceholders = (code, placeholders) => placeholders.reduce((result, placeholder) => {
288
490
  return result.split(placeholder.marker).join(`\${${placeholder.code}}`);
289
491
  }, code);
492
+ const compileReactTemplate = (templateSource, placeholders, resourcePath) => {
493
+ const parsed = parseSync(`${resourcePath}?jsx-react-template`, templateSource, TEMPLATE_PARSER_OPTIONS);
494
+ if (parsed.errors.length > 0) {
495
+ throw new Error(formatParserError(parsed.errors[0]));
496
+ }
497
+ const root = extractJsxRoot(parsed.program);
498
+ const builder = new ReactTemplateBuilder(placeholders);
499
+ return builder.compile(root);
500
+ };
290
501
  const isLoaderPlaceholderIdentifier = (node) => {
291
- if (node?.type !== 'Identifier' || typeof node.name !== 'string') {
502
+ if (!node ||
503
+ (node.type !== 'Identifier' && node.type !== 'JSXIdentifier') ||
504
+ typeof node.name !== 'string') {
505
+ /* c8 ignore next */
292
506
  return false;
293
507
  }
294
508
  return (node.name.startsWith(TEMPLATE_EXPR_PLACEHOLDER_PREFIX) ||
@@ -307,28 +521,47 @@ const transformSource = (source, config) => {
307
521
  }
308
522
  });
309
523
  if (!taggedTemplates.length) {
310
- return source;
524
+ return { code: source, helpers: [] };
311
525
  }
312
526
  const magic = new MagicString(source);
313
527
  let mutated = false;
528
+ const helperKinds = new Set();
314
529
  taggedTemplates
315
530
  .sort((a, b) => b.node.start - a.node.start)
316
531
  .forEach(entry => {
317
532
  const { node, tagName } = entry;
533
+ const mode = config.tagModes.get(tagName) ?? DEFAULT_MODE;
318
534
  const quasi = node.quasi;
319
535
  const templateSource = buildTemplateSource(quasi.quasis, quasi.expressions, source, tagName);
320
- const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath);
321
- const restored = restoreTemplatePlaceholders(code, templateSource.placeholders);
322
- const templateChanged = changed || templateSource.mutated;
323
- if (!templateChanged) {
536
+ if (mode === 'runtime') {
537
+ const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath);
538
+ const restored = restoreTemplatePlaceholders(code, templateSource.placeholders);
539
+ const templateChanged = changed || templateSource.mutated;
540
+ if (!templateChanged) {
541
+ return;
542
+ }
543
+ const tagSource = source.slice(node.tag.start, node.tag.end);
544
+ const replacement = `${tagSource}\`${restored}\``;
545
+ magic.overwrite(node.start, node.end, replacement);
546
+ mutated = true;
547
+ return;
548
+ }
549
+ if (mode === 'react') {
550
+ const compiled = compileReactTemplate(templateSource.source, templateSource.placeholders, config.resourcePath);
551
+ helperKinds.add('react');
552
+ magic.overwrite(node.start, node.end, compiled);
553
+ mutated = true;
324
554
  return;
325
555
  }
326
- const tagSource = source.slice(node.tag.start, node.tag.end);
327
- const replacement = `${tagSource}\`${restored}\``;
328
- magic.overwrite(node.start, node.end, replacement);
329
- mutated = true;
556
+ /* c8 ignore next */
557
+ throw new Error(`[jsx-loader] Transformation mode "${mode}" not implemented yet for tag "${tagName}".`);
330
558
  });
331
- return mutated ? magic.toString() : source;
559
+ return {
560
+ code: mutated ? magic.toString() : source,
561
+ helpers: Array.from(helperKinds)
562
+ .map(kind => HELPER_SNIPPETS[kind])
563
+ .filter(Boolean),
564
+ };
332
565
  };
333
566
  export default function jsxLoader(input) {
334
567
  const callback = this.async();
@@ -343,13 +576,37 @@ export default function jsxLoader(input) {
343
576
  : legacyTag
344
577
  ? [legacyTag]
345
578
  : DEFAULT_TAGS;
346
- const tags = Array.from(new Set(tagList));
579
+ const tagModes = new Map();
580
+ const configuredTagModes = options.tagModes && typeof options.tagModes === 'object'
581
+ ? options.tagModes
582
+ : undefined;
583
+ if (configuredTagModes) {
584
+ Object.entries(configuredTagModes).forEach(([tagName, mode]) => {
585
+ const parsed = parseLoaderMode(mode);
586
+ if (!parsed || typeof tagName !== 'string' || !tagName.length) {
587
+ return;
588
+ }
589
+ tagModes.set(tagName, parsed);
590
+ });
591
+ }
592
+ const defaultMode = parseLoaderMode(options.mode) ?? DEFAULT_MODE;
593
+ const tags = Array.from(new Set([...tagList, ...tagModes.keys()]));
594
+ tags.forEach(tagName => {
595
+ if (!tagModes.has(tagName)) {
596
+ tagModes.set(tagName, defaultMode);
597
+ }
598
+ });
347
599
  const source = typeof input === 'string' ? input : input.toString('utf8');
348
- const output = transformSource(source, {
600
+ const { code, helpers } = transformSource(source, {
349
601
  resourcePath: this.resourcePath,
350
602
  tags,
603
+ tagModes,
351
604
  });
352
- callback(null, output);
605
+ if (helpers.length) {
606
+ callback(null, `${code}\n${helpers.join('\n')}`);
607
+ return;
608
+ }
609
+ callback(null, code);
353
610
  }
354
611
  catch (error) {
355
612
  callback(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knighted/jsx",
3
- "version": "1.2.0-rc.1",
3
+ "version": "1.2.0-rc.2",
4
4
  "description": "Runtime JSX tagged template that renders DOM or React trees anywhere without a build step.",
5
5
  "keywords": [
6
6
  "jsx runtime",