@knighted/jsx 1.6.3-rc.1 → 1.7.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.
Files changed (45) hide show
  1. package/dist/cjs/loader/dom-template-builder.cjs +217 -0
  2. package/dist/cjs/loader/dom-template-builder.d.cts +12 -0
  3. package/dist/cjs/loader/helpers/dom-snippets.cjs +149 -0
  4. package/dist/cjs/loader/helpers/dom-snippets.d.cts +2 -0
  5. package/dist/cjs/loader/helpers/format-import-specifier.cjs +30 -0
  6. package/dist/cjs/loader/helpers/format-import-specifier.d.cts +13 -0
  7. package/dist/cjs/loader/helpers/materialize-slice.cjs +37 -0
  8. package/dist/cjs/loader/helpers/materialize-slice.d.cts +1 -0
  9. package/dist/cjs/loader/helpers/parse-range-key.cjs +14 -0
  10. package/dist/cjs/loader/helpers/parse-range-key.d.cts +1 -0
  11. package/dist/cjs/loader/helpers/rewrite-imports-without-tags.cjs +62 -0
  12. package/dist/cjs/loader/helpers/rewrite-imports-without-tags.d.cts +3 -0
  13. package/dist/cjs/loader/jsx.cjs +57 -33
  14. package/dist/cjs/loader/jsx.d.cts +3 -1
  15. package/dist/cjs/loader/modes.cjs +17 -0
  16. package/dist/cjs/loader/modes.d.cts +3 -0
  17. package/dist/cjs/runtime/shared.cjs +3 -13
  18. package/dist/cjs/shared/normalize-text.cjs +22 -0
  19. package/dist/cjs/shared/normalize-text.d.cts +1 -0
  20. package/dist/lite/debug/index.js +7 -7
  21. package/dist/lite/index.js +7 -7
  22. package/dist/lite/node/debug/index.js +7 -7
  23. package/dist/lite/node/index.js +7 -7
  24. package/dist/lite/node/react/index.js +5 -5
  25. package/dist/lite/react/index.js +5 -5
  26. package/dist/loader/dom-template-builder.d.ts +12 -0
  27. package/dist/loader/dom-template-builder.js +213 -0
  28. package/dist/loader/helpers/dom-snippets.d.ts +2 -0
  29. package/dist/loader/helpers/dom-snippets.js +146 -0
  30. package/dist/loader/helpers/format-import-specifier.d.ts +13 -0
  31. package/dist/loader/helpers/format-import-specifier.js +26 -0
  32. package/dist/loader/helpers/materialize-slice.d.ts +1 -0
  33. package/dist/loader/helpers/materialize-slice.js +33 -0
  34. package/dist/loader/helpers/parse-range-key.d.ts +1 -0
  35. package/dist/loader/helpers/parse-range-key.js +10 -0
  36. package/dist/loader/helpers/rewrite-imports-without-tags.d.ts +3 -0
  37. package/dist/loader/helpers/rewrite-imports-without-tags.js +58 -0
  38. package/dist/loader/jsx.d.ts +3 -1
  39. package/dist/loader/jsx.js +55 -31
  40. package/dist/loader/modes.d.ts +3 -0
  41. package/dist/loader/modes.js +13 -0
  42. package/dist/runtime/shared.js +3 -13
  43. package/dist/shared/normalize-text.d.ts +1 -0
  44. package/dist/shared/normalize-text.js +18 -0
  45. package/package.json +6 -6
