Skip to content

Commit 0370f69

Browse files
feat: jsx-runtime exports. (#42)
1 parent 9f9d289 commit 0370f69

9 files changed

Lines changed: 275 additions & 7 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ A runtime JSX template tag backed by the [`oxc-parser`](https://github.com/oxc-p
2020
- [Node / SSR usage](#node--ssr-usage)
2121
- [Browser usage](#browser-usage)
2222
- [TypeScript plugin](docs/ts-plugin.md)
23+
- [TypeScript guide](docs/typescript.md)
2324
- [Component testing](docs/testing.md)
2425
- [CLI setup](docs/cli.md)
2526

@@ -229,6 +230,7 @@ The [`@knighted/jsx-ts-plugin`](docs/ts-plugin.md) keeps DOM (`jsx`) and React (
229230

230231
- Choose **TypeScript: Select TypeScript Version → Use Workspace Version** in VS Code so the plugin loads from `node_modules`.
231232
- Run `tsc --noEmit` (or your build step) to surface the same diagnostics your editor shows.
233+
- Set `jsxImportSource` to `@knighted/jsx` when compiling `.tsx` helpers. The package publishes the `@knighted/jsx/jsx-runtime` module TypeScript expects. The runtime export exists solely for diagnostics and will throw if you call it at execution time—switch back to tagged templates before shipping code.
232234
- Drop `/* @jsx-dom */` or `/* @jsx-react */` immediately before a tagged template when you need a one-off override.
233235
- Import the `JsxRenderable` helper type from `@knighted/jsx` whenever you annotate DOM-facing utilities without the plugin:
234236

@@ -239,6 +241,9 @@ The [`@knighted/jsx-ts-plugin`](docs/ts-plugin.md) keeps DOM (`jsx`) and React (
239241
const view = jsx`<section>${coerceToDom(data)}</section>`
240242
```
241243

244+
> [!TIP]
245+
> Full `tsconfig` examples (single config or split React + DOM helper projects) live in [docs/typescript.md](docs/typescript.md).
246+
242247
Head over to [docs/ts-plugin.md](docs/ts-plugin.md) for deeper guidance, advanced options, and troubleshooting tips.
243248

244249
## Browser usage

docs/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,5 +91,5 @@ Notes:
9191

9292
## Troubleshooting
9393

94-
- Missing `jsx-runtime` errors: ensure your TS config maps `@knighted/jsx/jsx-runtime` to the local package (or install typings) when authoring `.tsx` tests. Tagged templates in `.ts` files do not need that mapping.
94+
- Missing `jsx-runtime` errors: reinstall dependencies (or re-run your package manager) so the `@knighted/jsx/jsx-runtime` entry from this package is available. Tagged templates in `.ts` files do not load that module, but `.tsx` helpers compiled with `jsxImportSource` still expect it to exist.
9595
- If events fail to fire, verify your environment is `jsdom`/`happy-dom` and that the element was appended to the document (some libraries query `document.body`).

docs/ts-plugin.md

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# TypeScript Plugin Support
22

3-
Use the [`@knighted/jsx-ts-plugin`](https://github.com/knightedcodemonkey/jsx-ts-plugin) to teach both the TypeScript language service and `tsc --noEmit` how to interpret `@knighted/jsx` tagged templates. The plugin understands the DOM (`jsx`) and React (`reactJsx`) entrypoints, applies mode-aware diagnostics, and forwards the same rules to the compiler so command-line builds match what your editor reports.
3+
Use the [`@knighted/jsx-ts-plugin`](https://github.com/knightedcodemonkey/jsx-ts-plugin) to teach the TypeScript language service how to interpret `@knighted/jsx` tagged templates. The plugin understands the DOM (`jsx`) and React (`reactJsx`) entrypoints and applies mode-aware diagnostics so editors surface real JSX errors inside template literals.
4+
5+
> [!IMPORTANT]
6+
> TypeScript only loads language-service plugins inside editors (via `tsserver`). Running `tsc` or `tsc --noEmit` directly will **not** execute this plugin. To enforce the same diagnostics in CI, pair your build with a compiler transform (loader, `ts-patch`, etc.) or run a custom check that reuses the plugin’s transformation logic.
47
58
## Installation
69

@@ -37,7 +40,8 @@ Restart your editor after saving the config. From VS Code you can run **TypeScri
3740

3841
- `jsx` templates run in **DOM mode** (accepting DOM nodes, strings, iterables, etc.).
3942
- `reactJsx` templates run in **React mode** (accepting `ReactNode`, hooks, and JSX component types).
40-
- `tsc --noEmit` and `tsserver` share the same diagnostics, so CI sees the exact errors you see inside your editor.
43+
44+
Editors surface the extra diagnostics immediately because the plugin runs inside `tsserver`. Command-line builds still rely on whichever compiler transform or loader you configure outside this plugin.
4145

4246
You can override the mode per expression by dropping an inline directive immediately before the template literal:
4347

@@ -65,11 +69,48 @@ const view = jsx`<span>${asRenderable(payload)}</span>`
6569

6670
React mode continues to rely on `ReactNode`, so projects that import both helpers can keep using the standard React types.
6771

72+
### DOM component example
73+
74+
When you build DOM-only helpers (like badges rendered into Lit components), type them with `JsxRenderable` so you never have to cast to `ReactNode`:
75+
76+
```ts
77+
import { jsx } from '@knighted/jsx'
78+
import type { JsxRenderable } from '@knighted/jsx'
79+
80+
type DomBadgeProps = { label: JsxRenderable }
81+
82+
export const DomBadge = ({ label }: DomBadgeProps): HTMLElement => {
83+
let clicks = 0
84+
const counterText = jsx`<span>Clicked ${clicks} times</span>` as HTMLSpanElement
85+
86+
return jsx`
87+
<article class="dom-badge">
88+
<header>
89+
<h2>Lit + DOM with jsx</h2>
90+
<p data-kind="react">${label}</p>
91+
</header>
92+
<button
93+
type="button"
94+
data-kind="dom-counter"
95+
onClick=${() => {
96+
clicks += 1
97+
counterText.textContent = `Clicked ${clicks} times`
98+
}}
99+
>
100+
${counterText}
101+
</button>
102+
</article>
103+
` as HTMLDivElement
104+
}
105+
```
106+
107+
Here `label` stays fully typed as a DOM-friendly value, and the component returns an `HTMLElement`, so nothing needs to be widened to `ReactNode`.
108+
68109
## Editor checklist
69110

70111
1. Install `@knighted/jsx-ts-plugin` as a dev dependency.
71112
2. Add a single plugin block in `tsconfig.json` (as shown above) or extend it with additional `tagModes` for custom tags.
72113
3. Restart your editor and point VS Code at the workspace TypeScript version so the plugin loads.
73-
4. Run `tsc --noEmit` in CI to surface the same diagnostics the editor shows.
114+
4. Pair your CI/build step with the loader or compiler transform you already use for `@knighted/jsx` templates—`tsc --noEmit` alone will not load the language-service plugin.
74115

75116
Following the checklist keeps DOM and React templates aligned across the entire toolchain—no ReactNode casts, no mismatched compiler results, and no duplicate plugin entries.

docs/typescript.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# TypeScript guide
2+
3+
The `@knighted/jsx` compiler plugin and runtime typings let you keep DOM and React tagged templates type-safe without a separate build step. This guide shows the recommended `tsconfig` layouts for projects that:
4+
5+
- Author DOM helpers with the `jsx` tagged template.
6+
- Compose React elements through `reactJsx`.
7+
- Mix the helpers with traditional React components that use the normal JSX transform.
8+
9+
> [!NOTE]
10+
> At runtime you still render through the tagged template functions (`jsx`, `reactJsx`, or their Node variants). The `@knighted/jsx/jsx-runtime` entry only exists so TypeScript can validate `.tsx` helpers when you set `jsxImportSource`.
11+
12+
## Quick start (single config)
13+
14+
Use one `tsconfig.json` when the whole project can share the same JSX compiler options:
15+
16+
```jsonc
17+
{
18+
"compilerOptions": {
19+
"target": "ES2022",
20+
"module": "ESNext",
21+
"moduleResolution": "NodeNext",
22+
"strict": true,
23+
"jsx": "react-jsx",
24+
"jsxImportSource": "@knighted/jsx",
25+
"plugins": [
26+
{
27+
"name": "@knighted/jsx-ts-plugin",
28+
"tagModes": {
29+
"jsx": "dom",
30+
"reactJsx": "react",
31+
},
32+
},
33+
],
34+
},
35+
"include": ["src"],
36+
}
37+
```
38+
39+
- `jsxImportSource` points TypeScript at the packaged runtime typings so `.tsx` helpers get DOM-friendly diagnostics.
40+
- The language-service plugin enforces DOM vs React rules for tagged templates in `.ts` files. Add extra keys in `tagModes` if you alias `jsx`/`reactJsx` to different identifiers.
41+
- React components still compile and run through React’s own runtime; the setting only affects type checking.
42+
43+
## Mixed React build + DOM helper configs
44+
45+
Larger repos sometimes prefer separate project references. The pattern below keeps React’s default JSX runtime for your main app while opt-ing `.tsx` helper folders into the `@knighted/jsx` diagnostics.
46+
47+
```jsonc
48+
// tsconfig.base.json
49+
{
50+
"compilerOptions": {
51+
"target": "ES2022",
52+
"module": "ESNext",
53+
"moduleResolution": "NodeNext",
54+
"strict": true,
55+
"esModuleInterop": true,
56+
"skipLibCheck": true,
57+
},
58+
}
59+
```
60+
61+
```jsonc
62+
// tsconfig.react.json (default React transform)
63+
{
64+
"extends": "./tsconfig.base.json",
65+
"compilerOptions": {
66+
"jsx": "react-jsx",
67+
"jsxImportSource": "react",
68+
},
69+
"include": ["src/**/*.ts", "src/**/*.tsx"],
70+
"exclude": ["src/dom-helpers/**"],
71+
}
72+
```
73+
74+
```jsonc
75+
// tsconfig.jsx-helpers.json (DOM + React tagged templates)
76+
{
77+
"extends": "./tsconfig.base.json",
78+
"compilerOptions": {
79+
"jsx": "react-jsx",
80+
"jsxImportSource": "@knighted/jsx",
81+
"plugins": [
82+
{
83+
"name": "@knighted/jsx-ts-plugin",
84+
"tagModes": {
85+
"jsx": "dom",
86+
"reactJsx": "react",
87+
},
88+
},
89+
],
90+
},
91+
"include": ["src/dom-helpers/**/*.ts", "src/dom-helpers/**/*.tsx"],
92+
}
93+
```
94+
95+
Run `tsc --build tsconfig.react.json tsconfig.jsx-helpers.json` (or wire both configs into your scripts). Only the helper config needs the plugin; the React build keeps its default runtime semantics.
96+
97+
## Tips
98+
99+
- Keep `jsxImportSource` scoped to the configs that actually need DOM diagnostics. Standard React components do not require it.
100+
- `reactJsx` tagged templates already return `ReactElement`s, so you can mix them into React trees even when the file compiles under the helper config.
101+
- When you rename the template tag identifiers, update `tagModes` so the plugin continues to associate each tag with the correct mode.
102+
- Pair editor diagnostics with `tsc --noEmit` (using the same config) to ensure CI surfaces the same errors.

eslint.config.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export default [
6262
'@typescript-eslint/no-explicit-any': 'off',
6363
},
6464
},
65+
{
66+
files: ['src/jsx-runtime.ts'],
67+
rules: {
68+
'@typescript-eslint/no-unused-vars': 'off',
69+
'@typescript-eslint/no-namespace': 'off',
70+
},
71+
},
6572
{
6673
...playwrightConfig,
6774
files: ['playwright/**/*.{ts,tsx,js}'],

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/jsx",
3-
"version": "1.3.3",
3+
"version": "1.4.0",
44
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere without a build step.",
55
"keywords": [
66
"jsx runtime",
@@ -26,6 +26,16 @@
2626
"import": "./dist/index.js",
2727
"default": "./dist/index.js"
2828
},
29+
"./jsx-runtime": {
30+
"types": "./dist/jsx-runtime.d.ts",
31+
"import": "./dist/jsx-runtime.js",
32+
"default": "./dist/jsx-runtime.js"
33+
},
34+
"./jsx-dev-runtime": {
35+
"types": "./dist/jsx-runtime.d.ts",
36+
"import": "./dist/jsx-runtime.js",
37+
"default": "./dist/jsx-runtime.js"
38+
},
2939
"./lite": {
3040
"types": "./dist/index.d.ts",
3141
"import": "./dist/lite/index.js",

src/jsx-runtime.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { JsxRenderable } from './jsx.js'
2+
3+
const runtimeModuleId = '@knighted/jsx/jsx-runtime'
4+
const fragmentSymbolDescription = `${runtimeModuleId}::Fragment`
5+
6+
const runtimeNotAvailable = () => {
7+
throw new Error(
8+
`The automatic JSX runtime is only published for TypeScript diagnostics. ` +
9+
`Render DOM nodes through the jsx tagged template exported by @knighted/jsx instead.`,
10+
)
11+
}
12+
13+
export const Fragment: unique symbol = Symbol.for(fragmentSymbolDescription)
14+
15+
export function jsx(_: unknown, __?: unknown, ___?: unknown): JsxRenderable {
16+
return runtimeNotAvailable()
17+
}
18+
19+
export function jsxs(_: unknown, __?: unknown, ___?: unknown): JsxRenderable {
20+
return runtimeNotAvailable()
21+
}
22+
23+
export function jsxDEV(
24+
_: unknown,
25+
__?: unknown,
26+
___?: unknown,
27+
____?: boolean,
28+
_____?: unknown,
29+
______?: unknown,
30+
): JsxRenderable {
31+
return runtimeNotAvailable()
32+
}
33+
34+
type DataAttributes = {
35+
[K in `data-${string}`]?: string | number | boolean | null | undefined
36+
}
37+
38+
type AriaAttributes = {
39+
[K in `aria-${string}`]?: string | number | boolean | null | undefined
40+
}
41+
42+
type EventHandlers<T extends EventTarget> = {
43+
[K in keyof GlobalEventHandlersEventMap as `on${Capitalize<string & K>}`]?: (
44+
event: GlobalEventHandlersEventMap[K],
45+
) => void
46+
}
47+
48+
type ElementProps<Tag extends keyof HTMLElementTagNameMap> = Partial<
49+
HTMLElementTagNameMap[Tag]
50+
> &
51+
EventHandlers<HTMLElementTagNameMap[Tag]> &
52+
DataAttributes &
53+
AriaAttributes & {
54+
class?: string
55+
className?: string
56+
style?: string | Record<string, string | number>
57+
ref?:
58+
| ((value: HTMLElementTagNameMap[Tag]) => void)
59+
| { current: HTMLElementTagNameMap[Tag] | null }
60+
children?: JsxRenderable | JsxRenderable[]
61+
}
62+
63+
declare global {
64+
namespace JSX {
65+
type Element = JsxRenderable
66+
type IntrinsicElements = {
67+
[Tag in keyof HTMLElementTagNameMap]: ElementProps<Tag>
68+
}
69+
}
70+
}

test/jsx-runtime.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { Fragment, jsx, jsxDEV, jsxs } from '../src/jsx-runtime.js'
4+
5+
const diagnosticOnlyMessage =
6+
'The automatic JSX runtime is only published for TypeScript diagnostics. Render DOM nodes through the jsx tagged template exported by @knighted/jsx instead.'
7+
8+
describe('@knighted/jsx/jsx-runtime', () => {
9+
it.each([
10+
['jsx', () => jsx([] as unknown as TemplateStringsArray)],
11+
['jsxs', () => jsxs([] as unknown as TemplateStringsArray)],
12+
[
13+
'jsxDEV',
14+
() =>
15+
jsxDEV(
16+
[] as unknown as TemplateStringsArray,
17+
undefined,
18+
undefined,
19+
false,
20+
undefined,
21+
undefined,
22+
),
23+
],
24+
])('throws when %s is invoked at runtime', (_, invoke) => {
25+
expect(invoke).toThrowError(diagnosticOnlyMessage)
26+
})
27+
28+
it('exposes a stable Fragment symbol', () => {
29+
expect(typeof Fragment).toBe('symbol')
30+
expect(Fragment.description).toBe('@knighted/jsx/jsx-runtime::Fragment')
31+
expect(Symbol.for('@knighted/jsx/jsx-runtime::Fragment')).toBe(Fragment)
32+
})
33+
})

0 commit comments

Comments
 (0)