Skip to content

Commit 9a80577

Browse files
feat: better dom support and cli init. (#45)
1 parent 8bee9dc commit 9a80577

20 files changed

Lines changed: 4181 additions & 1593 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ jobs:
3333
with:
3434
name: npm-debug-log-${{ hashFiles('package-lock.json') }}
3535
path: npm-debug.log
36+
- name: Check Cycles
37+
run: npm run cycles
3638
- name: Check Types
3739
run: npm run check-types
3840
- name: Test

.github/workflows/publish.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ name: Publish
33
on:
44
release:
55
types: [published]
6+
workflow_dispatch:
7+
inputs:
8+
branch:
9+
description: Branch to publish from (defaults to main)
10+
required: false
11+
default: main
612

713
permissions:
814
id-token: write
@@ -13,8 +19,22 @@ jobs:
1319
if: contains('["knightedcodemonkey"]', github.actor)
1420
runs-on: ubuntu-latest
1521
steps:
22+
- name: Determine ref
23+
id: publish-ref
24+
run: |
25+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
26+
ref="${{ github.event.inputs.branch }}"
27+
if [ -z "$ref" ]; then
28+
ref="main"
29+
fi
30+
else
31+
ref="${{ github.ref }}"
32+
fi
33+
echo "ref=$ref" >> "$GITHUB_OUTPUT"
1634
- name: Checkout
1735
uses: actions/checkout@v4.2.2
36+
with:
37+
ref: ${{ steps.publish-ref.outputs.ref }}
1838
- name: Setup Node
1939
uses: actions/setup-node@v4.3.0
2040
with:

.husky/pre-push

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ if (set -o pipefail) 2>/dev/null; then :; fi
44

55
npm run prettier:check
66
npm run lint
7+
npm run cycles
78
npm run check-types
89
npm test

README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ The React runtime shares the same template semantics as `jsx`, except it returns
9999

100100
- `style` accepts either a string or an object. Object values handle CSS custom properties (`--token`) automatically.
101101
- `class` and `className` both work and can be strings or arrays.
102-
- Event handlers use the `on<Event>` naming convention (e.g. `onClick`).
102+
- Event handlers use the `on<Event>` naming convention (e.g. `onClick`), support capture-phase variants via `on<Event>Capture`, and allow custom events with the `on:custom-event` syntax (descriptor objects with `{ handler, once, capture }` are also accepted).
103103
- `ref` supports callback refs as well as mutable `{ current }` objects.
104104
- `dangerouslySetInnerHTML` expects an object with an `__html` field, mirroring React.
105105

@@ -286,6 +286,26 @@ import { reactJsx as nodeReactJsx } from '@knighted/jsx/node/react/lite'
286286

287287
Each lite subpath ships the same API as its standard counterpart but is pre-minified and scoped to just that runtime (DOM, React, Node DOM, or Node React). Swap them in when you want the smallest possible bundles; otherwise the default exports keep working as-is.
288288

289+
## Common gotchas
290+
291+
### DocumentFragment reuse (DOM helper)
292+
293+
`jsx` returns actual DOM nodes, so fragments compile down to real `DocumentFragment` instances. The browser treats those fragments as one-time transport containers: append them to a parent, and the fragment empties itself as it moves its children. Unlike VDOM libraries (React, Preact, Solid), we do not clone fragments on your behalf, so storing a fragment and reusing it later will not work the way a React developer might expect.
294+
295+
```ts
296+
const header = jsx`
297+
<>
298+
<h1>Title</h1>
299+
<p>Reusable? Only if you clone.</p>
300+
</>
301+
`
302+
303+
document.querySelector('header')!.append(header)
304+
document.querySelector('footer')!.append(header) // footer stays empty
305+
```
306+
307+
When you need multiple copies, call the template again, wrap it in a helper (`const makeHeader = () => jsx`<...>`; makeHeader()`), or clone the fragment before reusing it (`footer.append(header.cloneNode(true))`). Components that return fragments are unaffected because every invocation produces a fresh fragment.
308+
289309
## Limitations
290310

291311
- Requires a DOM-like environment (it throws when `document` is missing).

codecov.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ coverage:
88
default:
99
target: 80.0
1010
threshold: 2.0
11+
cli:
12+
target: 55.0
13+
threshold: 5.0
14+
paths:
15+
- src/cli/

docs/cli.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ npx @knighted/jsx init
1010

1111
What it does by default:
1212

13-
- Installs `@oxc-parser/binding-wasm32-wasi` plus runtime helpers (`@napi-rs/wasm-runtime`, `@emnapi/runtime`, `@emnapi/core`).
13+
- Installs `@oxc-parser/binding-wasm32-wasi` that matches the library's bundled `oxc-parser` version plus runtime helpers (`@napi-rs/wasm-runtime`, `@emnapi/runtime`, `@emnapi/core`).
1414
- Records the binding in `optionalDependencies` so the version is visible in your project.
1515
- Verifies the binding can be imported and reports the resolved path.
1616
- Skips loader config changes (prompted only when you opt in).
@@ -19,6 +19,7 @@ What it does by default:
1919

2020
- `--package-manager`, `--pm <npm|pnpm|yarn|bun>`: override detection.
2121
- `--wasm-package <spec>`: install a different binding spec (or set `WASM_BINDING_PACKAGE`).
22+
- `--wasm-version <semver>`: override the default bundled version when using the standard binding package.
2223
- `--config`: prompt for loader help (no automatic edits yet; shows guidance only).
2324
- `--skip-config`: skip loader help (default).
2425
- `--dry-run`: print what would happen without executing.
@@ -40,12 +41,16 @@ npx @knighted/jsx init --pm npm
4041
# Prompt for loader guidance after install
4142
npx @knighted/jsx init --config
4243

43-
# Use a custom binding build
44-
WASM_BINDING_PACKAGE=@oxc-parser/binding-wasm32-wasi@^0.100.0 npx @knighted/jsx init
44+
# Install a specific binding version
45+
npx @knighted/jsx init --wasm-version 0.100.0
46+
47+
# Use a custom binding spec entirely
48+
WASM_BINDING_PACKAGE=@oxc-parser/binding-wasm32-wasi@beta npx @knighted/jsx init
4549
```
4650

4751
## Notes
4852

53+
- The default binding install always matches the `oxc-parser` version bundled with `@knighted/jsx`; use `--wasm-version`, `--wasm-package`, or `WASM_BINDING_PACKAGE` when you intentionally need a different build.
4954
- The command uses `npm pack` internally to pull the WASM binding even when it is marked for `cpu: ["wasm32"]`.
5055
- Loader configuration is opt-in and requires a prompt. No config files are modified unless you request help.
5156
- If verification fails, rerun with `--verbose` to see the resolved binding path and error details.

docs/how-it-compares.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,21 @@ Use this quick matrix to see how `@knighted/jsx` stacks up against other tagged-
1111
| SSR / Node | `@knighted/jsx/node` bootstraps `linkedom`/`jsdom` automatically; fixtures cover Next.js, Lit + React hybrids, and plain Node usage. | Depends on the hyperscript target/framework to provide SSR. | Provides DOM-focused SSR utilities but no automatic shims or React interop. |
1212
| TypeScript support | First-class typings for DOM + React runtimes, loader options, and Node helpers. | Community types only for the tag factory. | Minimal typings; templates rely on generic DOM types. |
1313
| Component interoperability | Mix DOM helpers, React components, Lit roots, and loader-transformed calls in one file. | Primarily a JSX stand-in for Preact or hyperscript. | Focused on DOM updates, pairs with `uhtml/async` or low-level renderers. |
14-
| Approx. size | DOM: ~8.8 kB raw / ~2.3 kB min+gzip. Lite DOM: ~5.7 kB raw / ~2.5 kB min+gzip. | ~1 kB min+gzip. | ~7 kB min+gzip. |
14+
| Approx. size | DOM: ~14.3 kB raw / ~3.3 kB min+gzip. Lite DOM: ~7.8 kB raw / ~3.3 kB min+gzip. | ~1 kB min+gzip. | ~7 kB min+gzip. |
1515

1616
> `htm` and `uhtml` remain excellent when you only need lightweight hyperscript or DOM templating. `@knighted/jsx` trades a slightly larger runtime for full JSX semantics, React parity, loaders, and SSR tooling.
1717
1818
> [!NOTE]
19-
> `@knighted/jsx` sizes were measured by gzipping `dist/jsx.js` (default runtime) and `dist/lite/index.js` from the latest build. The lite build is raw-smaller, but its minified output uses fewer repeated tokens, so gzip has a little less to compress (hence the slightly higher min+gzip size).
19+
> `@knighted/jsx` sizes were measured by gzipping `dist/jsx.js` (default runtime) and `dist/lite/index.js` from the latest build. The lite build stays raw-smaller, while gzip now lands within a few bytes of the default runtime because all helpers live in one bundle.
2020
2121
## Detailed size breakdown
2222

23-
| Entry point | Raw size (bytes) | Min+gzip size (bytes) | Lite raw size (bytes) | Lite min+gzip size (bytes) | Notes |
24-
| --------------------------------------------- | ---------------------------------------------------------------------------- | --------------------- | --------------------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- |
25-
| DOM runtime (`@knighted/jsx`) | 9,054 (`dist/jsx.js`) | 2,291 | 5,790 (`dist/lite/index.js`) | 2,512 | Lite bundle packs everything into one file, so gzip sees fewer repeated tokens despite the smaller raw payload. |
26-
| React runtime (`@knighted/jsx/react`) | 5,133 (`dist/react/react-jsx.js`) | 1,445 | 4,146 (`dist/lite/react/index.js`) | 1,885 | Same trade-off as DOM: raw-lite wins, gzip-lite is slightly larger because it inlines all helpers. |
27-
| Node DOM entry (`@knighted/jsx/node`) | 9,197 combined (`dist/jsx.js` + `dist/node/bootstrap.js` via dynamic import) |3,205 | 6,954 (`dist/lite/node/index.js`) | 3,007 | Lite bundle already includes the bootstrap shim, so both raw and gzipped footprints shrink end-to-end. |
28-
| Node React entry (`@knighted/jsx/node/react`) | 7,559 combined (`dist/react/react-jsx.js` + `dist/node/bootstrap.js`) | ≈2,359 | 4,146 (`dist/lite/node/react/index.js`) | 1,885 | Lite variant bundles React + bootstrap, cutting both raw and gzipped sizes. |
23+
| Entry point | Raw size (bytes) | Min+gzip size (bytes) | Lite raw size (bytes) | Lite min+gzip size (bytes) | Notes |
24+
| --------------------------------------------- | ----------------------------------------------------------------------------- | --------------------- | --------------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------- |
25+
| DOM runtime (`@knighted/jsx`) | 14,580 (`dist/jsx.js`) | 3,384 | 7,959 (`dist/lite/index.js`) | 3,351 | Lite bundle packs everything into one file, so gzip results now sit within a few bytes of the default runtime. |
26+
| React runtime (`@knighted/jsx/react`) | 5,166 (`dist/react/react-jsx.js`) | 1,454 | 4,556 (`dist/lite/react/index.js`) | 2,076 | Lite React remains raw-smaller but pays a small gzip premium because it inlines helper code. |
27+
| Node DOM entry (`@knighted/jsx/node`) | 17,006 combined (`dist/jsx.js` + `dist/node/bootstrap.js` via dynamic import) |4,298 | 9,131 (`dist/lite/node/index.js`) | 3,847 | Lite bundle already includes the bootstrap shim, so both raw and gzipped footprints shrink end-to-end. |
28+
| Node React entry (`@knighted/jsx/node/react`) | 7,592 combined (`dist/react/react-jsx.js` + `dist/node/bootstrap.js`) | ≈2,368 | 4,556 (`dist/lite/node/react/index.js`) | 2,076 | Lite variant bundles React + bootstrap, cutting both raw and gzipped sizes. |
2929

3030
> [!TIP]
3131
> Numbers were captured using `gzip -c <file> | wc -c`. “Combined” entries include every file the non-lite entry loads at runtime so you can compare end-to-end costs.

docs/next-steps.md

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,7 @@
22

33
A few focused improvements will give @knighted/jsx a more polished, batteries-included feel across editors, runtimes, and testing workflows.
44

5-
## 1. Type-level ergonomics
6-
7-
- Provide opt-in helper wrappers (for example `jsx.el<'button'>`) that return concrete `HTMLElement` types, plus richer React intrinsic typing for `reactJsx` so attribute completion matches native elements.
8-
- Clearly explain how to mix these helpers with existing components to keep type safety predictable across DOM and React runtimes.
9-
10-
## 2. Starter templates
11-
12-
- Publish StackBlitz/CodeSandbox starters (DOM only, React, Lit + React) and link them from the docs so newcomers can experiment without cloning the repo.
13-
- Include scripts that demonstrate the CDN-only workflow alongside bundler-driven builds.
14-
15-
## 3. Runtime diagnostics
16-
17-
- Add a development flag that logs friendly warnings for common pitfalls (missing `key`, passing plain strings instead of nodes, etc.) to shorten the feedback loop while prototyping.
5+
1. **Type-level ergonomics** – Explore opt-in helpers like `jsx.el<'button'>` (or richer intrinsic maps) so DOM nodes return concrete element types, and tighten the React intrinsic typing to match native attributes. Document how these helpers compose with existing components so the DX stays predictable.
6+
2. **Starter templates** – Ship StackBlitz/CodeSandbox starters (DOM-only, React, Lit + React) that highlight CDN flows and bundler builds. Link them in the README/docs so developers can experiment without cloning the repo.
7+
3. **Runtime diagnostics** – Add an optional dev flag that surfaces friendly warnings for common pitfalls (missing `key`, fragment reuse, string refs, etc.) to shorten debugging cycles without bloating production bundles.
8+
4. **Bundle-size trims** – Audit shared helpers/metadata for duplication, experiment with feature flags (opt-out of advanced descriptors), and run esbuild/rollup analyzers to find dead code so the next releases can claw back some of the new bytes.

eslint.config.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import js from '@eslint/js'
22
import pluginN from 'eslint-plugin-n'
33
import playwright from 'eslint-plugin-playwright'
4+
import unicorn from 'eslint-plugin-unicorn'
5+
import vitest from '@vitest/eslint-plugin'
46
import tseslint from 'typescript-eslint'
57

68
const playwrightConfig = playwright.configs['flat/recommended']
9+
const filenameCaseIgnore = ['^README(?:\\..+)?$']
710

811
export default [
912
{
@@ -62,13 +65,44 @@ export default [
6265
'@typescript-eslint/no-explicit-any': 'off',
6366
},
6467
},
68+
{
69+
plugins: {
70+
unicorn,
71+
},
72+
rules: {
73+
'unicorn/filename-case': [
74+
'error',
75+
{
76+
cases: {
77+
kebabCase: true,
78+
},
79+
ignore: filenameCaseIgnore,
80+
},
81+
],
82+
},
83+
},
84+
{
85+
...vitest.configs.recommended,
86+
files: ['test/**/*.test.{js,jsx,ts,tsx}', 'test/**/*.spec.{js,jsx,ts,tsx}'],
87+
},
6588
{
6689
files: ['src/jsx-runtime.ts'],
6790
rules: {
6891
'@typescript-eslint/no-unused-vars': 'off',
6992
'@typescript-eslint/no-namespace': 'off',
7093
},
7194
},
95+
{
96+
files: ['src/jsx-runtime.ts', 'test/jsx.test.ts'],
97+
rules: {
98+
'n/no-unsupported-features/node-builtins': [
99+
'error',
100+
{
101+
ignores: ['CustomEvent'],
102+
},
103+
],
104+
},
105+
},
72106
{
73107
...playwrightConfig,
74108
files: ['playwright/**/*.{ts,tsx,js}'],

examples/esm-demo.html

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -491,29 +491,29 @@ <h1>@knighted/jsx + esm.sh</h1>
491491
{
492492
entry: '@knighted/jsx/lite',
493493
context: 'DOM runtime helpers',
494-
raw: '~5.7 kB raw',
495-
gzip: '~2.5 kB min+gzip',
494+
raw: '~7.8 kB raw',
495+
gzip: '~3.3 kB min+gzip',
496496
note: 'Great for browser demos or DOM-like shims without bundlers.',
497497
},
498498
{
499499
entry: '@knighted/jsx/react/lite',
500500
context: 'React runtime helpers',
501-
raw: '~4.1 kB raw',
502-
gzip: '~1.9 kB min+gzip',
501+
raw: '~4.6 kB raw',
502+
gzip: '~2.1 kB min+gzip',
503503
note: 'Emits React elements so hooks and React 19 streaming just work.',
504504
},
505505
{
506506
entry: '@knighted/jsx/node/lite',
507507
context: 'Node DOM entry',
508-
raw: '~6.9 kB raw',
509-
gzip: '~3.0 kB min+gzip',
508+
raw: '~9.1 kB raw',
509+
gzip: '~3.8 kB min+gzip',
510510
note: 'Bundles the DOM shim bootstrap for headless SSR scripts.',
511511
},
512512
{
513513
entry: '@knighted/jsx/node/react/lite',
514514
context: 'Node React entry',
515-
raw: '~4.1 kB raw',
516-
gzip: '~1.9 kB min+gzip',
515+
raw: '~4.6 kB raw',
516+
gzip: '~2.1 kB min+gzip',
517517
note: jsx`<span>Combines the <code>reactJsx</code> helper with the DOM shim for hybrid SSR pipelines.</span>`,
518518
},
519519
]

0 commit comments

Comments
 (0)