@knighted/jsx 1.9.0 → 1.9.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
@@ -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,26 +250,182 @@ const collectRootJsxNodes = (root) => {
230
250
  walk(root, false);
231
251
  return nodes;
232
252
  };
253
+ const MAX_TYPESCRIPT_STRIP_PASSES = 5;
254
+ const hasStringProperty = (value, key) => isObjectRecord(value) && typeof value[key] === 'string';
255
+ const hasSourceAndExpressionRanges = (value) => isObjectRecord(value) &&
256
+ typeof value.type === 'string' &&
257
+ hasSourceRange(value) &&
258
+ 'expression' in value &&
259
+ hasSourceRange(value.expression);
260
+ const isTypeOnlyImportExport = (value) => hasStringProperty(value, 'importKind')
261
+ ? value.importKind === 'type'
262
+ : hasStringProperty(value, 'exportKind') && value.exportKind === 'type';
263
+ const isTypeOnlyNode = (value) => {
264
+ if (!isObjectRecord(value) || typeof value.type !== 'string') {
265
+ return false;
266
+ }
267
+ return [
268
+ 'TSTypeAnnotation',
269
+ 'TSTypeParameterDeclaration',
270
+ 'TSTypeAliasDeclaration',
271
+ 'TSInterfaceDeclaration',
272
+ 'TSDeclareFunction',
273
+ 'TSImportEqualsDeclaration',
274
+ 'TSNamespaceExportDeclaration',
275
+ 'TSModuleDeclaration',
276
+ ].includes(value.type);
277
+ };
278
+ const createStripEditForTsWrapper = (value, source) => {
279
+ if (!hasSourceAndExpressionRanges(value)) {
280
+ return null;
281
+ }
282
+ if (value.type !== 'TSAsExpression' &&
283
+ value.type !== 'TSSatisfiesExpression' &&
284
+ value.type !== 'TSInstantiationExpression' &&
285
+ value.type !== 'TSNonNullExpression' &&
286
+ value.type !== 'TSTypeAssertion') {
287
+ return null;
288
+ }
289
+ const [exprStart, exprEnd] = value.expression.range;
290
+ return {
291
+ range: value.range,
292
+ replacement: source.slice(exprStart, exprEnd),
293
+ };
294
+ };
295
+ const collectTypeScriptStripEdits = (source, root) => {
296
+ const edits = [];
297
+ const walk = (value) => {
298
+ if (!isObjectRecord(value)) {
299
+ return;
300
+ }
301
+ if (Array.isArray(value)) {
302
+ value.forEach(walk);
303
+ return;
304
+ }
305
+ if (hasSourceRange(value)) {
306
+ if (isTypeOnlyNode(value) || isTypeOnlyImportExport(value)) {
307
+ edits.push({ range: value.range });
308
+ return;
309
+ }
310
+ else {
311
+ const wrapperEdit = createStripEditForTsWrapper(value, source);
312
+ if (wrapperEdit) {
313
+ edits.push(wrapperEdit);
314
+ return;
315
+ }
316
+ }
317
+ }
318
+ for (const entry of Object.values(value)) {
319
+ walk(entry);
320
+ }
321
+ };
322
+ walk(root);
323
+ return edits;
324
+ };
325
+ const rangeOverlaps = (first, second) => first[0] < second[1] && second[0] < first[1];
326
+ const compareStripEditPriority = (first, second) => {
327
+ const firstLength = first.range[1] - first.range[0];
328
+ const secondLength = second.range[1] - second.range[0];
329
+ if (firstLength !== secondLength) {
330
+ return secondLength - firstLength;
331
+ }
332
+ return compareByRangeStartDesc(first, second);
333
+ };
334
+ const applyStripEdits = (magic, edits) => {
335
+ if (!edits.length) {
336
+ return false;
337
+ }
338
+ const appliedRanges = [];
339
+ let changed = false;
340
+ edits
341
+ .slice()
342
+ .sort(compareStripEditPriority)
343
+ .forEach(edit => {
344
+ /* c8 ignore next -- overlap handling is defensive after de-duplicated collection */
345
+ if (appliedRanges.some(range => rangeOverlaps(range, edit.range))) {
346
+ return;
347
+ }
348
+ const [start, end] = edit.range;
349
+ if (edit.replacement === undefined) {
350
+ magic.remove(start, end);
351
+ }
352
+ else {
353
+ magic.overwrite(start, end, edit.replacement);
354
+ }
355
+ appliedRanges.push(edit.range);
356
+ changed = true;
357
+ });
358
+ return changed;
359
+ };
360
+ const stripTypeScriptSyntax = (source, sourceType) => {
361
+ let currentCode = source;
362
+ let changed = false;
363
+ let reachedStripPassLimit = true;
364
+ for (let pass = 0; pass < MAX_TYPESCRIPT_STRIP_PASSES; pass += 1) {
365
+ const parsed = (0, oxc_parser_1.parseSync)('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
366
+ const error = parsed.errors[0];
367
+ if (error) {
368
+ throw new Error(formatParserError(error));
369
+ }
370
+ const edits = collectTypeScriptStripEdits(currentCode, parsed.program);
371
+ if (!edits.length) {
372
+ reachedStripPassLimit = false;
373
+ break;
374
+ }
375
+ const magic = new magic_string_1.default(currentCode);
376
+ const passChanged = applyStripEdits(magic, edits);
377
+ if (!passChanged) {
378
+ reachedStripPassLimit = false;
379
+ break;
380
+ }
381
+ currentCode = magic.toString();
382
+ changed = true;
383
+ }
384
+ if (reachedStripPassLimit) {
385
+ const parsed = (0, oxc_parser_1.parseSync)('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
386
+ const error = parsed.errors[0];
387
+ if (error) {
388
+ throw new Error(formatParserError(error));
389
+ }
390
+ const remainingEdits = collectTypeScriptStripEdits(currentCode, parsed.program);
391
+ if (remainingEdits.length) {
392
+ throw new Error(`[jsx] TypeScript strip did not converge after ${MAX_TYPESCRIPT_STRIP_PASSES} passes (${remainingEdits.length} removable TypeScript nodes remain).`);
393
+ }
394
+ }
395
+ return {
396
+ code: currentCode,
397
+ changed,
398
+ };
399
+ };
233
400
  function transpileJsxSource(source, options = {}) {
234
401
  const sourceType = options.sourceType ?? 'module';
235
402
  const createElementRef = options.createElement ?? 'React.createElement';
236
403
  const fragmentRef = options.fragment ?? 'React.Fragment';
404
+ const typescriptMode = options.typescript ?? 'preserve';
237
405
  const parsed = (0, oxc_parser_1.parseSync)('transpile-jsx-source.tsx', source, createModuleParserOptions(sourceType));
238
406
  const firstError = parsed.errors[0];
239
407
  if (firstError) {
240
408
  throw new Error(formatParserError(firstError));
241
409
  }
242
410
  const jsxRoots = collectRootJsxNodes(parsed.program);
243
- if (!jsxRoots.length) {
244
- return { code: source, changed: false };
411
+ const jsxMagic = new magic_string_1.default(source);
412
+ if (jsxRoots.length) {
413
+ const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef, typescriptMode === 'strip');
414
+ jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
415
+ jsxMagic.overwrite(node.range[0], node.range[1], builder.compile(node));
416
+ });
245
417
  }
246
- const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef);
247
- const magic = new magic_string_1.default(source);
248
- jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
249
- magic.overwrite(node.range[0], node.range[1], builder.compile(node));
250
- });
418
+ const jsxCode = jsxRoots.length ? jsxMagic.toString() : source;
419
+ const jsxChanged = jsxRoots.length > 0;
420
+ if (typescriptMode !== 'strip') {
421
+ return {
422
+ code: jsxCode,
423
+ changed: jsxChanged,
424
+ };
425
+ }
426
+ const stripResult = stripTypeScriptSyntax(jsxCode, sourceType);
251
427
  return {
252
- code: magic.toString(),
253
- changed: true,
428
+ code: stripResult.code,
429
+ changed: jsxChanged || stripResult.changed,
254
430
  };