@@ -0,0 +1,33 @@
1
+ import { parseRangeKey } from './parse-range-key.js';
2
+ export const materializeSlice = (start, end, source, replacements) => {
3
+ const exact = replacements.get(`${start}:${end}`);
4
+ if (exact !== undefined) {
5
+ return exact;
6
+ }
7
+ const nested = [];
8
+ replacements.forEach((code, key) => {
9
+ const range = parseRangeKey(key);
10
+ if (!range)
11
+ return;
12
+ const [rStart, rEnd] = range;
13
+ if (rStart >= start && rEnd <= end) {
14
+ nested.push({ start: rStart, end: rEnd, code });
15
+ }
16
+ });
17
+ if (!nested.length) {
18
+ return source.slice(start, end);
19
+ }
20
+ nested.sort((a, b) => a.start - b.start);
21
+ let cursor = start;
22
+ let output = '';
23
+ nested.forEach(entry => {
24
+ if (entry.start < cursor) {
25
+ throw new Error(`[jsx-loader] Overlapping replacement ranges detected (${entry.start}:${entry.end}) within ${start}:${end}. Nested replacements must not overlap.`);
26
+ }
27
+ output += source.slice(cursor, entry.start);
28
+ output += entry.code;
29
+ cursor = entry.end;
30
+ });
31
+ output += source.slice(cursor, end);
32
+ return output;
33
+ };
@@ -0,0 +1 @@
1
+ export declare const parseRangeKey: (key: string) => [number, number] | null;
@@ -0,0 +1,10 @@
1
+ export const parseRangeKey = (key) => {
2
+ const [start, end] = key.split(':').map(entry => Number.parseInt(entry, 10));
3
+ if (!Number.isFinite(start) || !Number.isFinite(end)) {
4
+ return null;
5
+ }
6
+ if (end < start) {
7
+ return null;
8
+ }
9
+ return [start, end];
10
+ };
@@ -0,0 +1,3 @@
1
+ import type MagicString from 'magic-string';
2
+ import type { Program } from '@oxc-project/types';
3
+ export declare const rewriteImportsWithoutTags: (program: Program, magic: MagicString, inlineTagNames: Set<string>, originalSource: string) => boolean;
@@ -0,0 +1,58 @@
1
+ import { formatImportSpecifier } from './format-import-specifier.js';
2
+ export const rewriteImportsWithoutTags = (program, magic, inlineTagNames, originalSource) => {
3
+ if (!inlineTagNames.size) {
4
+ return false;
5
+ }
6
+ let mutated = false;
7
+ program.body.forEach(node => {
8
+ if (node.type !== 'ImportDeclaration') {
9
+ return;
10
+ }
11
+ const specifiers = node.specifiers;
12
+ const kept = [];
13
+ let removed = false;
14
+ specifiers.forEach(spec => {
15
+ const localName = spec.local?.name;
16
+ if (!localName) {
17
+ kept.push(spec);
18
+ return;
19
+ }
20
+ const shouldDrop = inlineTagNames.has(localName);
21
+ if (shouldDrop) {
22
+ removed = true;
23
+ return;
24
+ }
25
+ kept.push(spec);
26
+ });
27
+ if (!removed) {
28
+ return;
29
+ }
30
+ if (!kept.length) {
31
+ magic.remove(node.start, node.end);
32
+ mutated = true;
33
+ return;
34
+ }
35
+ const keyword = node.importKind === 'type' ? 'import type' : 'import';
36
+ const bindings = [];
37
+ const defaultSpec = kept.find(spec => spec.type === 'ImportDefaultSpecifier');
38
+ const namespaceSpec = kept.find(spec => spec.type === 'ImportNamespaceSpecifier');
39
+ const namedSpecs = kept.filter(spec => spec.type === 'ImportSpecifier');
40
+ if (defaultSpec) {
41
+ bindings.push(formatImportSpecifier(defaultSpec));
42
+ }
43
+ if (namespaceSpec) {
44
+ bindings.push(formatImportSpecifier(namespaceSpec));
45
+ }
46
+ if (namedSpecs.length) {
47
+ bindings.push(`{ ${namedSpecs.map(formatImportSpecifier).join(', ')} }`);
48
+ }
49
+ const sourceLiteral = node.source;
50
+ const sourceText = sourceLiteral.raw
51
+ ? sourceLiteral.raw
52
+ : originalSource.slice(sourceLiteral.start ?? 0, sourceLiteral.end ?? 0);
53
+ const rewritten = `${keyword} ${bindings.join(', ')} from ${sourceText}`;
54
+ magic.overwrite(node.start, node.end, rewritten);
55
+ mutated = true;
56
+ });
57
+ return mutated;
58
+ };
@@ -1,11 +1,13 @@
1
1
  import { type SourceMap } from 'magic-string';
