@knighted/jsx 1.9.0 → 1.9.1

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
@@ -94,6 +94,21 @@ transpileJsxSource(input, {
94
94
  })
95
95
  ```
96
96
 
97
+ By default, TypeScript syntax is preserved in the output. If your source needs to run directly
98
+ as JavaScript (for example, code entered in an editor), enable type stripping:
99
+
100
+ ```ts
101
+ transpileJsxSource(input, {
102
+ typescript: 'strip',
103
+ })
104
+ ```
105
+
106
+ Supported `typescript` modes:
107
+
108
+ - `'preserve'` (default): keep TypeScript syntax in output.
109
+ - `'strip'`: remove type-only declarations and erase inline type syntax (`: T`, `as T`,
110
+ `satisfies T`, non-null assertions, and type assertions) while still transpiling JSX.
111
+
97
112
  ### React runtime (`reactJsx`)
98
113
 
99
114
  Need to compose React elements instead of DOM nodes? Import the dedicated helper from the `@knighted/jsx/react` subpath (React 18+ and `react-dom` are still required to mount the tree):
@@ -35,15 +35,24 @@ const isSourceRange = (value) => Array.isArray(value) &&
35
35
  typeof value[0] === 'number' &&
36
36
  typeof value[1] === 'number';
37
37
  const hasSourceRange = (value) => isObjectRecord(value) && isSourceRange(value.range);
38
+ const tsWrapperExpressionNodeTypes = new Set([
39
+ 'TSAsExpression',
40
+ 'TSSatisfiesExpression',
41
+ 'TSInstantiationExpression',
42
+ 'TSNonNullExpression',
43
+ 'TSTypeAssertion',
44
+ ]);
38
45
  const compareByRangeStartDesc = (first, second) => second.range[0] - first.range[0];
39
46
  class SourceJsxReactBuilder {
40
47
  source;
41
48
  createElementRef;
42
49
  fragmentRef;
43
- constructor(source, createElementRef, fragmentRef) {
50
+ stripTypes;
51
+ constructor(source, createElementRef, fragmentRef, stripTypes) {
44
52
  this.source = source;
45
53
  this.createElementRef = createElementRef;
46
54
  this.fragmentRef = fragmentRef;
55
+ this.stripTypes = stripTypes;
47
56
  }
48
57
  compile(node) {
49
58
  return this.compileNode(node);
@@ -179,6 +188,17 @@ class SourceJsxReactBuilder {
179
188
  if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
180
189
  return this.compileNode(node);
181
190
  }
191
+ if (this.stripTypes && isObjectRecord(node)) {
192
+ if ('expression' in node && node.type === 'ParenthesizedExpression') {
193
+ return `(${this.compileExpression(node.expression)})`;
194
+ }
195
+ if ('expression' in node &&
196
+ typeof node.type === 'string' &&
197
+ tsWrapperExpressionNodeTypes.has(node.type)) {
198
+ return this.compileExpression(node.expression);
199
+ }
200
+ }
201
+ /* c8 ignore next 3 -- defensive guard for malformed external AST nodes */
182
202
  if (!hasSourceRange(node)) {
183
203
  throw new Error('[jsx] Unable to read source range for expression node.');
184
204
  }
@@ -230,21 +250,134 @@ const collectRootJsxNodes = (root) => {
230
250
  walk(root, false);
231
251
  return nodes;
232
252
  };
253
+ const hasStringProperty = (value, key) => isObjectRecord(value) && typeof value[key] === 'string';
254
+ const hasSourceAndExpressionRanges = (value) => isObjectRecord(value) &&
255
+ typeof value.type === 'string' &&
256
+ hasSourceRange(value) &&
257
+ 'expression' in value &&
258
+ hasSourceRange(value.expression);
259
+ const isTypeOnlyImportExport = (value) => hasStringProperty(value, 'importKind')
260
+ ? value.importKind === 'type'
261
+ : hasStringProperty(value, 'exportKind') && value.exportKind === 'type';
262
+ const isTypeOnlyNode = (value) => {
263
+ if (!isObjectRecord(value) || typeof value.type !== 'string') {
264
+ return false;
265
+ }
266
+ return [
267
+ 'TSTypeAnnotation',
268
+ 'TSTypeParameterDeclaration',
269
+ 'TSTypeAliasDeclaration',
270
+ 'TSInterfaceDeclaration',
271
+ 'TSDeclareFunction',
272
+ 'TSImportEqualsDeclaration',
273
+ 'TSNamespaceExportDeclaration',
274
+ 'TSModuleDeclaration',
275
+ ].includes(value.type);
276
+ };
277
+ const createStripEditForTsWrapper = (value, source) => {
278
+ if (!hasSourceAndExpressionRanges(value)) {
279
+ return null;
280
+ }
281
+ if (value.type !== 'TSAsExpression' &&
282
+ value.type !== 'TSSatisfiesExpression' &&
283
+ value.type !== 'TSInstantiationExpression' &&
284
+ value.type !== 'TSNonNullExpression' &&
285
+ value.type !== 'TSTypeAssertion') {
286
+ return null;
287
+ }
288
+ const [exprStart, exprEnd] = value.expression.range;
289
+ return {
290
+ range: value.range,
291
+ replacement: source.slice(exprStart, exprEnd),
292
+ };
293
+ };
294
+ const collectTypeScriptStripEdits = (source, root) => {
295
+ const edits = [];
296
+ const walk = (value) => {
297
+ if (!isObjectRecord(value)) {
298
+ return;
299
+ }
300
+ if (Array.isArray(value)) {
301
+ value.forEach(walk);
302
+ return;
303
+ }
304
+ if (hasSourceRange(value)) {
305
+ if (isTypeOnlyNode(value) || isTypeOnlyImportExport(value)) {
306
+ edits.push({ range: value.range });
307
+ return;
308
+ }
309
+ else {
310
+ const wrapperEdit = createStripEditForTsWrapper(value, source);
311
+ if (wrapperEdit) {
312
+ edits.push(wrapperEdit);
313
+ return;
314
+ }
315
+ }
316
+ }
317
+ for (const entry of Object.values(value)) {
318
+ walk(entry);
319
+ }
320
+ };
321
+ walk(root);
322
+ return edits;
323
+ };
324
+ const rangeOverlaps = (first, second) => first[0] < second[1] && second[0] < first[1];
325
+ const compareStripEditPriority = (first, second) => {
326
+ const firstLength = first.range[1] - first.range[0];
327
+ const secondLength = second.range[1] - second.range[0];
328
+ if (firstLength !== secondLength) {
329
+ return secondLength - firstLength;
330
+ }
331
+ return compareByRangeStartDesc(first, second);
332
+ };
333
+ const applyStripEdits = (magic, edits) => {
334
+ if (!edits.length) {
335
+ return false;
336
+ }
337
+ const appliedRanges = [];
338
+ let changed = false;
339
+ edits
340
+ .slice()
341
+ .sort(compareStripEditPriority)
342
+ .forEach(edit => {
343
+ /* c8 ignore next -- overlap handling is defensive after de-duplicated collection */
344
+ if (appliedRanges.some(range => rangeOverlaps(range, edit.range))) {
345
+ return;
346
+ }
347
+ const [start, end] = edit.range;
348
+ if (edit.replacement === undefined) {
349
+ magic.remove(start, end);
350
+ }
351
+ else {
352
+ magic.overwrite(start, end, edit.replacement);
353
+ }
354
+ appliedRanges.push(edit.range);
355
+ changed = true;
356
+ });
357
+ return changed;
358
+ };
233
359
  function transpileJsxSource(source, options = {}) {
234
360
  const sourceType = options.sourceType ?? 'module';
235
361
  const createElementRef = options.createElement ?? 'React.createElement';
236
362
  const fragmentRef = options.fragment ?? 'React.Fragment';
363
+ const typescriptMode = options.typescript ?? 'preserve';
237
364
  const parsed = (0, oxc_parser_1.parseSync)('transpile-jsx-source.tsx', source, createModuleParserOptions(sourceType));
238
365
  const firstError = parsed.errors[0];
239
366
  if (firstError) {
240
367
  throw new Error(formatParserError(firstError));
241
368
  }
369
+ const magic = new magic_string_1.default(source);
370
+ const stripChanged = typescriptMode === 'strip'
371
+ ? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program))
372
+ : false;
242
373
  const jsxRoots = collectRootJsxNodes(parsed.program);
243
374
  if (!jsxRoots.length) {
244
- return { code: source, changed: false };
375
+ return {
376
+ code: stripChanged ? magic.toString() : source,
377
+ changed: stripChanged,
378
+ };
245
379
  }
246
- const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef);
247
- const magic = new magic_string_1.default(source);
380
+ const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef, typescriptMode === 'strip');
248
381
  jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
249
382
  magic.overwrite(node.range[0], node.range[1], builder.compile(node));
250
383
  });
@@ -1,8 +1,10 @@
1
1
  type TranspileSourceType = 'module' | 'script';
2
+ type TranspileTypeScriptMode = 'preserve' | 'strip';
2
3
  export type TranspileJsxSourceOptions = {
3
4
  sourceType?: TranspileSourceType;
4
5
  createElement?: string;
5
6
  fragment?: string;
7
+ typescript?: TranspileTypeScriptMode;
6
8
  };
7
9
  export type TranspileJsxSourceResult = {
8
10
  code: string;
@@ -1,8 +1,10 @@
1
1
  type TranspileSourceType = 'module' | 'script';
2
+ type TranspileTypeScriptMode = 'preserve' | 'strip';
2
3
  export type TranspileJsxSourceOptions = {
3
4
  sourceType?: TranspileSourceType;
4
5
  createElement?: string;
5
6
  fragment?: string;
7
+ typescript?: TranspileTypeScriptMode;
6
8
  };
7
9
  export type TranspileJsxSourceResult = {
8
10
  code: string;
package/dist/transpile.js CHANGED
@@ -29,15 +29,24 @@ const isSourceRange = (value) => Array.isArray(value) &&
29
29
  typeof value[0] === 'number' &&
30
30
  typeof value[1] === 'number';
31
31
  const hasSourceRange = (value) => isObjectRecord(value) && isSourceRange(value.range);
32
+ const tsWrapperExpressionNodeTypes = new Set([
33
+ 'TSAsExpression',
34
+ 'TSSatisfiesExpression',
35
+ 'TSInstantiationExpression',
36
+ 'TSNonNullExpression',
37
+ 'TSTypeAssertion',
38
+ ]);
32
39
  const compareByRangeStartDesc = (first, second) => second.range[0] - first.range[0];
33
40
  class SourceJsxReactBuilder {
34
41
  source;
35
42
  createElementRef;
36
43
  fragmentRef;
37
- constructor(source, createElementRef, fragmentRef) {
44
+ stripTypes;
45
+ constructor(source, createElementRef, fragmentRef, stripTypes) {
38
46
  this.source = source;
39
47
  this.createElementRef = createElementRef;
40
48
  this.fragmentRef = fragmentRef;
49
+ this.stripTypes = stripTypes;
41
50
  }
42
51
  compile(node) {
43
52
  return this.compileNode(node);
@@ -173,6 +182,17 @@ class SourceJsxReactBuilder {
173
182
  if (node.type === 'JSXElement' || node.type === 'JSXFragment') {
174
183
  return this.compileNode(node);
175
184
  }
185
+ if (this.stripTypes && isObjectRecord(node)) {
186
+ if ('expression' in node && node.type === 'ParenthesizedExpression') {
187
+ return `(${this.compileExpression(node.expression)})`;
188
+ }
189
+ if ('expression' in node &&
190
+ typeof node.type === 'string' &&
191
+ tsWrapperExpressionNodeTypes.has(node.type)) {
192
+ return this.compileExpression(node.expression);
193
+ }
194
+ }
195
+ /* c8 ignore next 3 -- defensive guard for malformed external AST nodes */
176
196
  if (!hasSourceRange(node)) {
177
197
  throw new Error('[jsx] Unable to read source range for expression node.');
178
198
  }
@@ -224,21 +244,134 @@ const collectRootJsxNodes = (root) => {
224
244
  walk(root, false);
225
245
  return nodes;
226
246
  };
247
+ const hasStringProperty = (value, key) => isObjectRecord(value) && typeof value[key] === 'string';
248
+ const hasSourceAndExpressionRanges = (value) => isObjectRecord(value) &&
249
+ typeof value.type === 'string' &&
250
+ hasSourceRange(value) &&
251
+ 'expression' in value &&
252
+ hasSourceRange(value.expression);
253
+ const isTypeOnlyImportExport = (value) => hasStringProperty(value, 'importKind')
254
+ ? value.importKind === 'type'
255
+ : hasStringProperty(value, 'exportKind') && value.exportKind === 'type';
256
+ const isTypeOnlyNode = (value) => {
257
+ if (!isObjectRecord(value) || typeof value.type !== 'string') {
258
+ return false;
259
+ }
260
+ return [
261
+ 'TSTypeAnnotation',
262
+ 'TSTypeParameterDeclaration',
263
+ 'TSTypeAliasDeclaration',
264
+ 'TSInterfaceDeclaration',
265
+ 'TSDeclareFunction',
266
+ 'TSImportEqualsDeclaration',
267
+ 'TSNamespaceExportDeclaration',
268
+ 'TSModuleDeclaration',
269
+ ].includes(value.type);
270
+ };
271
+ const createStripEditForTsWrapper = (value, source) => {
272
+ if (!hasSourceAndExpressionRanges(value)) {
273
+ return null;
274
+ }
275
+ if (value.type !== 'TSAsExpression' &&
276
+ value.type !== 'TSSatisfiesExpression' &&
277
+ value.type !== 'TSInstantiationExpression' &&
278
+ value.type !== 'TSNonNullExpression' &&
279
+ value.type !== 'TSTypeAssertion') {
280
+ return null;
281
+ }
282
+ const [exprStart, exprEnd] = value.expression.range;
283
+ return {
284
+ range: value.range,
285
+ replacement: source.slice(exprStart, exprEnd),
286
+ };
287
+ };
288
+ const collectTypeScriptStripEdits = (source, root) => {
289
+ const edits = [];
290
+ const walk = (value) => {
291
+ if (!isObjectRecord(value)) {
292
+ return;
293
+ }
294
+ if (Array.isArray(value)) {
295
+ value.forEach(walk);
296
+ return;
297
+ }
298
+ if (hasSourceRange(value)) {
299
+ if (isTypeOnlyNode(value) || isTypeOnlyImportExport(value)) {
300
+ edits.push({ range: value.range });
301
+ return;
302
+ }
303
+ else {
304
+ const wrapperEdit = createStripEditForTsWrapper(value, source);
305
+ if (wrapperEdit) {
306
+ edits.push(wrapperEdit);
307
+ return;
308
+ }
309
+ }
310
+ }
311
+ for (const entry of Object.values(value)) {
312
+ walk(entry);
313
+ }
314
+ };
315
+ walk(root);
316
+ return edits;
317
+ };
318
+ const rangeOverlaps = (first, second) => first[0] < second[1] && second[0] < first[1];
319
+ const compareStripEditPriority = (first, second) => {
320
+ const firstLength = first.range[1] - first.range[0];
321
+ const secondLength = second.range[1] - second.range[0];
322
+ if (firstLength !== secondLength) {
323
+ return secondLength - firstLength;
324
+ }
325
+ return compareByRangeStartDesc(first, second);
326
+ };
327
+ const applyStripEdits = (magic, edits) => {
328
+ if (!edits.length) {
329
+ return false;
330
+ }
331
+ const appliedRanges = [];
332
+ let changed = false;
333
+ edits
334
+ .slice()
335
+ .sort(compareStripEditPriority)
336
+ .forEach(edit => {
337
+ /* c8 ignore next -- overlap handling is defensive after de-duplicated collection */
338
+ if (appliedRanges.some(range => rangeOverlaps(range, edit.range))) {
339
+ return;
340
+ }
341
+ const [start, end] = edit.range;
342
+ if (edit.replacement === undefined) {
343
+ magic.remove(start, end);
344
+ }
345
+ else {
346
+ magic.overwrite(start, end, edit.replacement);
347
+ }
348
+ appliedRanges.push(edit.range);
349
+ changed = true;
350
+ });
351
+ return changed;
352
+ };
227
353
  export function transpileJsxSource(source, options = {}) {
228
354
  const sourceType = options.sourceType ?? 'module';
229
355
  const createElementRef = options.createElement ?? 'React.createElement';
230
356
  const fragmentRef = options.fragment ?? 'React.Fragment';
357
+ const typescriptMode = options.typescript ?? 'preserve';
231
358
  const parsed = parseSync('transpile-jsx-source.tsx', source, createModuleParserOptions(sourceType));
232
359
  const firstError = parsed.errors[0];
233
360
  if (firstError) {
234
361
  throw new Error(formatParserError(firstError));
235
362
  }
363
+ const magic = new MagicString(source);
364
+ const stripChanged = typescriptMode === 'strip'
365
+ ? applyStripEdits(magic, collectTypeScriptStripEdits(source, parsed.program))
366
+ : false;
236
367
  const jsxRoots = collectRootJsxNodes(parsed.program);
237
368
  if (!jsxRoots.length) {
238
- return { code: source, changed: false };
369
+ return {
370
+ code: stripChanged ? magic.toString() : source,
371
+ changed: stripChanged,
372
+ };
239
373
  }
240
- const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef);
241
- const magic = new MagicString(source);
374
+ const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef, typescriptMode === 'strip');
242
375
  jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
243
376
  magic.overwrite(node.range[0], node.range[1], builder.compile(node));
244
377
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knighted/jsx",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "Runtime JSX tagged template that renders DOM or React trees anywhere with or without a build step.",
5
5
  "keywords": [
6
6
  "jsx runtime",