@knighted/jsx 1.0.0-alpha.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.
- package/README.md +132 -0
- package/dist/cjs/index.cjs +5 -0
- package/dist/cjs/index.d.cts +2 -0
- package/dist/cjs/jsx.cjs +407 -0
- package/dist/cjs/jsx.d.cts +8 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/jsx.d.ts +8 -0
- package/dist/jsx.js +403 -0
- package/package.json +77 -0
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# [`@knighted/jsx`](https://www.npmjs.com/package/@knighted/jsx)
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
[](https://codecov.io/gh/knightedcodemonkey/jsx)
|
|
5
|
+
[](https://www.npmjs.com/package/@knighted/jsx)
|
|
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.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @knighted/jsx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
> [!IMPORTANT]
|
|
16
|
+
> This package is ESM-only and targets browsers or ESM-aware bundlers. `require()` is not supported; use native `import`/`<script type="module">` and a DOM-like environment.
|
|
17
|
+
|
|
18
|
+
The parser automatically uses native bindings when it runs in Node.js. To enable the WASM binding for browser builds you also need the `@oxc-parser/binding-wasm32-wasi` package. Because npm enforces the `cpu: ["wasm32"]` flag you must opt into the install explicitly:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm_config_ignore_platform=true npm install @oxc-parser/binding-wasm32-wasi
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> Tip: public CDNs such as `esm.sh` or `jsdelivr` already publish bundles that include the WASM binding, so you can import this package directly from those endpoints in `<script type="module">` blocks without any extra setup.
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { jsx } from '@knighted/jsx'
|
|
30
|
+
|
|
31
|
+
const count = 3
|
|
32
|
+
const handleClick = () => console.log('clicked!')
|
|
33
|
+
|
|
34
|
+
const button = jsx`
|
|
35
|
+
<button className={${`counter-${count}`}} onClick={${handleClick}}>
|
|
36
|
+
Count is {${count}}
|
|
37
|
+
</button>
|
|
38
|
+
`
|
|
39
|
+
|
|
40
|
+
document.body.append(button)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Interpolations
|
|
44
|
+
|
|
45
|
+
- All dynamic values are provided through standard template literal expressions (`${...}`). Wrap them in JSX braces to keep the syntax valid: `className={${value}}`, `{${items}}`, etc.
|
|
46
|
+
- Every expression can be any JavaScript value: primitives, arrays/iterables, DOM nodes, functions, other `jsx` results, or custom component references.
|
|
47
|
+
- Async values (Promises) are not supported. Resolve them before passing into the template.
|
|
48
|
+
|
|
49
|
+
### Components
|
|
50
|
+
|
|
51
|
+
You can inline components by interpolating the function used for the tag name. The component receives a props object plus the optional `children` prop and can return anything that `jsx` can render (DOM nodes, strings, fragments, other arrays, ...).
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
const Button = ({ children, variant = 'primary' }) => {
|
|
55
|
+
const el = document.createElement('button')
|
|
56
|
+
el.dataset.variant = variant
|
|
57
|
+
el.append(children ?? '')
|
|
58
|
+
return el
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const view = jsx`
|
|
62
|
+
<section>
|
|
63
|
+
<${Button} variant={${'ghost'}}>
|
|
64
|
+
{${'Tap me'}}
|
|
65
|
+
</${Button}>
|
|
66
|
+
</section>
|
|
67
|
+
`
|
|
68
|
+
|
|
69
|
+
document.body.append(view)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Fragments & SVG
|
|
73
|
+
|
|
74
|
+
Use JSX fragments (`<>...</>`) for multi-root templates. SVG trees automatically switch to the `http://www.w3.org/2000/svg` namespace once they enter an `<svg>` tag, and fall back inside `<foreignObject>`.
|
|
75
|
+
|
|
76
|
+
### DOM-specific props
|
|
77
|
+
|
|
78
|
+
- `style` accepts either a string or an object. Object values handle CSS custom properties (`--token`) automatically.
|
|
79
|
+
- `class` and `className` both work and can be strings or arrays.
|
|
80
|
+
- Event handlers use the `on<Event>` naming convention (e.g. `onClick`).
|
|
81
|
+
- `ref` supports callback refs as well as mutable `{ current }` objects.
|
|
82
|
+
- `dangerouslySetInnerHTML` expects an object with an `__html` field, mirroring React.
|
|
83
|
+
|
|
84
|
+
## Browser usage
|
|
85
|
+
|
|
86
|
+
When you are not using a bundler, load the module directly from a CDN that understands npm packages:
|
|
87
|
+
|
|
88
|
+
```html
|
|
89
|
+
<script type="module">
|
|
90
|
+
import { jsx } from 'https://esm.sh/@knighted/jsx'
|
|
91
|
+
|
|
92
|
+
const message = jsx`<p>Hello from the browser</p>`
|
|
93
|
+
document.body.append(message)
|
|
94
|
+
</script>
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
If you are building locally with Vite/Rollup/Webpack make sure the WASM binding is installable (see the `npm_config_ignore_platform` tip above) so the bundler can resolve `@oxc-parser/binding-wasm32-wasi`.
|
|
98
|
+
|
|
99
|
+
## Testing
|
|
100
|
+
|
|
101
|
+
Run the Vitest suite (powered by jsdom) to exercise the DOM runtime and component support:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
npm run test
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Tests live in `test/jsx.test.ts` and cover DOM props/events, custom components, fragments, and iterable children so you can see exactly how the template tag is meant to be used.
|
|
108
|
+
|
|
109
|
+
## Browser demo / Vite build
|
|
110
|
+
|
|
111
|
+
This repo ships with a ready-to-run Vite demo under `examples/browser` that bundles the library (and the WASM binding vendored in `vendor/binding-wasm32-wasi`). Use it for a full end-to-end verification in a real browser:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
# Start a dev server at http://localhost:5173
|
|
115
|
+
npm run dev
|
|
116
|
+
|
|
117
|
+
# Produce a production Rollup build and preview it
|
|
118
|
+
npm run build:demo
|
|
119
|
+
npm run preview
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The Vite config aliases `@oxc-parser/binding-wasm32-wasi` to the vendored copy so you don’t have to perform any extra install tricks locally, while production consumers can still rely on the published package.
|
|
123
|
+
|
|
124
|
+
## Limitations
|
|
125
|
+
|
|
126
|
+
- Requires a DOM-like environment (it throws when `document` is missing).
|
|
127
|
+
- JSX identifiers are resolved at runtime through template interpolations; you cannot reference closures directly inside the template without using `${...}`.
|
|
128
|
+
- Promises/async components are not supported.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT © Knighted Code Monkey
|
package/dist/cjs/jsx.cjs
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.jsx = void 0;
|
|
4
|
+
const oxc_parser_1 = require("oxc-parser");
|
|
5
|
+
const OPEN_TAG_RE = /<\s*$/;
|
|
6
|
+
const CLOSE_TAG_RE = /<\/\s*$/;
|
|
7
|
+
const PLACEHOLDER_PREFIX = '__KX_EXPR__';
|
|
8
|
+
let invocationCounter = 0;
|
|
9
|
+
const parserOptions = {
|
|
10
|
+
lang: 'jsx',
|
|
11
|
+
sourceType: 'module',
|
|
12
|
+
range: true,
|
|
13
|
+
preserveParens: true,
|
|
14
|
+
};
|
|
15
|
+
const ensureDomAvailable = () => {
|
|
16
|
+
if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
|
|
17
|
+
throw new Error('The jsx template tag requires a DOM-like environment (document missing).');
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const formatParserError = (error) => {
|
|
21
|
+
let message = `[oxc-parser] ${error.message}`;
|
|
22
|
+
if (error.labels?.length) {
|
|
23
|
+
const label = error.labels[0];
|
|
24
|
+
if (label.message) {
|
|
25
|
+
message += `\n${label.message}`;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (error.codeframe) {
|
|
29
|
+
message += `\n${error.codeframe}`;
|
|
30
|
+
}
|
|
31
|
+
return message;
|
|
32
|
+
};
|
|
33
|
+
const extractRootNode = (program) => {
|
|
34
|
+
for (const statement of program.body) {
|
|
35
|
+
if (statement.type === 'ExpressionStatement') {
|
|
36
|
+
const expression = statement.expression;
|
|
37
|
+
if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
|
|
38
|
+
return expression;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
throw new Error('The jsx template must contain a single JSX element or fragment.');
|
|
43
|
+
};
|
|
44
|
+
const getIdentifierName = (identifier) => {
|
|
45
|
+
switch (identifier.type) {
|
|
46
|
+
case 'JSXIdentifier':
|
|
47
|
+
return identifier.name;
|
|
48
|
+
case 'JSXNamespacedName':
|
|
49
|
+
return `${identifier.namespace.name}:${identifier.name.name}`;
|
|
50
|
+
case 'JSXMemberExpression':
|
|
51
|
+
return `${getIdentifierName(identifier.object)}.${identifier.property.name}`;
|
|
52
|
+
default:
|
|
53
|
+
return '';
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const isNodeLike = (value) => {
|
|
57
|
+
if (typeof Node === 'undefined') {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return value instanceof Node || value instanceof DocumentFragment;
|
|
61
|
+
};
|
|
62
|
+
const isIterable = (value) => {
|
|
63
|
+
if (!value || typeof value === 'string') {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
return typeof value[Symbol.iterator] === 'function';
|
|
67
|
+
};
|
|
68
|
+
const isPromiseLike = (value) => {
|
|
69
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return typeof value.then === 'function';
|
|
73
|
+
};
|
|
74
|
+
const normalizeJsxText = (value) => {
|
|
75
|
+
const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
|
|
76
|
+
const trimmed = collapsed.trim();
|
|
77
|
+
return trimmed.length > 0 ? trimmed : '';
|
|
78
|
+
};
|
|
79
|
+
const setDomProp = (element, name, value) => {
|
|
80
|
+
if (value === false || value === null || value === undefined) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (name === 'dangerouslySetInnerHTML' &&
|
|
84
|
+
typeof value === 'object' &&
|
|
85
|
+
value &&
|
|
86
|
+
'__html' in value) {
|
|
87
|
+
element.innerHTML = String(value.__html ?? '');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (name === 'ref') {
|
|
91
|
+
if (typeof value === 'function') {
|
|
92
|
+
value(element);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (value && typeof value === 'object') {
|
|
96
|
+
;
|
|
97
|
+
value.current = element;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (name === 'style' && typeof value === 'object' && value !== null) {
|
|
102
|
+
const styleRecord = value;
|
|
103
|
+
const styleTarget = element.style;
|
|
104
|
+
if (!styleTarget) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const mutableStyle = styleTarget;
|
|
108
|
+
Object.entries(styleRecord).forEach(([prop, propValue]) => {
|
|
109
|
+
if (propValue === null || propValue === undefined) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (prop.startsWith('--')) {
|
|
113
|
+
styleTarget.setProperty(prop, String(propValue));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
mutableStyle[prop] = propValue;
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (typeof value === 'function' && name.startsWith('on')) {
|
|
121
|
+
const eventName = name.slice(2).toLowerCase();
|
|
122
|
+
element.addEventListener(eventName, value);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (name === 'class' || name === 'className') {
|
|
126
|
+
const classValue = Array.isArray(value)
|
|
127
|
+
? value.filter(Boolean).join(' ')
|
|
128
|
+
: String(value);
|
|
129
|
+
element.setAttribute('class', classValue);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (name === 'htmlFor') {
|
|
133
|
+
element.setAttribute('for', String(value));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (name in element && !name.includes('-')) {
|
|
137
|
+
element[name] = value;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
141
|
+
};
|
|
142
|
+
const appendChildValue = (parent, value) => {
|
|
143
|
+
if (value === null || value === undefined) {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (typeof value === 'boolean') {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (isPromiseLike(value)) {
|
|
150
|
+
throw new Error('Async values are not supported inside jsx template results.');
|
|
151
|
+
}
|
|
152
|
+
if (Array.isArray(value)) {
|
|
153
|
+
value.forEach(child => appendChildValue(parent, child));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (isIterable(value)) {
|
|
157
|
+
for (const entry of value) {
|
|
158
|
+
appendChildValue(parent, entry);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (isNodeLike(value)) {
|
|
163
|
+
parent.appendChild(value);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
parent.appendChild(document.createTextNode(String(value)));
|
|
167
|
+
};
|
|
168
|
+
const resolveAttributes = (attributes, ctx) => {
|
|
169
|
+
const props = {};
|
|
170
|
+
attributes.forEach(attribute => {
|
|
171
|
+
if (attribute.type === 'JSXSpreadAttribute') {
|
|
172
|
+
const spreadValue = evaluateExpression(attribute.argument, ctx);
|
|
173
|
+
if (spreadValue && typeof spreadValue === 'object' && !Array.isArray(spreadValue)) {
|
|
174
|
+
Object.assign(props, spreadValue);
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const name = getIdentifierName(attribute.name);
|
|
179
|
+
if (!attribute.value) {
|
|
180
|
+
props[name] = true;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (attribute.value.type === 'Literal') {
|
|
184
|
+
props[name] = attribute.value.value;
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (attribute.value.type === 'JSXExpressionContainer') {
|
|
188
|
+
if (attribute.value.expression.type === 'JSXEmptyExpression') {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
props[name] = evaluateExpression(attribute.value.expression, ctx);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
return props;
|
|
195
|
+
};
|
|
196
|
+
const applyDomAttributes = (element, attributes, ctx) => {
|
|
197
|
+
const props = resolveAttributes(attributes, ctx);
|
|
198
|
+
Object.entries(props).forEach(([name, value]) => {
|
|
199
|
+
if (name === 'key') {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (name === 'children') {
|
|
203
|
+
appendChildValue(element, value);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
setDomProp(element, name, value);
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
const evaluateJsxChildren = (children, ctx, namespace) => {
|
|
210
|
+
const resolved = [];
|
|
211
|
+
children.forEach(child => {
|
|
212
|
+
switch (child.type) {
|
|
213
|
+
case 'JSXText': {
|
|
214
|
+
const text = normalizeJsxText(child.value);
|
|
215
|
+
if (text) {
|
|
216
|
+
resolved.push(text);
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'JSXExpressionContainer': {
|
|
221
|
+
if (child.expression.type === 'JSXEmptyExpression') {
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
resolved.push(evaluateExpression(child.expression, ctx));
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case 'JSXSpreadChild': {
|
|
228
|
+
const spreadValue = evaluateExpression(child.expression, ctx);
|
|
229
|
+
if (spreadValue !== undefined && spreadValue !== null) {
|
|
230
|
+
resolved.push(spreadValue);
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
case 'JSXElement':
|
|
235
|
+
case 'JSXFragment': {
|
|
236
|
+
resolved.push(evaluateJsxNode(child, ctx, namespace));
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return resolved;
|
|
242
|
+
};
|
|
243
|
+
const evaluateComponent = (element, ctx, component, namespace) => {
|
|
244
|
+
const props = resolveAttributes(element.openingElement.attributes, ctx);
|
|
245
|
+
const childValues = evaluateJsxChildren(element.children, ctx, namespace);
|
|
246
|
+
if (childValues.length === 1) {
|
|
247
|
+
props.children = childValues[0];
|
|
248
|
+
}
|
|
249
|
+
else if (childValues.length > 1) {
|
|
250
|
+
props.children = childValues;
|
|
251
|
+
}
|
|
252
|
+
const result = component(props);
|
|
253
|
+
if (isPromiseLike(result)) {
|
|
254
|
+
throw new Error('Async jsx components are not supported.');
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
};
|
|
258
|
+
const evaluateJsxElement = (element, ctx, namespace) => {
|
|
259
|
+
const opening = element.openingElement;
|
|
260
|
+
const tagName = getIdentifierName(opening.name);
|
|
261
|
+
const component = ctx.components.get(tagName);
|
|
262
|
+
if (component) {
|
|
263
|
+
return evaluateComponent(element, ctx, component, namespace);
|
|
264
|
+
}
|
|
265
|
+
if (/[A-Z]/.test(tagName[0] ?? '')) {
|
|
266
|
+
throw new Error(`Unknown component "${tagName}". Did you interpolate it with the template literal?`);
|
|
267
|
+
}
|
|
268
|
+
const nextNamespace = tagName === 'svg' ? 'svg' : namespace;
|
|
269
|
+
const childNamespace = tagName === 'foreignObject' ? null : nextNamespace;
|
|
270
|
+
const domElement = nextNamespace === 'svg'
|
|
271
|
+
? document.createElementNS('http://www.w3.org/2000/svg', tagName)
|
|
272
|
+
: document.createElement(tagName);
|
|
273
|
+
applyDomAttributes(domElement, opening.attributes, ctx);
|
|
274
|
+
const childValues = evaluateJsxChildren(element.children, ctx, childNamespace);
|
|
275
|
+
childValues.forEach(value => appendChildValue(domElement, value));
|
|
276
|
+
return domElement;
|
|
277
|
+
};
|
|
278
|
+
const evaluateJsxNode = (node, ctx, namespace) => {
|
|
279
|
+
if (node.type === 'JSXFragment') {
|
|
280
|
+
const fragment = document.createDocumentFragment();
|
|
281
|
+
const children = evaluateJsxChildren(node.children, ctx, namespace);
|
|
282
|
+
children.forEach(child => appendChildValue(fragment, child));
|
|
283
|
+
return fragment;
|
|
284
|
+
}
|
|
285
|
+
return evaluateJsxElement(node, ctx, namespace);
|
|
286
|
+
};
|
|
287
|
+
const walkAst = (node, visitor) => {
|
|
288
|
+
if (!node || typeof node !== 'object') {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const candidate = node;
|
|
292
|
+
if (typeof candidate.type !== 'string') {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
visitor(candidate);
|
|
296
|
+
Object.values(candidate).forEach(value => {
|
|
297
|
+
if (!value) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (Array.isArray(value)) {
|
|
301
|
+
value.forEach(child => walkAst(child, visitor));
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (typeof value === 'object') {
|
|
305
|
+
walkAst(value, visitor);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
};
|
|
309
|
+
const collectPlaceholderNames = (expression, ctx) => {
|
|
310
|
+
const placeholders = new Set();
|
|
311
|
+
walkAst(expression, node => {
|
|
312
|
+
if (node.type === 'Identifier' && ctx.placeholders.has(node.name)) {
|
|
313
|
+
placeholders.add(node.name);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return Array.from(placeholders);
|
|
317
|
+
};
|
|
318
|
+
const evaluateExpression = (expression, ctx) => {
|
|
319
|
+
if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
|
|
320
|
+
return evaluateJsxNode(expression, ctx, null);
|
|
321
|
+
}
|
|
322
|
+
if (!('range' in expression) || !expression.range) {
|
|
323
|
+
throw new Error('Unable to evaluate expression: missing source range information.');
|
|
324
|
+
}
|
|
325
|
+
const [start, end] = expression.range;
|
|
326
|
+
const source = ctx.source.slice(start, end);
|
|
327
|
+
const placeholders = collectPlaceholderNames(expression, ctx);
|
|
328
|
+
try {
|
|
329
|
+
const evaluator = new Function(...placeholders, `"use strict"; return (${source});`);
|
|
330
|
+
const args = placeholders.map(name => ctx.placeholders.get(name));
|
|
331
|
+
return evaluator(...args);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
throw new Error(`Failed to evaluate expression ${source}: ${error.message}`);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
const sanitizeIdentifier = (value) => {
|
|
338
|
+
const cleaned = value.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
339
|
+
if (!cleaned) {
|
|
340
|
+
return 'Component';
|
|
341
|
+
}
|
|
342
|
+
if (!/[A-Za-z_$]/.test(cleaned[0])) {
|
|
343
|
+
return `Component${cleaned}`;
|
|
344
|
+
}
|
|
345
|
+
return cleaned;
|
|
346
|
+
};
|
|
347
|
+
const ensureBinding = (value, bindings, bindingLookup) => {
|
|
348
|
+
const existing = bindingLookup.get(value);
|
|
349
|
+
if (existing) {
|
|
350
|
+
return existing;
|
|
351
|
+
}
|
|
352
|
+
const descriptor = value.displayName || value.name || `Component${bindings.length}`;
|
|
353
|
+
const baseName = sanitizeIdentifier(descriptor);
|
|
354
|
+
let candidate = baseName;
|
|
355
|
+
let suffix = 1;
|
|
356
|
+
while (bindings.some(binding => binding.name === candidate)) {
|
|
357
|
+
candidate = `${baseName}${suffix++}`;
|
|
358
|
+
}
|
|
359
|
+
const binding = { name: candidate, value };
|
|
360
|
+
bindings.push(binding);
|
|
361
|
+
bindingLookup.set(value, binding);
|
|
362
|
+
return binding;
|
|
363
|
+
};
|
|
364
|
+
const buildTemplate = (strings, values) => {
|
|
365
|
+
const raw = strings.raw ?? strings;
|
|
366
|
+
const placeholders = new Map();
|
|
367
|
+
const bindings = [];
|
|
368
|
+
const bindingLookup = new Map();
|
|
369
|
+
let source = raw[0] ?? '';
|
|
370
|
+
const templateId = invocationCounter++;
|
|
371
|
+
let placeholderIndex = 0;
|
|
372
|
+
for (let idx = 0; idx < values.length; idx++) {
|
|
373
|
+
const chunk = raw[idx] ?? '';
|
|
374
|
+
const nextChunk = raw[idx + 1] ?? '';
|
|
375
|
+
const value = values[idx];
|
|
376
|
+
const isTagNamePosition = OPEN_TAG_RE.test(chunk) || CLOSE_TAG_RE.test(chunk);
|
|
377
|
+
if (isTagNamePosition && typeof value === 'function') {
|
|
378
|
+
const binding = ensureBinding(value, bindings, bindingLookup);
|
|
379
|
+
source += binding.name + nextChunk;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (isTagNamePosition && typeof value === 'string') {
|
|
383
|
+
source += value + nextChunk;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const placeholder = `${PLACEHOLDER_PREFIX}${templateId}_${placeholderIndex++}__`;
|
|
387
|
+
placeholders.set(placeholder, value);
|
|
388
|
+
source += placeholder + nextChunk;
|
|
389
|
+
}
|
|
390
|
+
return { source, placeholders, bindings };
|
|
391
|
+
};
|
|
392
|
+
const jsx = (templates, ...values) => {
|
|
393
|
+
ensureDomAvailable();
|
|
394
|
+
const build = buildTemplate(templates, values);
|
|
395
|
+
const result = (0, oxc_parser_1.parseSync)('inline.jsx', build.source, parserOptions);
|
|
396
|
+
if (result.errors.length > 0) {
|
|
397
|
+
throw new Error(formatParserError(result.errors[0]));
|
|
398
|
+
}
|
|
399
|
+
const root = extractRootNode(result.program);
|
|
400
|
+
const ctx = {
|
|
401
|
+
source: build.source,
|
|
402
|
+
placeholders: build.placeholders,
|
|
403
|
+
components: new Map(build.bindings.map(binding => [binding.name, binding.value])),
|
|
404
|
+
};
|
|
405
|
+
return evaluateJsxNode(root, ctx, null);
|
|
406
|
+
};
|
|
407
|
+
exports.jsx = jsx;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type JsxRenderable = Node | DocumentFragment | string | number | bigint | boolean | null | undefined | Iterable<JsxRenderable>;
|
|
2
|
+
export type JsxComponent<Props = Record<string, unknown>> = {
|
|
3
|
+
(props: Props & {
|
|
4
|
+
children?: JsxRenderable | JsxRenderable[];
|
|
5
|
+
}): JsxRenderable;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const jsx: (templates: TemplateStringsArray, ...values: unknown[]) => JsxRenderable;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { jsx } from './jsx.js';
|
package/dist/jsx.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type JsxRenderable = Node | DocumentFragment | string | number | bigint | boolean | null | undefined | Iterable<JsxRenderable>;
|
|
2
|
+
export type JsxComponent<Props = Record<string, unknown>> = {
|
|
3
|
+
(props: Props & {
|
|
4
|
+
children?: JsxRenderable | JsxRenderable[];
|
|
5
|
+
}): JsxRenderable;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare const jsx: (templates: TemplateStringsArray, ...values: unknown[]) => JsxRenderable;
|
package/dist/jsx.js
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { parseSync } from 'oxc-parser';
|
|
2
|
+
const OPEN_TAG_RE = /<\s*$/;
|
|
3
|
+
const CLOSE_TAG_RE = /<\/\s*$/;
|
|
4
|
+
const PLACEHOLDER_PREFIX = '__KX_EXPR__';
|
|
5
|
+
let invocationCounter = 0;
|
|
6
|
+
const parserOptions = {
|
|
7
|
+
lang: 'jsx',
|
|
8
|
+
sourceType: 'module',
|
|
9
|
+
range: true,
|
|
10
|
+
preserveParens: true,
|
|
11
|
+
};
|
|
12
|
+
const ensureDomAvailable = () => {
|
|
13
|
+
if (typeof document === 'undefined' || typeof document.createElement !== 'function') {
|
|
14
|
+
throw new Error('The jsx template tag requires a DOM-like environment (document missing).');
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const formatParserError = (error) => {
|
|
18
|
+
let message = `[oxc-parser] ${error.message}`;
|
|
19
|
+
if (error.labels?.length) {
|
|
20
|
+
const label = error.labels[0];
|
|
21
|
+
if (label.message) {
|
|
22
|
+
message += `\n${label.message}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (error.codeframe) {
|
|
26
|
+
message += `\n${error.codeframe}`;
|
|
27
|
+
}
|
|
28
|
+
return message;
|
|
29
|
+
};
|
|
30
|
+
const extractRootNode = (program) => {
|
|
31
|
+
for (const statement of program.body) {
|
|
32
|
+
if (statement.type === 'ExpressionStatement') {
|
|
33
|
+
const expression = statement.expression;
|
|
34
|
+
if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
|
|
35
|
+
return expression;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw new Error('The jsx template must contain a single JSX element or fragment.');
|
|
40
|
+
};
|
|
41
|
+
const getIdentifierName = (identifier) => {
|
|
42
|
+
switch (identifier.type) {
|
|
43
|
+
case 'JSXIdentifier':
|
|
44
|
+
return identifier.name;
|
|
45
|
+
case 'JSXNamespacedName':
|
|
46
|
+
return `${identifier.namespace.name}:${identifier.name.name}`;
|
|
47
|
+
case 'JSXMemberExpression':
|
|
48
|
+
return `${getIdentifierName(identifier.object)}.${identifier.property.name}`;
|
|
49
|
+
default:
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
const isNodeLike = (value) => {
|
|
54
|
+
if (typeof Node === 'undefined') {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return value instanceof Node || value instanceof DocumentFragment;
|
|
58
|
+
};
|
|
59
|
+
const isIterable = (value) => {
|
|
60
|
+
if (!value || typeof value === 'string') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return typeof value[Symbol.iterator] === 'function';
|
|
64
|
+
};
|
|
65
|
+
const isPromiseLike = (value) => {
|
|
66
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
return typeof value.then === 'function';
|
|
70
|
+
};
|
|
71
|
+
const normalizeJsxText = (value) => {
|
|
72
|
+
const collapsed = value.replace(/\r/g, '').replace(/\n\s+/g, ' ');
|
|
73
|
+
const trimmed = collapsed.trim();
|
|
74
|
+
return trimmed.length > 0 ? trimmed : '';
|
|
75
|
+
};
|
|
76
|
+
const setDomProp = (element, name, value) => {
|
|
77
|
+
if (value === false || value === null || value === undefined) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (name === 'dangerouslySetInnerHTML' &&
|
|
81
|
+
typeof value === 'object' &&
|
|
82
|
+
value &&
|
|
83
|
+
'__html' in value) {
|
|
84
|
+
element.innerHTML = String(value.__html ?? '');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (name === 'ref') {
|
|
88
|
+
if (typeof value === 'function') {
|
|
89
|
+
value(element);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (value && typeof value === 'object') {
|
|
93
|
+
;
|
|
94
|
+
value.current = element;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (name === 'style' && typeof value === 'object' && value !== null) {
|
|
99
|
+
const styleRecord = value;
|
|
100
|
+
const styleTarget = element.style;
|
|
101
|
+
if (!styleTarget) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const mutableStyle = styleTarget;
|
|
105
|
+
Object.entries(styleRecord).forEach(([prop, propValue]) => {
|
|
106
|
+
if (propValue === null || propValue === undefined) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (prop.startsWith('--')) {
|
|
110
|
+
styleTarget.setProperty(prop, String(propValue));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
mutableStyle[prop] = propValue;
|
|
114
|
+
});
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (typeof value === 'function' && name.startsWith('on')) {
|
|
118
|
+
const eventName = name.slice(2).toLowerCase();
|
|
119
|
+
element.addEventListener(eventName, value);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (name === 'class' || name === 'className') {
|
|
123
|
+
const classValue = Array.isArray(value)
|
|
124
|
+
? value.filter(Boolean).join(' ')
|
|
125
|
+
: String(value);
|
|
126
|
+
element.setAttribute('class', classValue);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (name === 'htmlFor') {
|
|
130
|
+
element.setAttribute('for', String(value));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (name in element && !name.includes('-')) {
|
|
134
|
+
element[name] = value;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
element.setAttribute(name, value === true ? '' : String(value));
|
|
138
|
+
};
|
|
139
|
+
const appendChildValue = (parent, value) => {
|
|
140
|
+
if (value === null || value === undefined) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (typeof value === 'boolean') {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (isPromiseLike(value)) {
|
|
147
|
+
throw new Error('Async values are not supported inside jsx template results.');
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(value)) {
|
|
150
|
+
value.forEach(child => appendChildValue(parent, child));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (isIterable(value)) {
|
|
154
|
+
for (const entry of value) {
|
|
155
|
+
appendChildValue(parent, entry);
|
|
156
|
+
}
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (isNodeLike(value)) {
|
|
160
|
+
parent.appendChild(value);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
parent.appendChild(document.createTextNode(String(value)));
|
|
164
|
+
};
|
|
165
|
+
const resolveAttributes = (attributes, ctx) => {
|
|
166
|
+
const props = {};
|
|
167
|
+
attributes.forEach(attribute => {
|
|
168
|
+
if (attribute.type === 'JSXSpreadAttribute') {
|
|
169
|
+
const spreadValue = evaluateExpression(attribute.argument, ctx);
|
|
170
|
+
if (spreadValue && typeof spreadValue === 'object' && !Array.isArray(spreadValue)) {
|
|
171
|
+
Object.assign(props, spreadValue);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const name = getIdentifierName(attribute.name);
|
|
176
|
+
if (!attribute.value) {
|
|
177
|
+
props[name] = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (attribute.value.type === 'Literal') {
|
|
181
|
+
props[name] = attribute.value.value;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (attribute.value.type === 'JSXExpressionContainer') {
|
|
185
|
+
if (attribute.value.expression.type === 'JSXEmptyExpression') {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
props[name] = evaluateExpression(attribute.value.expression, ctx);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return props;
|
|
192
|
+
};
|
|
193
|
+
const applyDomAttributes = (element, attributes, ctx) => {
|
|
194
|
+
const props = resolveAttributes(attributes, ctx);
|
|
195
|
+
Object.entries(props).forEach(([name, value]) => {
|
|
196
|
+
if (name === 'key') {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (name === 'children') {
|
|
200
|
+
appendChildValue(element, value);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
setDomProp(element, name, value);
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
const evaluateJsxChildren = (children, ctx, namespace) => {
|
|
207
|
+
const resolved = [];
|
|
208
|
+
children.forEach(child => {
|
|
209
|
+
switch (child.type) {
|
|
210
|
+
case 'JSXText': {
|
|
211
|
+
const text = normalizeJsxText(child.value);
|
|
212
|
+
if (text) {
|
|
213
|
+
resolved.push(text);
|
|
214
|
+
}
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
case 'JSXExpressionContainer': {
|
|
218
|
+
if (child.expression.type === 'JSXEmptyExpression') {
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
resolved.push(evaluateExpression(child.expression, ctx));
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'JSXSpreadChild': {
|
|
225
|
+
const spreadValue = evaluateExpression(child.expression, ctx);
|
|
226
|
+
if (spreadValue !== undefined && spreadValue !== null) {
|
|
227
|
+
resolved.push(spreadValue);
|
|
228
|
+
}
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case 'JSXElement':
|
|
232
|
+
case 'JSXFragment': {
|
|
233
|
+
resolved.push(evaluateJsxNode(child, ctx, namespace));
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
return resolved;
|
|
239
|
+
};
|
|
240
|
+
const evaluateComponent = (element, ctx, component, namespace) => {
|
|
241
|
+
const props = resolveAttributes(element.openingElement.attributes, ctx);
|
|
242
|
+
const childValues = evaluateJsxChildren(element.children, ctx, namespace);
|
|
243
|
+
if (childValues.length === 1) {
|
|
244
|
+
props.children = childValues[0];
|
|
245
|
+
}
|
|
246
|
+
else if (childValues.length > 1) {
|
|
247
|
+
props.children = childValues;
|
|
248
|
+
}
|
|
249
|
+
const result = component(props);
|
|
250
|
+
if (isPromiseLike(result)) {
|
|
251
|
+
throw new Error('Async jsx components are not supported.');
|
|
252
|
+
}
|
|
253
|
+
return result;
|
|
254
|
+
};
|
|
255
|
+
const evaluateJsxElement = (element, ctx, namespace) => {
|
|
256
|
+
const opening = element.openingElement;
|
|
257
|
+
const tagName = getIdentifierName(opening.name);
|
|
258
|
+
const component = ctx.components.get(tagName);
|
|
259
|
+
if (component) {
|
|
260
|
+
return evaluateComponent(element, ctx, component, namespace);
|
|
261
|
+
}
|
|
262
|
+
if (/[A-Z]/.test(tagName[0] ?? '')) {
|
|
263
|
+
throw new Error(`Unknown component "${tagName}". Did you interpolate it with the template literal?`);
|
|
264
|
+
}
|
|
265
|
+
const nextNamespace = tagName === 'svg' ? 'svg' : namespace;
|
|
266
|
+
const childNamespace = tagName === 'foreignObject' ? null : nextNamespace;
|
|
267
|
+
const domElement = nextNamespace === 'svg'
|
|
268
|
+
? document.createElementNS('http://www.w3.org/2000/svg', tagName)
|
|
269
|
+
: document.createElement(tagName);
|
|
270
|
+
applyDomAttributes(domElement, opening.attributes, ctx);
|
|
271
|
+
const childValues = evaluateJsxChildren(element.children, ctx, childNamespace);
|
|
272
|
+
childValues.forEach(value => appendChildValue(domElement, value));
|
|
273
|
+
return domElement;
|
|
274
|
+
};
|
|
275
|
+
const evaluateJsxNode = (node, ctx, namespace) => {
|
|
276
|
+
if (node.type === 'JSXFragment') {
|
|
277
|
+
const fragment = document.createDocumentFragment();
|
|
278
|
+
const children = evaluateJsxChildren(node.children, ctx, namespace);
|
|
279
|
+
children.forEach(child => appendChildValue(fragment, child));
|
|
280
|
+
return fragment;
|
|
281
|
+
}
|
|
282
|
+
return evaluateJsxElement(node, ctx, namespace);
|
|
283
|
+
};
|
|
284
|
+
const walkAst = (node, visitor) => {
|
|
285
|
+
if (!node || typeof node !== 'object') {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const candidate = node;
|
|
289
|
+
if (typeof candidate.type !== 'string') {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
visitor(candidate);
|
|
293
|
+
Object.values(candidate).forEach(value => {
|
|
294
|
+
if (!value) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (Array.isArray(value)) {
|
|
298
|
+
value.forEach(child => walkAst(child, visitor));
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (typeof value === 'object') {
|
|
302
|
+
walkAst(value, visitor);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
};
|
|
306
|
+
const collectPlaceholderNames = (expression, ctx) => {
|
|
307
|
+
const placeholders = new Set();
|
|
308
|
+
walkAst(expression, node => {
|
|
309
|
+
if (node.type === 'Identifier' && ctx.placeholders.has(node.name)) {
|
|
310
|
+
placeholders.add(node.name);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
return Array.from(placeholders);
|
|
314
|
+
};
|
|
315
|
+
const evaluateExpression = (expression, ctx) => {
|
|
316
|
+
if (expression.type === 'JSXElement' || expression.type === 'JSXFragment') {
|
|
317
|
+
return evaluateJsxNode(expression, ctx, null);
|
|
318
|
+
}
|
|
319
|
+
if (!('range' in expression) || !expression.range) {
|
|
320
|
+
throw new Error('Unable to evaluate expression: missing source range information.');
|
|
321
|
+
}
|
|
322
|
+
const [start, end] = expression.range;
|
|
323
|
+
const source = ctx.source.slice(start, end);
|
|
324
|
+
const placeholders = collectPlaceholderNames(expression, ctx);
|
|
325
|
+
try {
|
|
326
|
+
const evaluator = new Function(...placeholders, `"use strict"; return (${source});`);
|
|
327
|
+
const args = placeholders.map(name => ctx.placeholders.get(name));
|
|
328
|
+
return evaluator(...args);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
throw new Error(`Failed to evaluate expression ${source}: ${error.message}`);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
const sanitizeIdentifier = (value) => {
|
|
335
|
+
const cleaned = value.replace(/[^a-zA-Z0-9_$]/g, '');
|
|
336
|
+
if (!cleaned) {
|
|
337
|
+
return 'Component';
|
|
338
|
+
}
|
|
339
|
+
if (!/[A-Za-z_$]/.test(cleaned[0])) {
|
|
340
|
+
return `Component${cleaned}`;
|
|
341
|
+
}
|
|
342
|
+
return cleaned;
|
|
343
|
+
};
|
|
344
|
+
const ensureBinding = (value, bindings, bindingLookup) => {
|
|
345
|
+
const existing = bindingLookup.get(value);
|
|
346
|
+
if (existing) {
|
|
347
|
+
return existing;
|
|
348
|
+
}
|
|
349
|
+
const descriptor = value.displayName || value.name || `Component${bindings.length}`;
|
|
350
|
+
const baseName = sanitizeIdentifier(descriptor);
|
|
351
|
+
let candidate = baseName;
|
|
352
|
+
let suffix = 1;
|
|
353
|
+
while (bindings.some(binding => binding.name === candidate)) {
|
|
354
|
+
candidate = `${baseName}${suffix++}`;
|
|
355
|
+
}
|
|
356
|
+
const binding = { name: candidate, value };
|
|
357
|
+
bindings.push(binding);
|
|
358
|
+
bindingLookup.set(value, binding);
|
|
359
|
+
return binding;
|
|
360
|
+
};
|
|
361
|
+
const buildTemplate = (strings, values) => {
|
|
362
|
+
const raw = strings.raw ?? strings;
|
|
363
|
+
const placeholders = new Map();
|
|
364
|
+
const bindings = [];
|
|
365
|
+
const bindingLookup = new Map();
|
|
366
|
+
let source = raw[0] ?? '';
|
|
367
|
+
const templateId = invocationCounter++;
|
|
368
|
+
let placeholderIndex = 0;
|
|
369
|
+
for (let idx = 0; idx < values.length; idx++) {
|
|
370
|
+
const chunk = raw[idx] ?? '';
|
|
371
|
+
const nextChunk = raw[idx + 1] ?? '';
|
|
372
|
+
const value = values[idx];
|
|
373
|
+
const isTagNamePosition = OPEN_TAG_RE.test(chunk) || CLOSE_TAG_RE.test(chunk);
|
|
374
|
+
if (isTagNamePosition && typeof value === 'function') {
|
|
375
|
+
const binding = ensureBinding(value, bindings, bindingLookup);
|
|
376
|
+
source += binding.name + nextChunk;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
if (isTagNamePosition && typeof value === 'string') {
|
|
380
|
+
source += value + nextChunk;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const placeholder = `${PLACEHOLDER_PREFIX}${templateId}_${placeholderIndex++}__`;
|
|
384
|
+
placeholders.set(placeholder, value);
|
|
385
|
+
source += placeholder + nextChunk;
|
|
386
|
+
}
|
|
387
|
+
return { source, placeholders, bindings };
|
|
388
|
+
};
|
|
389
|
+
export const jsx = (templates, ...values) => {
|
|
390
|
+
ensureDomAvailable();
|
|
391
|
+
const build = buildTemplate(templates, values);
|
|
392
|
+
const result = parseSync('inline.jsx', build.source, parserOptions);
|
|
393
|
+
if (result.errors.length > 0) {
|
|
394
|
+
throw new Error(formatParserError(result.errors[0]));
|
|
395
|
+
}
|
|
396
|
+
const root = extractRootNode(result.program);
|
|
397
|
+
const ctx = {
|
|
398
|
+
source: build.source,
|
|
399
|
+
placeholders: build.placeholders,
|
|
400
|
+
components: new Map(build.bindings.map(binding => [binding.name, binding.value])),
|
|
401
|
+
};
|
|
402
|
+
return evaluateJsxNode(root, ctx, null);
|
|
403
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@knighted/jsx",
|
|
3
|
+
"version": "1.0.0-alpha.0",
|
|
4
|
+
"description": "A simple JSX transpiler that runs in node.js or the browser.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"jsx transform"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js",
|
|
15
|
+
"default": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22"
|
|
21
|
+
},
|
|
22
|
+
"engineStrict": true,
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "duel",
|
|
25
|
+
"precheck-types": "npm run build",
|
|
26
|
+
"check-types": "npm run check-types:lib && npm run check-types:demo",
|
|
27
|
+
"check-types:lib": "tsc --noEmit --project tsconfig.json",
|
|
28
|
+
"check-types:demo": "tsc --noEmit --project examples/browser/tsconfig.json",
|
|
29
|
+
"lint": "eslint src test",
|
|
30
|
+
"prettier": "prettier -w .",
|
|
31
|
+
"test": "vitest run --coverage",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"dev": "vite dev --config vite.config.ts",
|
|
34
|
+
"build:demo": "vite build --config vite.config.ts",
|
|
35
|
+
"preview": "vite preview --config vite.config.ts",
|
|
36
|
+
"prepack": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@eslint/js": "^9.39.1",
|
|
40
|
+
"@knighted/duel": "^2.1.6",
|
|
41
|
+
"@types/jsdom": "^27.0.0",
|
|
42
|
+
"@vitest/coverage-v8": "^4.0.14",
|
|
43
|
+
"eslint": "^9.39.1",
|
|
44
|
+
"jsdom": "^27.2.0",
|
|
45
|
+
"prettier": "^3.7.3",
|
|
46
|
+
"typescript": "^5.9.3",
|
|
47
|
+
"typescript-eslint": "^8.48.0",
|
|
48
|
+
"vite": "^7.2.4",
|
|
49
|
+
"vitest": "^4.0.14"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"oxc-parser": "^0.99.0"
|
|
53
|
+
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"@oxc-parser/binding-darwin-arm64": "^0.99.0",
|
|
56
|
+
"@oxc-parser/binding-linux-x64-gnu": "^0.99.0",
|
|
57
|
+
"@oxc-parser/binding-wasm32-wasi": "^0.99.0"
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"dist"
|
|
61
|
+
],
|
|
62
|
+
"author": "KCM <knightedcodemonkey@gmail.com>",
|
|
63
|
+
"license": "MIT",
|
|
64
|
+
"repository": {
|
|
65
|
+
"type": "git",
|
|
66
|
+
"url": "git+https://github.com/knightedcodemonkey/jsx.git"
|
|
67
|
+
},
|
|
68
|
+
"bugs": {
|
|
69
|
+
"url": "https://github.com/knightedcodemonkey/jsx/issues"
|
|
70
|
+
},
|
|
71
|
+
"prettier": {
|
|
72
|
+
"arrowParens": "avoid",
|
|
73
|
+
"printWidth": 90,
|
|
74
|
+
"semi": false,
|
|
75
|
+
"singleQuote": true
|
|
76
|
+
}
|
|
77
|
+
}
|