@knighted/jsx 1.2.0-rc.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,23 @@
4
4
  [![codecov](https://codecov.io/gh/knightedcodemonkey/jsx/graph/badge.svg?token=tjxuFwcwkr)](https://codecov.io/gh/knightedcodemonkey/jsx)
5
5
  [![NPM version](https://img.shields.io/npm/v/@knighted/jsx.svg)](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
- const count = 3
36
- const handleClick = () => console.log('clicked!')
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 view = reactJsx`
56
- <section className="react-demo">
57
- <h2>Hello from React</h2>
58
- <button onClick={${() => console.log('clicked!')}}>Tap me</button>
59
- </section>
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(view)
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.
@@ -109,6 +136,118 @@ npx http-server test/fixtures/rspack-app -p 4173
109
136
 
110
137
  Visit `http://localhost:4173` (or whichever port you pick) to interact with the Lit + React demo.
111
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.
250
+
112
251
  ### Interpolations
113
252
 
114
253
  - All dynamic values are provided through standard template literal expressions (`${...}`). Wrap them in JSX braces to keep the syntax valid: `className={${value}}`, `{${items}}`, etc.
@@ -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 ~13.9 kB raw / ~3.6 kB min+gzip, while the new `@knighted/jsx/lite` entry is ~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.
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
 
@@ -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,4 @@
1
+ import { ensureNodeDom } from './bootstrap.cjs';
2
+ import { jsx as baseJsx } from '../jsx.cjs';
3
+ await ensureNodeDom();
4
+ export const jsx = baseJsx;
@@ -0,0 +1,2 @@
1
+ export declare const jsx: (templates: TemplateStringsArray, ...values: unknown[]) => import("../jsx.cjs").JsxRenderable;
2
+ export type { JsxRenderable, JsxComponent } from '../jsx.cjs';
@@ -0,0 +1 @@
1
+ export { reactJsx } from '../../react/react-jsx.cjs';
@@ -0,0 +1,2 @@
1
+ export { reactJsx } from '../../react/react-jsx.cjs';
2
+ export type { ReactJsxComponent } from '../../react/react-jsx.cjs';
@@ -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,2 @@
1
+ export declare const jsx: (templates: TemplateStringsArray, ...values: unknown[]) => import("../jsx.js").JsxRenderable;
2
+ export type { JsxRenderable, JsxComponent } from '../jsx.js';
@@ -0,0 +1,4 @@
1
+ import { ensureNodeDom } from './bootstrap.js';
2
+ import { jsx as baseJsx } from '../jsx.js';
3
+ await ensureNodeDom();
4
+ export const jsx = baseJsx;
@@ -0,0 +1,2 @@
1
+ export { reactJsx } from '../../react/react-jsx.js';
2
+ export type { ReactJsxComponent } from '../../react/react-jsx.js';
@@ -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.0",
4
- "description": "A simple JSX transpiler that runs in node.js or the browser.",
3
+ "version": "1.2.0-rc.1",
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 node",
12
- "jsx"
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": {