2
+ import { type LoaderMode } from './modes.js';
2
3
  type LoaderCallback = (error: Error | null, content?: string, map?: SourceMap | null) => void;
3
4
  type LoaderContext<TOptions> = {
4
5
  resourcePath: string;
6
+ target?: string;
7
+ emitWarning?: (warning: Error | string) => void;
5
8
  async(): LoaderCallback;
6
9
  getOptions?: () => Partial<TOptions>;
7
10
  };
8
- type LoaderMode = 'runtime' | 'react';
9
11
  type LoaderOptions = {
10
12
  /**
11
13
  * Name of the tagged template function. Defaults to `jsx`.
@@ -1,6 +1,11 @@
1
1
  import MagicString from 'magic-string';
2
2
  import { parseSync } from 'oxc-parser';
3
3
  import { formatTaggedTemplateParserError, } from '../internal/template-diagnostics.js';
4
+ import { compileDomTemplate, DOM_HELPER_SNIPPETS } from './dom-template-builder.js';
5
+ import { materializeSlice } from './helpers/materialize-slice.js';
6
+ import { rewriteImportsWithoutTags } from './helpers/rewrite-imports-without-tags.js';
7
+ import { DEFAULT_MODE, parseLoaderMode } from './modes.js';
8
+ import { normalizeJsxText } from '../shared/normalize-text.js';
4
9
  const createPlaceholderMap = (placeholders) => new Map(placeholders.map(entry => [entry.marker, entry.code]));
5
10
  class ReactTemplateBuilder {
6
11
  placeholderMap;
@@ -217,23 +222,13 @@ const TEMPLATE_PARSER_OPTIONS = {
217
222
  preserveParens: true,
218
223
  };
219
224
  const DEFAULT_TAGS = ['jsx', 'reactJsx'];
220
- const DEFAULT_MODE = 'runtime';
225
+ const WEB_TARGETS = new Set(['web', 'webworker', 'electron-renderer', 'node-webkit']);
226
+ const isWebTarget = (target) => target ? WEB_TARGETS.has(target) : false;
221
227
  const HELPER_SNIPPETS = {
222
228
  react: `const __jsxReactMergeProps = (...sources) => Object.assign({}, ...sources)
223
229
  const __jsxReact = (type, props, ...children) => React.createElement(type, props, ...children)
224
230
  `,
225
- };
226
- const parseLoaderMode = (value) => {
227
- if (typeof value !== 'string') {
228
- return null;
229
- }
230
- switch (value) {
231
- case 'runtime':
232
- case 'react':
233
- return value;
234
- default:
235
- return null;
236
- }
231
+ ...DOM_HELPER_SNIPPETS,
237
232
  };
238
233
  const escapeTemplateChunk = (chunk) => chunk.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${');
239
234
  const formatParserError = (error) => {
@@ -410,19 +405,8 @@ const extractJsxRoot = (program) => {
410
405
  throw new Error('[jsx-loader] Expected the template to contain a single JSX root node.');
411
406
  };
412
407
  const normalizeJsxTextSegments = (value, placeholders) => {
413
- const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
414
- const leadingWhitespace = value.match(/^\s*/)?.[0] ?? '';
415
- const trailingWhitespace = value.match(/\s*$/)?.[0] ?? '';
416
- const trimStart = /\n/.test(leadingWhitespace);
417
- const trimEnd = /\n/.test(trailingWhitespace);
418
- let normalized = collapsed;
419
- if (trimStart) {
420
- normalized = normalized.replace(/^\s+/, '');
421
- }
422
- if (trimEnd) {
423
- normalized = normalized.replace(/\s+$/, '');
424
- }
425
- if (normalized.length === 0 || normalized.trim().length === 0) {
408
+ const normalized = normalizeJsxText(value);
409
+ if (!normalized) {
426
410
  return [];
427
411
  }
428
412
  const segments = [];
@@ -471,7 +455,7 @@ const materializeTemplateStrings = (quasis) => {
471
455
  });
472
456
  return templates;