255
431
  }
@@ -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,26 +244,182 @@ const collectRootJsxNodes = (root) => {
224
244
  walk(root, false);
225
245
  return nodes;
226
246
  };
247
+ const MAX_TYPESCRIPT_STRIP_PASSES = 5;
248
+ const hasStringProperty = (value, key) => isObjectRecord(value) && typeof value[key] === 'string';
249
+ const hasSourceAndExpressionRanges = (value) => isObjectRecord(value) &&
250
+ typeof value.type === 'string' &&
251
+ hasSourceRange(value) &&
252
+ 'expression' in value &&
253
+ hasSourceRange(value.expression);
254
+ const isTypeOnlyImportExport = (value) => hasStringProperty(value, 'importKind')
255
+ ? value.importKind === 'type'
256
+ : hasStringProperty(value, 'exportKind') && value.exportKind === 'type';
257
+ const isTypeOnlyNode = (value) => {
258
+ if (!isObjectRecord(value) || typeof value.type !== 'string') {
259
+ return false;
260
+ }
261
+ return [
262
+ 'TSTypeAnnotation',
263
+ 'TSTypeParameterDeclaration',
264
+ 'TSTypeAliasDeclaration',
265
+ 'TSInterfaceDeclaration',
266
+ 'TSDeclareFunction',
267
+ 'TSImportEqualsDeclaration',
268
+ 'TSNamespaceExportDeclaration',
269
+ 'TSModuleDeclaration',
270
+ ].includes(value.type);
271
+ };
272
+ const createStripEditForTsWrapper = (value, source) => {
273
+ if (!hasSourceAndExpressionRanges(value)) {
274
+ return null;
275
+ }
276
+ if (value.type !== 'TSAsExpression' &&
277
+ value.type !== 'TSSatisfiesExpression' &&
278
+ value.type !== 'TSInstantiationExpression' &&
279
+ value.type !== 'TSNonNullExpression' &&
280
+ value.type !== 'TSTypeAssertion') {
281
+ return null;
282
+ }
283
+ const [exprStart, exprEnd] = value.expression.range;
284
+ return {
285
+ range: value.range,
286
+ replacement: source.slice(exprStart, exprEnd),
287
+ };
288
+ };
289
+ const collectTypeScriptStripEdits = (source, root) => {
290
+ const edits = [];
291
+ const walk = (value) => {
292
+ if (!isObjectRecord(value)) {
293
+ return;
294
+ }
295
+ if (Array.isArray(value)) {
296
+ value.forEach(walk);
297
+ return;
298
+ }
299
+ if (hasSourceRange(value)) {
300
+ if (isTypeOnlyNode(value) || isTypeOnlyImportExport(value)) {
301
+ edits.push({ range: value.range });
302
+ return;
303
+ }
304
+ else {
305
+ const wrapperEdit = createStripEditForTsWrapper(value, source);
306
+ if (wrapperEdit) {
307
+ edits.push(wrapperEdit);
308
+ return;
309
+ }
310
+ }
311
+ }
312
+ for (const entry of Object.values(value)) {
313
+ walk(entry);
314
+ }
315
+ };
316
+ walk(root);
317
+ return edits;
318
+ };
319
+ const rangeOverlaps = (first, second) => first[0] < second[1] && second[0] < first[1];
320
+ const compareStripEditPriority = (first, second) => {
321
+ const firstLength = first.range[1] - first.range[0];
322
+ const secondLength = second.range[1] - second.range[0];
323
+ if (firstLength !== secondLength) {
324
+ return secondLength - firstLength;
325
+ }
326
+ return compareByRangeStartDesc(first, second);
327
+ };
328
+ const applyStripEdits = (magic, edits) => {
329
+ if (!edits.length) {
330
+ return false;
331
+ }
332
+ const appliedRanges = [];
333
+ let changed = false;
334
+ edits
335
+ .slice()
336
+ .sort(compareStripEditPriority)
337
+ .forEach(edit => {
338
+ /* c8 ignore next -- overlap handling is defensive after de-duplicated collection */
339
+ if (appliedRanges.some(range => rangeOverlaps(range, edit.range))) {
340
+ return;
341
+ }
342
+ const [start, end] = edit.range;
343
+ if (edit.replacement === undefined) {
344
+ magic.remove(start, end);
345
+ }
346
+ else {
347
+ magic.overwrite(start, end, edit.replacement);
348
+ }
349
+ appliedRanges.push(edit.range);
350
+ changed = true;
351
+ });
352
+ return changed;
353
+ };
354
+ const stripTypeScriptSyntax = (source, sourceType) => {
355
+ let currentCode = source;
356
+ let changed = false;
357
+ let reachedStripPassLimit = true;
358
+ for (let pass = 0; pass < MAX_TYPESCRIPT_STRIP_PASSES; pass += 1) {
359
+ const parsed = parseSync('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
360
+ const error = parsed.errors[0];
361
+ if (error) {
362
+ throw new Error(formatParserError(error));
363
+ }
364
+ const edits = collectTypeScriptStripEdits(currentCode, parsed.program);
365
+ if (!edits.length) {
366
+ reachedStripPassLimit = false;
367
+ break;
368
+ }
369
+ const magic = new MagicString(currentCode);
370
+ const passChanged = applyStripEdits(magic, edits);
371
+ if (!passChanged) {
372
+ reachedStripPassLimit = false;
373
+ break;
374
+ }
375
+ currentCode = magic.toString();
376
+ changed = true;
377
+ }
378
+ if (reachedStripPassLimit) {
379
+ const parsed = parseSync('transpile-jsx-source.tsx', currentCode, createModuleParserOptions(sourceType));
380
+ const error = parsed.errors[0];
381
+ if (error) {
382
+ throw new Error(formatParserError(error));
383
+ }
384
+ const remainingEdits = collectTypeScriptStripEdits(currentCode, parsed.program);
385
+ if (remainingEdits.length) {
386
+ throw new Error(`[jsx] TypeScript strip did not converge after ${MAX_TYPESCRIPT_STRIP_PASSES} passes (${remainingEdits.length} removable TypeScript nodes remain).`);
387
+ }
388
+ }
389
+ return {
390
+ code: currentCode,
391
+ changed,
392
+ };
393
+ };
227
394
  export function transpileJsxSource(source, options = {}) {
228
395
  const sourceType = options.sourceType ?? 'module';
229
396
  const createElementRef = options.createElement ?? 'React.createElement';
230
397
  const fragmentRef = options.fragment ?? 'React.Fragment';
398
+ const typescriptMode = options.typescript ?? 'preserve';
231
399
  const parsed = parseSync('transpile-jsx-source.tsx', source, createModuleParserOptions(sourceType));
232
400
  const firstError = parsed.errors[0];
233
401
  if (firstError) {
234
402
  throw new Error(formatParserError(firstError));
235
403
  }
236
404
  const jsxRoots = collectRootJsxNodes(parsed.program);
237
- if (!jsxRoots.length) {
238
- return { code: source, changed: false };
405
+ const jsxMagic = new MagicString(source);
406
+ if (jsxRoots.length) {
407
+ const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef, typescriptMode === 'strip');
408
+ jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
409
+ jsxMagic.overwrite(node.range[0], node.range[1], builder.compile(node));
410
+ });
239
411
  }
240
- const builder = new SourceJsxReactBuilder(source, createElementRef, fragmentRef);
241
- const magic = new MagicString(source);
242
- jsxRoots.sort(compareByRangeStartDesc).forEach(node => {
243
- magic.overwrite(node.range[0], node.range[1], builder.compile(node));
244
- });
412
+ const jsxCode = jsxRoots.length ? jsxMagic.toString() : source;
413
+ const jsxChanged = jsxRoots.length > 0;
414
+ if (typescriptMode !== 'strip') {
415
+ return {
416
+ code: jsxCode,
417
+ changed: jsxChanged,
418
+ };
419
+ }
420
+ const stripResult = stripTypeScriptSyntax(jsxCode, sourceType);
245
421
  return {
246
- code: magic.toString(),
247
- changed: true,
422
+ code: stripResult.code,
423
+ changed: jsxChanged || stripResult.changed,
248
424
  };
249
425
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knighted/jsx",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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",