Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ A runtime JSX template tag backed by the [`oxc-parser`](https://github.com/oxc-p
- [Node / SSR usage](#node--ssr-usage)
- [Browser usage](#browser-usage)
- [TypeScript plugin](docs/ts-plugin.md)
- [TypeScript guide](docs/typescript.md)
- [Component testing](docs/testing.md)
- [CLI setup](docs/cli.md)

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

- Choose **TypeScript: Select TypeScript Version → Use Workspace Version** in VS Code so the plugin loads from `node_modules`.
- Run `tsc --noEmit` (or your build step) to surface the same diagnostics your editor shows.
- 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.
- Drop `/* @jsx-dom */` or `/* @jsx-react */` immediately before a tagged template when you need a one-off override.
- Import the `JsxRenderable` helper type from `@knighted/jsx` whenever you annotate DOM-facing utilities without the plugin:

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

> [!TIP]
> Full `tsconfig` examples (single config or split React + DOM helper projects) live in [docs/typescript.md](docs/typescript.md).

Head over to [docs/ts-plugin.md](docs/ts-plugin.md) for deeper guidance, advanced options, and troubleshooting tips.

## Browser usage
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,5 @@ Notes:

## Troubleshooting

- 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.
- 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.
- 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`).
47 changes: 44 additions & 3 deletions docs/ts-plugin.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# TypeScript Plugin Support

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.
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.

> [!IMPORTANT]
> 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.

## Installation

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

- `jsx` templates run in **DOM mode** (accepting DOM nodes, strings, iterables, etc.).
- `reactJsx` templates run in **React mode** (accepting `ReactNode`, hooks, and JSX component types).
- `tsc --noEmit` and `tsserver` share the same diagnostics, so CI sees the exact errors you see inside your editor.

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.

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

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

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

### DOM component example

When you build DOM-only helpers (like badges rendered into Lit components), type them with `JsxRenderable` so you never have to cast to `ReactNode`:

```ts
import { jsx } from '@knighted/jsx'
import type { JsxRenderable } from '@knighted/jsx'

type DomBadgeProps = { label: JsxRenderable }

export const DomBadge = ({ label }: DomBadgeProps): HTMLElement => {
let clicks = 0
const counterText = jsx`<span>Clicked ${clicks} times</span>` as HTMLSpanElement

return jsx`
<article class="dom-badge">
<header>
<h2>Lit + DOM with jsx</h2>
<p data-kind="react">${label}</p>
</header>
<button
type="button"
data-kind="dom-counter"
onClick=${() => {
clicks += 1
counterText.textContent = `Clicked ${clicks} times`
}}
>
${counterText}
</button>
</article>
` as HTMLDivElement
}
```

Here `label` stays fully typed as a DOM-friendly value, and the component returns an `HTMLElement`, so nothing needs to be widened to `ReactNode`.

## Editor checklist

1. Install `@knighted/jsx-ts-plugin` as a dev dependency.
2. Add a single plugin block in `tsconfig.json` (as shown above) or extend it with additional `tagModes` for custom tags.
3. Restart your editor and point VS Code at the workspace TypeScript version so the plugin loads.
4. Run `tsc --noEmit` in CI to surface the same diagnostics the editor shows.
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.

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.
102 changes: 102 additions & 0 deletions docs/typescript.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# TypeScript guide

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:

- Author DOM helpers with the `jsx` tagged template.
- Compose React elements through `reactJsx`.
- Mix the helpers with traditional React components that use the normal JSX transform.

> [!NOTE]
> 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`.

## Quick start (single config)

Use one `tsconfig.json` when the whole project can share the same JSX compiler options:

```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "NodeNext",
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "@knighted/jsx",
"plugins": [
{
"name": "@knighted/jsx-ts-plugin",
"tagModes": {
"jsx": "dom",
"reactJsx": "react",
},
},
],
},
"include": ["src"],
}
```

- `jsxImportSource` points TypeScript at the packaged runtime typings so `.tsx` helpers get DOM-friendly diagnostics.
- 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.
- React components still compile and run through React’s own runtime; the setting only affects type checking.

## Mixed React build + DOM helper configs

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.

```jsonc
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
},
}
```

```jsonc
// tsconfig.react.json (default React transform)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["src/dom-helpers/**"],
}
```

```jsonc
// tsconfig.jsx-helpers.json (DOM + React tagged templates)
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@knighted/jsx",
"plugins": [
{
"name": "@knighted/jsx-ts-plugin",
"tagModes": {
"jsx": "dom",
"reactJsx": "react",
},
},
],
},
"include": ["src/dom-helpers/**/*.ts", "src/dom-helpers/**/*.tsx"],
}
```

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.

## Tips

- Keep `jsxImportSource` scoped to the configs that actually need DOM diagnostics. Standard React components do not require it.
- `reactJsx` tagged templates already return `ReactElement`s, so you can mix them into React trees even when the file compiles under the helper config.
- When you rename the template tag identifiers, update `tagModes` so the plugin continues to associate each tag with the correct mode.
- Pair editor diagnostics with `tsc --noEmit` (using the same config) to ensure CI surfaces the same errors.
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ export default [
'@typescript-eslint/no-explicit-any': 'off',
},
},
{
files: ['src/jsx-runtime.ts'],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-namespace': 'off',
},
},
{
...playwrightConfig,
files: ['playwright/**/*.{ts,tsx,js}'],
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@knighted/jsx",
"version": "1.3.3",
"version": "1.4.0",
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere without a build step.",
"keywords": [
"jsx runtime",
Expand All @@ -26,6 +26,16 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./jsx-runtime": {
"types": "./dist/jsx-runtime.d.ts",
"import": "./dist/jsx-runtime.js",
"default": "./dist/jsx-runtime.js"
},
"./jsx-dev-runtime": {
"types": "./dist/jsx-runtime.d.ts",
"import": "./dist/jsx-runtime.js",
"default": "./dist/jsx-runtime.js"
},
"./lite": {
"types": "./dist/index.d.ts",
"import": "./dist/lite/index.js",
Expand Down
70 changes: 70 additions & 0 deletions src/jsx-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { JsxRenderable } from './jsx.js'

const runtimeModuleId = '@knighted/jsx/jsx-runtime'
const fragmentSymbolDescription = `${runtimeModuleId}::Fragment`

const runtimeNotAvailable = () => {
throw new Error(
`The automatic JSX runtime is only published for TypeScript diagnostics. ` +
`Render DOM nodes through the jsx tagged template exported by @knighted/jsx instead.`,
)
}

export const Fragment: unique symbol = Symbol.for(fragmentSymbolDescription)

export function jsx(_: unknown, __?: unknown, ___?: unknown): JsxRenderable {
return runtimeNotAvailable()
}

export function jsxs(_: unknown, __?: unknown, ___?: unknown): JsxRenderable {
return runtimeNotAvailable()
}

export function jsxDEV(
_: unknown,
__?: unknown,
___?: unknown,
____?: boolean,
_____?: unknown,
______?: unknown,
): JsxRenderable {
return runtimeNotAvailable()
}

type DataAttributes = {
[K in `data-${string}`]?: string | number | boolean | null | undefined
}

type AriaAttributes = {
[K in `aria-${string}`]?: string | number | boolean | null | undefined
}

type EventHandlers<T extends EventTarget> = {
[K in keyof GlobalEventHandlersEventMap as `on${Capitalize<string & K>}`]?: (
event: GlobalEventHandlersEventMap[K],
) => void
}

type ElementProps<Tag extends keyof HTMLElementTagNameMap> = Partial<
HTMLElementTagNameMap[Tag]
> &
EventHandlers<HTMLElementTagNameMap[Tag]> &
DataAttributes &
AriaAttributes & {
class?: string
className?: string
style?: string | Record<string, string | number>
ref?:
| ((value: HTMLElementTagNameMap[Tag]) => void)
| { current: HTMLElementTagNameMap[Tag] | null }
children?: JsxRenderable | JsxRenderable[]
}

declare global {
namespace JSX {
type Element = JsxRenderable
type IntrinsicElements = {
[Tag in keyof HTMLElementTagNameMap]: ElementProps<Tag>
}
}
}
33 changes: 33 additions & 0 deletions test/jsx-runtime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'

import { Fragment, jsx, jsxDEV, jsxs } from '../src/jsx-runtime.js'

const diagnosticOnlyMessage =
'The automatic JSX runtime is only published for TypeScript diagnostics. Render DOM nodes through the jsx tagged template exported by @knighted/jsx instead.'

describe('@knighted/jsx/jsx-runtime', () => {
it.each([
['jsx', () => jsx([] as unknown as TemplateStringsArray)],
['jsxs', () => jsxs([] as unknown as TemplateStringsArray)],
[
'jsxDEV',
() =>
jsxDEV(
[] as unknown as TemplateStringsArray,
undefined,
undefined,
false,
undefined,
undefined,
),
],
])('throws when %s is invoked at runtime', (_, invoke) => {
expect(invoke).toThrowError(diagnosticOnlyMessage)
})

it('exposes a stable Fragment symbol', () => {
expect(typeof Fragment).toBe('symbol')
expect(Fragment.description).toBe('@knighted/jsx/jsx-runtime::Fragment')
expect(Symbol.for('@knighted/jsx/jsx-runtime::Fragment')).toBe(Fragment)
})
})