@knighted/jsx 1.2.0-rc.0 → 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 +152 -13
- package/dist/cjs/loader/jsx.cjs +271 -14
- package/dist/cjs/loader/jsx.d.cts +10 -0
- package/dist/cjs/node/bootstrap.cjs +83 -0
- package/dist/cjs/node/bootstrap.d.cts +1 -0
- package/dist/cjs/node/index.cjs +4 -0
- package/dist/cjs/node/index.d.cts +2 -0
- package/dist/cjs/node/react/index.cjs +1 -0
- package/dist/cjs/node/react/index.d.cts +2 -0
- package/dist/loader/jsx.d.ts +10 -0
- package/dist/loader/jsx.js +271 -14
- package/dist/node/bootstrap.d.ts +1 -0
- package/dist/node/bootstrap.js +83 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +4 -0
- package/dist/node/react/index.d.ts +2 -0
- package/dist/node/react/index.js +1 -0
- package/package.json +35 -9
package/README.md
CHANGED
|
@@ -4,7 +4,23 @@
|
|
|
4
4
|
[](https://codecov.io/gh/knightedcodemonkey/jsx)
|
|
5
5
|
[](https://www.npmjs.com/package/@knighted/jsx)
|
|
6
6
|
|
|
7
|
-
A runtime JSX template tag backed by the [`oxc-parser`](https://github.com/oxc-project/oxc) WebAssembly build. Use real JSX syntax directly inside template literals and turn the result into live DOM nodes (or values returned from your own components) without running a bundler.
|
|
7
|
+
A runtime JSX template tag backed by the [`oxc-parser`](https://github.com/oxc-project/oxc) WebAssembly build. Use real JSX syntax directly inside template literals and turn the result into live DOM nodes (or values returned from your own components) without running a bundler. One syntax works everywhere—browser scripts, SSR utilities, and bundler pipelines—no separate transpilation step required.
|
|
8
|
+
|
|
9
|
+
## Key features
|
|
10
|
+
|
|
11
|
+
- **Parse true JSX with no build step** – template literals go through `oxc-parser`, so fragments, spreads, and SVG namespaces all work as expected.
|
|
12
|
+
- **DOM + React runtimes** – choose `jsx` for DOM nodes or `reactJsx` for React elements, and mix them freely (even on the server).
|
|
13
|
+
- **Loader + SSR support** – ship tagged templates through Webpack/Rspack, Next.js, or plain Node by using the loader and the `@knighted/jsx/node` entry.
|
|
14
|
+
|
|
15
|
+
## Quick links
|
|
16
|
+
|
|
17
|
+
- [Usage](#usage)
|
|
18
|
+
- [React runtime](#react-runtime-reactjsx)
|
|
19
|
+
- [Loader integration](#loader-integration)
|
|
20
|
+
- [Node / SSR usage](#node--ssr-usage)
|
|
21
|
+
- [Next.js integration](#nextjs-integration)
|
|
22
|
+
- [Browser usage](#browser-usage)
|
|
23
|
+
- [Testing & demos](#testing)
|
|
8
24
|
|
|
9
25
|
## Installation
|
|
10
26
|
|
|
@@ -32,8 +48,11 @@ npm_config_ignore_platform=true npm install @oxc-parser/binding-wasm32-wasi
|
|
|
32
48
|
```ts
|
|
33
49
|
import { jsx } from '@knighted/jsx'
|
|
34
50
|
|
|
35
|
-
|
|
36
|
-
const handleClick = () =>
|
|
51
|
+
let count = 3
|
|
52
|
+
const handleClick = () => {
|
|
53
|
+
count += 1
|
|
54
|
+
console.log(`Count is now ${count}`)
|
|
55
|
+
}
|
|
37
56
|
|
|
38
57
|
const button = jsx`
|
|
39
58
|
<button className={${`counter-${count}`}} onClick={${handleClick}}>
|
|
@@ -49,17 +68,25 @@ document.body.append(button)
|
|
|
49
68
|
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):
|
|
50
69
|
|
|
51
70
|
```ts
|
|
71
|
+
import { useState } from 'react'
|
|
52
72
|
import { reactJsx } from '@knighted/jsx/react'
|
|
53
73
|
import { createRoot } from 'react-dom/client'
|
|
54
74
|
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
75
|
+
const App = () => {
|
|
76
|
+
const [count, setCount] = useState(0)
|
|
77
|
+
|
|
78
|
+
return reactJsx`
|
|
79
|
+
<section className="react-demo">
|
|
80
|
+
<h2>Hello from React</h2>
|
|
81
|
+
<p>Count is {${count}}</p>
|
|
82
|
+
<button onClick={${() => setCount(value => value + 1)}}>
|
|
83
|
+
Increment
|
|
84
|
+
</button>
|
|
85
|
+
</section>
|
|
86
|
+
`
|
|
87
|
+
}
|
|
61
88
|
|
|
62
|
-
createRoot(document.getElementById('root')!).render(
|
|
89
|
+
createRoot(document.getElementById('root')!).render(reactJsx`<${App} />`)
|
|
63
90
|
```
|
|
64
91
|
|
|
65
92
|
The React runtime shares the same template semantics as `jsx`, except it returns React elements (via `React.createElement`) so you can embed other React components with `<${MyComponent} />` and use hooks/state as usual. The helper lives in a separate subpath so DOM-only consumers never pay the React dependency cost.
|
|
@@ -100,14 +127,126 @@ npm run setup:wasm
|
|
|
100
127
|
npm run build:fixture
|
|
101
128
|
```
|
|
102
129
|
|
|
103
|
-
Then point a static server at the fixture root (which serves `index.html`
|
|
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:
|
|
104
131
|
|
|
105
132
|
```sh
|
|
106
133
|
# Serve the rspack fixture from the repo root
|
|
107
134
|
npx http-server test/fixtures/rspack-app -p 4173
|
|
108
135
|
```
|
|
109
136
|
|
|
110
|
-
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
|
+
|
|
139
|
+
## Node / SSR usage
|
|
140
|
+
|
|
141
|
+
Import the dedicated Node entry (`@knighted/jsx/node`) when you want to run the template tag inside bare Node.js. It automatically bootstraps a DOM shim by loading either `linkedom` or `jsdom` (install one of them to opt in) and then re-exports the usual helpers so you can keep authoring JSX in the same way:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { jsx } from '@knighted/jsx/node'
|
|
145
|
+
import { reactJsx } from '@knighted/jsx/node/react'
|
|
146
|
+
import { renderToString } from 'react-dom/server'
|
|
147
|
+
|
|
148
|
+
const Badge = ({ label }: { label: string }) =>
|
|
149
|
+
reactJsx`
|
|
150
|
+
<button type="button">React says: {${label}}</button>
|
|
151
|
+
`
|
|
152
|
+
|
|
153
|
+
const reactMarkup = renderToString(
|
|
154
|
+
reactJsx`
|
|
155
|
+
<${Badge} label={${'Server-only'}} />
|
|
156
|
+
`,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
const shell = jsx`
|
|
160
|
+
<main>
|
|
161
|
+
<section dangerouslySetInnerHTML={${{ __html: reactMarkup }}}></section>
|
|
162
|
+
</main>
|
|
163
|
+
`
|
|
164
|
+
|
|
165
|
+
console.log(shell.outerHTML)
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
> [!NOTE]
|
|
169
|
+
> The Node entry tries `linkedom` first and falls back to `jsdom`. Install whichever shim you prefer (both are optional peer dependencies) and, if needed, set `KNIGHTED_JSX_NODE_SHIM=jsdom` or `linkedom` to force a specific one.
|
|
170
|
+
|
|
171
|
+
This repository ships a ready-to-run fixture under `test/fixtures/node-ssr` that uses the Node entry to render a Lit shell plus a React subtree through `ReactDOMServer.renderToString`. Run `npm run build` once to emit `dist/`, then execute `npm run demo:node-ssr` to log the generated markup.
|
|
172
|
+
|
|
173
|
+
## Next.js integration
|
|
174
|
+
|
|
175
|
+
> [!IMPORTANT]
|
|
176
|
+
> Next already compiles `.tsx/.jsx` files, so you do not need this helper to author regular components. The loader only adds value when you want to reuse the tagged template runtime during SSR—mixing DOM nodes built by `jsx` with React markup, rendering shared utilities on the server, or processing tagged templates outside the usual component pipeline.
|
|
177
|
+
|
|
178
|
+
Next (and Remix/other Webpack-based SSR stacks) can run the loader by adding a post-loader to the framework config so the template tags are rewritten after SWC/Babel transpilation. The fixture under `test/fixtures/next-app` ships a complete example that mixes DOM and React helpers during SSR so you can pre-render DOM snippets (for emails, HTML streams, CMS content, etc.) while still returning React components from your pages. The important bits live in `next.config.mjs`:
|
|
179
|
+
|
|
180
|
+
```js
|
|
181
|
+
import path from 'node:path'
|
|
182
|
+
import { fileURLToPath } from 'node:url'
|
|
183
|
+
|
|
184
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
185
|
+
const repoRoot = path.resolve(__dirname, '../../..')
|
|
186
|
+
const distDir = path.join(repoRoot, 'dist')
|
|
187
|
+
|
|
188
|
+
export default {
|
|
189
|
+
output: 'export',
|
|
190
|
+
webpack(config) {
|
|
191
|
+
config.resolve.alias = {
|
|
192
|
+
...(config.resolve.alias ?? {}),
|
|
193
|
+
'@knighted/jsx': path.join(distDir, 'index.js'),
|
|
194
|
+
'@knighted/jsx/react': path.join(distDir, 'react/index.js'),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
config.module.rules.push({
|
|
198
|
+
test: /\.[jt]sx?$/,
|
|
199
|
+
include: path.join(__dirname, 'pages'),
|
|
200
|
+
enforce: 'post',
|
|
201
|
+
use: [{ loader: path.join(distDir, 'loader/jsx.js') }],
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
return config
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Inside `pages/index.tsx` you can freely mix the helpers. The snippet below uses `jsx` on the server to prebuild a DOM fragment and then injects that HTML alongside a normal React component on the client:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import type { GetServerSideProps } from 'next'
|
|
213
|
+
import { jsx } from '@knighted/jsx'
|
|
214
|
+
import { reactJsx } from '@knighted/jsx/react'
|
|
215
|
+
|
|
216
|
+
const buildDomShell = () =>
|
|
217
|
+
jsx`
|
|
218
|
+
<section data-kind="dom-runtime">
|
|
219
|
+
<h2>DOM runtime</h2>
|
|
220
|
+
<p>Rendered as static HTML on the server</p>
|
|
221
|
+
</section>
|
|
222
|
+
`
|
|
223
|
+
|
|
224
|
+
export const getServerSideProps: GetServerSideProps = async () => {
|
|
225
|
+
return {
|
|
226
|
+
props: {
|
|
227
|
+
domShell: buildDomShell().outerHTML,
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const ReactBadge = () =>
|
|
233
|
+
reactJsx`
|
|
234
|
+
<button type="button">React badge</button>
|
|
235
|
+
`
|
|
236
|
+
|
|
237
|
+
type PageProps = { domShell: string }
|
|
238
|
+
|
|
239
|
+
export default function Page({ domShell }: PageProps) {
|
|
240
|
+
return reactJsx`
|
|
241
|
+
<main>
|
|
242
|
+
<${ReactBadge} />
|
|
243
|
+
<div dangerouslySetInnerHTML={${{ __html: domShell }}}></div>
|
|
244
|
+
</main>
|
|
245
|
+
`
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Build the fixture locally with `npx next build test/fixtures/next-app` (or run `npx vitest run test/next-fixture.test.ts`) to verify the integration end to end. You can adapt the same pattern in `app/` routes, API handlers, or server actions whenever you need DOM output generated by the tagged template runtime.
|
|
111
250
|
|
|
112
251
|
### Interpolations
|
|
113
252
|
|
|
@@ -227,7 +366,7 @@ Tradeoffs to keep in mind:
|
|
|
227
366
|
- **Parser vs tokenizer** – `htm` performs lightweight string tokenization, while `@knighted/jsx` pays a higher one-time parse cost but gains the full JSX grammar (fragments, spread children, nested namespaces) without heuristics. For large or deeply nested templates the WASM-backed parser is typically faster and more accurate than string slicing.
|
|
228
367
|
- **DOM-first rendering** – this runtime builds DOM nodes directly, so the cost after parsing is mostly attribute assignment and child insertion. `htm` usually feeds a virtual DOM/hyperscript factory (e.g., Preact’s `h`), which may add an extra abstraction layer before hitting the DOM.
|
|
229
368
|
- **Bundle size** – including the parser and WASM binding is heavier than `htm`’s ~1 kB tokenizer. If you just need hyperscript sugar, `htm` stays leaner; if you value real JSX semantics without a build step, the extra kilobytes buy you correctness and speed on complex trees.
|
|
230
|
-
- **Actual size** – the default `dist/jsx.js` bundle is ~
|
|
369
|
+
- **Actual size** – as of `v1.2.0-rc.1` the default `dist/jsx.js` bundle is ~9.0 kB raw / ~2.3 kB min+gzip, while the `@knighted/jsx/lite` entry stays ~5.7 kB raw / ~2.5 kB min+gzip. `htm` weighs in at roughly 0.7 kB min+gzip, so the lite entry narrows the gap to ~1.8 kB for production payloads.
|
|
231
370
|
|
|
232
371
|
In short, `@knighted/jsx` trades a slightly larger runtime for the ability to parse genuine JSX with native performance, whereas `htm` favors minimal footprint and hyperscript integration. Pick the tool that aligns with your rendering stack and performance envelope.
|
|
233
372
|
|
package/dist/cjs/loader/jsx.cjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
|
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
|
|
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
|
|
606
|
+
const { code, helpers } = transformSource(source, {
|
|
355
607
|
resourcePath: this.resourcePath,
|
|
356
608
|
tags,
|
|
609
|
+
tagModes,
|
|
357
610
|
});
|
|
358
|
-
|
|
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 {};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const DOM_TEMPLATE = '<!doctype html><html><body></body></html>';
|
|
2
|
+
const GLOBAL_KEYS = [
|
|
3
|
+
'window',
|
|
4
|
+
'self',
|
|
5
|
+
'document',
|
|
6
|
+
'HTMLElement',
|
|
7
|
+
'Element',
|
|
8
|
+
'Node',
|
|
9
|
+
'DocumentFragment',
|
|
10
|
+
'customElements',
|
|
11
|
+
'Text',
|
|
12
|
+
'Comment',
|
|
13
|
+
'MutationObserver',
|
|
14
|
+
'navigator',
|
|
15
|
+
];
|
|
16
|
+
const hasDom = () => typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
17
|
+
const assignGlobalTargets = (windowObj) => {
|
|
18
|
+
const target = globalThis;
|
|
19
|
+
const source = windowObj;
|
|
20
|
+
GLOBAL_KEYS.forEach(key => {
|
|
21
|
+
if (target[key] === undefined && source[key] !== undefined) {
|
|
22
|
+
target[key] = source[key];
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const loadLinkedom = async () => {
|
|
27
|
+
const { parseHTML } = await import('linkedom');
|
|
28
|
+
const { window } = parseHTML(DOM_TEMPLATE);
|
|
29
|
+
return window;
|
|
30
|
+
};
|
|
31
|
+
const loadJsdom = async () => {
|
|
32
|
+
const { JSDOM } = await import('jsdom');
|
|
33
|
+
const { window } = new JSDOM(DOM_TEMPLATE);
|
|
34
|
+
return window;
|
|
35
|
+
};
|
|
36
|
+
const parsePreference = () => {
|
|
37
|
+
const value = typeof process !== 'undefined' && process.env?.KNIGHTED_JSX_NODE_SHIM
|
|
38
|
+
? process.env.KNIGHTED_JSX_NODE_SHIM.toLowerCase()
|
|
39
|
+
: undefined;
|
|
40
|
+
if (value === 'linkedom' || value === 'jsdom') {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
return 'auto';
|
|
44
|
+
};
|
|
45
|
+
const selectLoaders = () => {
|
|
46
|
+
const pref = parsePreference();
|
|
47
|
+
if (pref === 'linkedom') {
|
|
48
|
+
return [loadLinkedom, loadJsdom];
|
|
49
|
+
}
|
|
50
|
+
if (pref === 'jsdom') {
|
|
51
|
+
return [loadJsdom, loadLinkedom];
|
|
52
|
+
}
|
|
53
|
+
return [loadLinkedom, loadJsdom];
|
|
54
|
+
};
|
|
55
|
+
const createShimWindow = async () => {
|
|
56
|
+
const errors = [];
|
|
57
|
+
for (const loader of selectLoaders()) {
|
|
58
|
+
try {
|
|
59
|
+
return await loader();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
errors.push(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const help = 'Unable to bootstrap a DOM-like environment. Install "linkedom" or "jsdom" (both optional peer dependencies) or set KNIGHTED_JSX_NODE_SHIM to pick one explicitly.';
|
|
66
|
+
throw new AggregateError(errors, help);
|
|
67
|
+
};
|
|
68
|
+
let bootstrapPromise = null;
|
|
69
|
+
export const ensureNodeDom = async () => {
|
|
70
|
+
if (hasDom()) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!bootstrapPromise) {
|
|
74
|
+
bootstrapPromise = (async () => {
|
|
75
|
+
const windowObj = await createShimWindow();
|
|
76
|
+
assignGlobalTargets(windowObj);
|
|
77
|
+
})().catch(error => {
|
|
78
|
+
bootstrapPromise = null;
|
|
79
|
+
throw error;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return bootstrapPromise;
|
|
83
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const ensureNodeDom: () => Promise<void>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { reactJsx } from '../../react/react-jsx.cjs';
|
package/dist/loader/jsx.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/loader/jsx.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
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
|
|
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
|
|
600
|
+
const { code, helpers } = transformSource(source, {
|
|
349
601
|
resourcePath: this.resourcePath,
|
|
350
602
|
tags,
|
|
603
|
+
tagModes,
|
|
351
604
|
});
|
|
352
|
-
|
|
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);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const ensureNodeDom: () => Promise<void>;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const DOM_TEMPLATE = '<!doctype html><html><body></body></html>';
|
|
2
|
+
const GLOBAL_KEYS = [
|
|
3
|
+
'window',
|
|
4
|
+
'self',
|
|
5
|
+
'document',
|
|
6
|
+
'HTMLElement',
|
|
7
|
+
'Element',
|
|
8
|
+
'Node',
|
|
9
|
+
'DocumentFragment',
|
|
10
|
+
'customElements',
|
|
11
|
+
'Text',
|
|
12
|
+
'Comment',
|
|
13
|
+
'MutationObserver',
|
|
14
|
+
'navigator',
|
|
15
|
+
];
|
|
16
|
+
const hasDom = () => typeof document !== 'undefined' && typeof document.createElement === 'function';
|
|
17
|
+
const assignGlobalTargets = (windowObj) => {
|
|
18
|
+
const target = globalThis;
|
|
19
|
+
const source = windowObj;
|
|
20
|
+
GLOBAL_KEYS.forEach(key => {
|
|
21
|
+
if (target[key] === undefined && source[key] !== undefined) {
|
|
22
|
+
target[key] = source[key];
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const loadLinkedom = async () => {
|
|
27
|
+
const { parseHTML } = await import('linkedom');
|
|
28
|
+
const { window } = parseHTML(DOM_TEMPLATE);
|
|
29
|
+
return window;
|
|
30
|
+
};
|
|
31
|
+
const loadJsdom = async () => {
|
|
32
|
+
const { JSDOM } = await import('jsdom');
|
|
33
|
+
const { window } = new JSDOM(DOM_TEMPLATE);
|
|
34
|
+
return window;
|
|
35
|
+
};
|
|
36
|
+
const parsePreference = () => {
|
|
37
|
+
const value = typeof process !== 'undefined' && process.env?.KNIGHTED_JSX_NODE_SHIM
|
|
38
|
+
? process.env.KNIGHTED_JSX_NODE_SHIM.toLowerCase()
|
|
39
|
+
: undefined;
|
|
40
|
+
if (value === 'linkedom' || value === 'jsdom') {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
return 'auto';
|
|
44
|
+
};
|
|
45
|
+
const selectLoaders = () => {
|
|
46
|
+
const pref = parsePreference();
|
|
47
|
+
if (pref === 'linkedom') {
|
|
48
|
+
return [loadLinkedom, loadJsdom];
|
|
49
|
+
}
|
|
50
|
+
if (pref === 'jsdom') {
|
|
51
|
+
return [loadJsdom, loadLinkedom];
|
|
52
|
+
}
|
|
53
|
+
return [loadLinkedom, loadJsdom];
|
|
54
|
+
};
|
|
55
|
+
const createShimWindow = async () => {
|
|
56
|
+
const errors = [];
|
|
57
|
+
for (const loader of selectLoaders()) {
|
|
58
|
+
try {
|
|
59
|
+
return await loader();
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
errors.push(error);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const help = 'Unable to bootstrap a DOM-like environment. Install "linkedom" or "jsdom" (both optional peer dependencies) or set KNIGHTED_JSX_NODE_SHIM to pick one explicitly.';
|
|
66
|
+
throw new AggregateError(errors, help);
|
|
67
|
+
};
|
|
68
|
+
let bootstrapPromise = null;
|
|
69
|
+
export const ensureNodeDom = async () => {
|
|
70
|
+
if (hasDom()) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (!bootstrapPromise) {
|
|
74
|
+
bootstrapPromise = (async () => {
|
|
75
|
+
const windowObj = await createShimWindow();
|
|
76
|
+
assignGlobalTargets(windowObj);
|
|
77
|
+
})().catch(error => {
|
|
78
|
+
bootstrapPromise = null;
|
|
79
|
+
throw error;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return bootstrapPromise;
|
|
83
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { reactJsx } from '../../react/react-jsx.js';
|
package/package.json
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knighted/jsx",
|
|
3
|
-
"version": "1.2.0-rc.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0-rc.2",
|
|
4
|
+
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere without a build step.",
|
|
5
5
|
"keywords": [
|
|
6
|
-
"jsx browser transform",
|
|
7
|
-
"jsx transpiler",
|
|
8
|
-
"jsx compiler",
|
|
9
|
-
"jsx parser",
|
|
10
6
|
"jsx runtime",
|
|
11
|
-
"jsx
|
|
12
|
-
"
|
|
7
|
+
"jsx template literal",
|
|
8
|
+
"tagged template",
|
|
9
|
+
"no-build jsx",
|
|
10
|
+
"dom runtime",
|
|
11
|
+
"react runtime",
|
|
12
|
+
"ssr",
|
|
13
|
+
"lit",
|
|
14
|
+
"webpack loader",
|
|
15
|
+
"oxc parser"
|
|
13
16
|
],
|
|
14
17
|
"type": "module",
|
|
15
18
|
"main": "./dist/index.js",
|
|
@@ -30,6 +33,16 @@
|
|
|
30
33
|
"import": "./dist/react/index.js",
|
|
31
34
|
"default": "./dist/react/index.js"
|
|
32
35
|
},
|
|
36
|
+
"./node": {
|
|
37
|
+
"types": "./dist/node/index.d.ts",
|
|
38
|
+
"import": "./dist/node/index.js",
|
|
39
|
+
"default": "./dist/node/index.js"
|
|
40
|
+
},
|
|
41
|
+
"./node/react": {
|
|
42
|
+
"types": "./dist/node/react/index.d.ts",
|
|
43
|
+
"import": "./dist/node/react/index.js",
|
|
44
|
+
"default": "./dist/node/react/index.js"
|
|
45
|
+
},
|
|
33
46
|
"./loader": {
|
|
34
47
|
"import": "./dist/loader/jsx.js",
|
|
35
48
|
"default": "./dist/loader/jsx.js"
|
|
@@ -51,6 +64,7 @@
|
|
|
51
64
|
"test": "vitest run --coverage",
|
|
52
65
|
"test:watch": "vitest",
|
|
53
66
|
"build:fixture": "node scripts/build-rspack-fixture.mjs",
|
|
67
|
+
"demo:node-ssr": "node test/fixtures/node-ssr/render.mjs",
|
|
54
68
|
"dev": "vite dev --config vite.config.ts",
|
|
55
69
|
"build:demo": "vite build --config vite.config.ts",
|
|
56
70
|
"preview": "vite preview --config vite.config.ts",
|
|
@@ -61,15 +75,19 @@
|
|
|
61
75
|
"devDependencies": {
|
|
62
76
|
"@eslint/js": "^9.39.1",
|
|
63
77
|
"@knighted/duel": "^2.1.6",
|
|
78
|
+
"@types/node": "^22.10.1",
|
|
64
79
|
"@rspack/core": "^1.0.5",
|
|
65
80
|
"@types/jsdom": "^27.0.0",
|
|
66
81
|
"@types/react": "^19.2.7",
|
|
67
82
|
"@types/react-dom": "^19.2.3",
|
|
68
83
|
"@vitest/coverage-v8": "^4.0.14",
|
|
69
84
|
"eslint": "^9.39.1",
|
|
85
|
+
"eslint-plugin-n": "^17.10.3",
|
|
86
|
+
"@oxc-project/types": "^0.99.0",
|
|
70
87
|
"http-server": "^14.1.1",
|
|
71
88
|
"jsdom": "^27.2.0",
|
|
72
89
|
"lit": "^3.2.1",
|
|
90
|
+
"next": "^16.0.0",
|
|
73
91
|
"prettier": "^3.7.3",
|
|
74
92
|
"react": "^19.0.0",
|
|
75
93
|
"react-dom": "^19.0.0",
|
|
@@ -85,11 +103,19 @@
|
|
|
85
103
|
"oxc-parser": "^0.99.0"
|
|
86
104
|
},
|
|
87
105
|
"peerDependencies": {
|
|
88
|
-
"react": ">=18"
|
|
106
|
+
"react": ">=18",
|
|
107
|
+
"jsdom": "*",
|
|
108
|
+
"linkedom": "*"
|
|
89
109
|
},
|
|
90
110
|
"peerDependenciesMeta": {
|
|
91
111
|
"react": {
|
|
92
112
|
"optional": true
|
|
113
|
+
},
|
|
114
|
+
"jsdom": {
|
|
115
|
+
"optional": true
|
|
116
|
+
},
|
|
117
|
+
"linkedom": {
|
|
118
|
+
"optional": true
|
|
93
119
|
}
|
|
94
120
|
},
|
|
95
121
|
"optionalDependencies": {
|