473
457
  };
474
- const buildTemplateSource = (quasis, expressions, source, tag) => {
458
+ const buildTemplateSource = (quasis, expressions, source, tag, replacements) => {
475
459
  const placeholderMap = new Map();
476
460
  const tagPlaceholderMap = new Map();
477
461
  let template = '';
@@ -530,7 +514,7 @@ const buildTemplateSource = (quasis, expressions, source, tag) => {
530
514
  const nextValue = nextChunk?.value;
531
515
  const rightText = nextValue?.cooked ?? nextValue?.raw ?? '';
532
516
  const context = getTemplateExpressionContext(chunk, rightText);
533
- const code = source.slice(start, end);
517
+ const code = materializeSlice(start, end, source, replacements);
534
518
  const marker = registerMarker(code, context.type === 'tag');
535
519
  const appendMarker = (wrapper) => {
536
520
  const insertion = wrapper ? wrapper(marker) : marker;
@@ -627,13 +611,15 @@ const transformSource = (source, config, options) => {
627
611
  const magic = new MagicString(source);
628
612
  let mutated = false;
629
613
  const helperKinds = new Set();
614
+ const replacements = new Map();
615
+ const inlineTags = new Set();
630
616
  taggedTemplates
631
617
  .sort((a, b) => b.node.start - a.node.start)
632
618
  .forEach(entry => {
633
619
  const { node, tagName } = entry;
634
620
  const mode = config.tagModes.get(tagName) ?? DEFAULT_MODE;
635
621
  const quasi = node.quasi;
636
- const templateSource = buildTemplateSource(quasi.quasis, quasi.expressions, source, tagName);
622
+ const templateSource = buildTemplateSource(quasi.quasis, quasi.expressions, source, tagName, replacements);
637
623
  const templateStrings = materializeTemplateStrings(quasi.quasis);
638
624
  if (mode === 'runtime') {
639
625
  const { code, changed } = transformTemplateLiteral(templateSource.source, config.resourcePath, tagName, templateStrings, templateSource.diagnostics);
@@ -645,6 +631,7 @@ const transformSource = (source, config, options) => {
645
631
  const tagSource = source.slice(node.tag.start, node.tag.end);
646
632
  const replacement = `${tagSource}\`${restored}\``;
647
633
  magic.overwrite(node.start, node.end, replacement);
634
+ replacements.set(`${node.start}:${node.end}`, replacement);
648
635
  mutated = true;
649
636
  return;
650
637
  }
@@ -652,7 +639,18 @@ const transformSource = (source, config, options) => {
652
639
  const compiled = compileReactTemplate(templateSource.source, templateSource.placeholders, config.resourcePath, tagName, templateStrings, templateSource.diagnostics);
653
640
  helperKinds.add('react');
654
641
  magic.overwrite(node.start, node.end, compiled);
642
+ replacements.set(`${node.start}:${node.end}`, compiled);
643
+ mutated = true;
644
+ inlineTags.add(tagName);
645
+ return;
646
+ }
647
+ if (mode === 'dom') {
648
+ const result = compileDomTemplate(templateSource.source, templateSource.placeholders, config.resourcePath, tagName, templateStrings, templateSource.diagnostics);
649
+ result.helpers.forEach(helper => helperKinds.add(helper));
650
+ magic.overwrite(node.start, node.end, result.code);
651
+ replacements.set(`${node.start}:${node.end}`, result.code);
655
652
  mutated = true;
653
+ inlineTags.add(tagName);
656
654
  return;
657
655
  }
658
656
  /* c8 ignore next */
@@ -660,12 +658,22 @@ const transformSource = (source, config, options) => {
660
658
  // Modes are validated during option parsing; this fallback guards future extensions.
661
659
  throw new Error(`[jsx-loader] Transformation mode "${mode}" not implemented yet for tag "${tagName}".`);
662
660
  });
661
+ if (rewriteImportsWithoutTags(ast.program, magic, inlineTags, source)) {
662
+ mutated = true;
663
+ }
663
664
  const helperSource = Array.from(helperKinds)
664
665
  .map(kind => HELPER_SNIPPETS[kind])
665
666
  .filter(Boolean)
666
667
  .join('\n');
667
668
  if (helperSource) {
668
- magic.append(`\n${helperSource}`);
669
+ const helperBlock = `${helperSource.trimEnd()}\n\n`;
670
+ const shebangIndex = source.startsWith('#!') ? source.indexOf('\n') : -1;
671
+ if (shebangIndex >= 0) {
672
+ magic.appendLeft(shebangIndex + 1, helperBlock);
673
+ }
674
+ else {
675
+ magic.prepend(helperBlock);
676
+ }
669
677
  mutated = true;
670
678
  }
671
679
  const code = mutated ? magic.toString() : source;
@@ -686,6 +694,8 @@ export default function jsxLoader(input) {
686
694
  const callback = this.async();
687
695
  try {
688
696
  const options = this.getOptions?.() ?? {};
697
+ const warn = this.emitWarning?.bind(this);
698
+ const webTarget = isWebTarget(this.target);
689
699
  const explicitTags = Array.isArray(options.tags)
690
700
  ? options.tags.filter((value) => typeof value === 'string' && value.length > 0)
691
701
  : null;
@@ -699,6 +709,9 @@ export default function jsxLoader(input) {
699
709
  const configuredTagModes = options.tagModes && typeof options.tagModes === 'object'
700
710
  ? options.tagModes
701
711
  : undefined;
712
+ const userSpecifiedMode = parseLoaderMode(options.mode);
713
+ const defaultMode = userSpecifiedMode ?? DEFAULT_MODE;
714
+ const userConfiguredTags = new Set();
702
715
  if (configuredTagModes) {
703
716
  Object.entries(configuredTagModes).forEach(([tagName, mode]) => {
704
717
  const parsed = parseLoaderMode(mode);
@@ -706,15 +719,26 @@ export default function jsxLoader(input) {
706
719
  return;
707
720
  }
708
721
  tagModes.set(tagName, parsed);
722
+ userConfiguredTags.add(tagName);
709
723
  });
710
724
  }
711
- const defaultMode = parseLoaderMode(options.mode) ?? DEFAULT_MODE;
712
725
  const tags = Array.from(new Set([...tagList, ...tagModes.keys()]));
713
726
  tags.forEach(tagName => {
714
727
  if (!tagModes.has(tagName)) {
715
728
  tagModes.set(tagName, defaultMode);
716
729
  }
717
730
  });
731
+ /**
732
+ * If targeting the web and runtime mode is only implied (not explicitly requested),
733
+ * keep the runtime output but surface a warning so users can opt into react mode when
734
+ * bundling for the browser.
735
+ */
736
+ if (webTarget && userSpecifiedMode === null) {
737
+ const hasImplicitRuntime = tags.some(tagName => tagModes.get(tagName) === 'runtime' && !userConfiguredTags.has(tagName));
738
+ if (hasImplicitRuntime) {
739
+ warn?.(new Error('[jsx-loader] Web target detected while defaulting to runtime mode; the shipped parser expects a Node-like environment. Set mode: "react" (or configure per-tag) when bundling client code, or provide a browser-safe runtime parser if you intentionally need runtime output.'));
740
+ }
741
+ }
718
742
  const source = typeof input === 'string' ? input : input.toString('utf8');
719
743
  const enableSourceMap = options.sourceMap === true;
720
744
  const { code, map } = transformSource(source, {
@@ -0,0 +1,3 @@
1
+ export type LoaderMode = 'runtime' | 'react' | 'dom';
2
+ export declare const DEFAULT_MODE: LoaderMode;
3
+ export declare const parseLoaderMode: (value: unknown) => LoaderMode | null;
@@ -0,0 +1,13 @@
1
+ export const DEFAULT_MODE = 'runtime';
2
+ export const parseLoaderMode = (value) => {
3
+ if (typeof value !== 'string')
4
+ return null;
5
+ switch (value) {
6
+ case 'runtime':
7
+ case 'react':
8
+ case 'dom':
9
+ return value;
10
+ default:
11
+ return null;
12
+ }
13
+ };
@@ -1,3 +1,4 @@
1
+ import { normalizeJsxText } from '../shared/normalize-text.js';
1
2
  export { formatTaggedTemplateParserError } from '../internal/template-diagnostics.js';
2
3
  const OPEN_TAG_RE = /<\s*$/;
3
4
  const CLOSE_TAG_RE = /<\/\s*$/;
@@ -70,19 +71,8 @@ export const walkAst = (node, visitor) => {
70
71
  });
71
72
  };
72
73
  export const normalizeJsxTextSegments = (value, placeholders) => {
73
- const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
74
- const leadingWhitespace = value.match(/^\s*/)?.[0] ?? '';
75
- const trailingWhitespace = value.match(/\s*$/)?.[0] ?? '';
76
- const trimStart = /\n/.test(leadingWhitespace);
77
- const trimEnd = /\n/.test(trailingWhitespace);
78
- let normalized = collapsed;
79
- if (trimStart) {
80
- normalized = normalized.replace(/^\s+/, '');
81
- }
82
- if (trimEnd) {
83
- normalized = normalized.replace(/\s+$/, '');
84
- }
85
- if (normalized.length === 0 || normalized.trim().length === 0) {
74
+ const normalized = normalizeJsxText(value);
75
+ if (!normalized) {
86
76
  return [];
87
77
  }
88
78
  const segments = [];
@@ -0,0 +1 @@
1
+ export declare const normalizeJsxText: (value: string) => string | null;
@@ -0,0 +1,18 @@
1
+ export const normalizeJsxText = (value) => {
2
+ const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
3
+ const leadingWhitespace = value.match(/^\s*/)?.[0] ?? '';
4
+ const trailingWhitespace = value.match(/\s*$/)?.[0] ?? '';
5
+ const trimStart = /\n/.test(leadingWhitespace);
6
+ const trimEnd = /\n/.test(trailingWhitespace);
7
+ let normalized = collapsed;
8
+ if (trimStart) {
9
+ normalized = normalized.replace(/^\s+/, '');
10
+ }
11
+ if (trimEnd) {
12
+ normalized = normalized.replace(/\s+$/, '');
13
+ }
14
+ if (normalized.length === 0 || normalized.trim().length === 0) {
15
+ return null;
16
+ }
17
+ return normalized;
18
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knighted/jsx",
3
- "version": "1.6.3-rc.1",
3
+ "version": "1.7.0",
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",
@@ -108,7 +108,7 @@
108
108
  "./package.json": "./package.json"
109
109
  },
110
110
  "engines": {
111
- "node": ">=22.17.0"
111
+ "node": ">=22.21.1"
112
112
  },
113
113
  "engineStrict": true,
114
114
  "scripts": {
@@ -119,9 +119,9 @@
119
119
  "check-types:lib": "tsc --noEmit --project tsconfig.json",
120
120
  "check-types:demo": "tsc --noEmit --project examples/browser/tsconfig.json",
121
121
  "check-types:test": "tsc --noEmit --project tsconfig.vitest.json",
122
- "clean:deps": "rimraf \"**/node_modules\"",
123
- "clean:dist": "rimraf \"**/dist\"",
124
- "clean": "npm run clean:deps && npm run clean:dist",
122
+ "clean:deps": "rimraf node_modules",
123
+ "clean:dist": "rimraf dist",
124
+ "clean": "npm run clean:dist && npm run clean:deps",
125
125
  "lint": "eslint src test",
126
126
  "pretest": "npm run build",
127
127
  "cycles": "madge src --circular --extensions ts,tsx,js,jsx --ts-config tsconfig.json",
@@ -143,7 +143,7 @@
143
143
  },
144
144
  "devDependencies": {
145
145
  "@eslint/js": "^9.39.1",
146
- "@knighted/duel": "^4.0.0-rc.5",
146
+ "@knighted/duel": "^4.0.0",
147
147
  "@oxc-project/types": "^0.105.0",
148
148
  "@playwright/test": "^1.57.0",
149
149
  "@rspack/core": "^1.0.5",