Skip to content

Commit 188228f

Browse files
fix: node tla, suffix handling. (#49)
1 parent 8baacb4 commit 188228f

12 files changed

Lines changed: 475 additions & 224 deletions

docs/runtime-helpers-overview.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
This note explains how the runtime helpers behave when you call them directly—without involving the loader. Both helpers are plain JavaScript tagged template literals that lean on the `oxc-parser` WebAssembly build to interpret JSX syntax at execution time.
44

5+
> [!IMPORTANT]
6+
> Runtime `jsx` calls must include literal JSX braces around attribute/child expressions (for example, `onClick={${handler}}`). Only the loader and TypeScript plugin auto-insert those braces; direct helper usage feeds the template straight into the parser and will throw if braces are missing.
7+
58
## What happens when you call the helpers?
69

710
1. **Template literal evaluation** – JavaScript collects the raw string segments (`strings`) and evaluated expressions (`values`) for the tagged template.
@@ -17,6 +20,9 @@ This note explains how the runtime helpers behave when you call them directly—
1720
- Works in browsers out of the box. In Node you can import `@knighted/jsx/node` to bootstrap a DOM shim (tries `linkedom`, then `jsdom`).
1821
- Expressions inside the template (props, children, dynamic tags) are evaluated before parsing—no special placeholder syntax is required.
1922

23+
> [!IMPORTANT]
24+
> The runtime parser only understands literal JSX syntax, so attribute expressions must include braces in the template source (e.g. `onClick={${handler}}`). The loader/plugin pipeline can insert those braces for you, but direct calls to ` jsx`` ` must already contain them.
25+
2026
Example:
2127

2228
```ts

package-lock.json

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

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/jsx",
3-
"version": "1.5.1",
3+
"version": "1.5.2",
44
"description": "Runtime JSX tagged template that renders DOM or React trees anywhere without a build step.",
55
"keywords": [
66
"jsx runtime",
@@ -162,7 +162,7 @@
162162
"optionalDependencies": {
163163
"@oxc-parser/binding-darwin-arm64": "^0.105.0",
164164
"@oxc-parser/binding-linux-x64-gnu": "^0.105.0",
165-
"@oxc-parser/binding-wasm32-wasi": "0.105.0"
165+
"@oxc-parser/binding-wasm32-wasi": "^0.105.0"
166166
},
167167
"overrides": {
168168
"module-lookup-amd": {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type {
2+
Expression,
3+
JSXAttribute,
4+
JSXElement,
5+
JSXFragment,
6+
JSXSpreadAttribute,
7+
} from '@oxc-project/types'
8+
import type { TemplateComponent, TemplateContext } from '../runtime/shared.js'
9+
10+
export type Namespace = 'svg' | null
11+
12+
export type EvaluateExpressionWithNamespace<TComponent extends TemplateComponent> = (
13+
expression: Expression | JSXElement | JSXFragment,
14+
ctx: TemplateContext<TComponent>,
15+
namespace: Namespace,
16+
) => unknown
17+
18+
export type ResolveAttributesDependencies<TComponent extends TemplateComponent> = {
19+
getIdentifierName: (name: JSXAttribute['name']) => string
20+
evaluateExpressionWithNamespace: EvaluateExpressionWithNamespace<TComponent>
21+
}
22+
23+
export type ResolveAttributesFn<TComponent extends TemplateComponent> = (
24+
attributes: (JSXAttribute | JSXSpreadAttribute)[],
25+
ctx: TemplateContext<TComponent>,
26+
namespace: Namespace,
27+
) => Record<string, unknown>
28+
29+
export const createResolveAttributes = <TComponent extends TemplateComponent>(
30+
deps: ResolveAttributesDependencies<TComponent>,
31+
): ResolveAttributesFn<TComponent> => {
32+
const { getIdentifierName, evaluateExpressionWithNamespace } = deps
33+
34+
return (attributes, ctx, namespace) => {
35+
const props: Record<string, unknown> = {}
36+
37+
attributes.forEach(attribute => {
38+
if (attribute.type === 'JSXSpreadAttribute') {
39+
const spreadValue = evaluateExpressionWithNamespace(
40+
attribute.argument,
41+
ctx,
42+
namespace,
43+
)
44+
45+
if (
46+
spreadValue &&
47+
typeof spreadValue === 'object' &&
48+
!Array.isArray(spreadValue)
49+
) {
50+
Object.assign(props, spreadValue)
51+
}
52+
53+
return
54+
}
55+
56+
const name = getIdentifierName(attribute.name)
57+
58+
if (!attribute.value) {
59+
props[name] = true
60+
return
61+
}
62+
63+
if (attribute.value.type === 'Literal') {
64+
props[name] = attribute.value.value
65+
return
66+
}
67+
68+
if (attribute.value.type === 'JSXExpressionContainer') {
69+
if (attribute.value.expression.type === 'JSXEmptyExpression') {
70+
return
71+
}
72+
73+
props[name] = evaluateExpressionWithNamespace(
74+
attribute.value.expression,
75+
ctx,
76+
namespace,
77+
)
78+
}
79+
})
80+
81+
return props
82+
}
83+
}

src/internal/event-bindings.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
const captureSuffix = 'Capture'
2+
3+
export type ParsedEventBinding = {
4+
eventName: string
5+
capture: boolean
6+
}
7+
8+
const stripCaptureSuffix = (rawName: string): ParsedEventBinding => {
9+
if (rawName.endsWith(captureSuffix)) {
10+
return { eventName: rawName.slice(0, -captureSuffix.length), capture: true }
11+
}
12+
13+
return { eventName: rawName, capture: false }
14+
}
15+
16+
export const parseEventPropName = (name: string): ParsedEventBinding | null => {
17+
if (!name.startsWith('on')) {
18+
return null
19+
}
20+
21+
if (name.startsWith('on:')) {
22+
const raw = name.slice(3)
23+
if (!raw) {
24+
return null
25+
}
26+
const parsed = stripCaptureSuffix(raw)
27+
if (!parsed.eventName) {
28+
return null
29+
}
30+
return parsed
31+
}
32+
33+
const raw = name.slice(2)
34+
if (!raw) {
35+
return null
36+
}
37+
38+
const parsed = stripCaptureSuffix(raw)
39+
if (!parsed.eventName) {
40+
return null
41+
}
42+
43+
return {
44+
eventName: parsed.eventName.toLowerCase(),
45+
capture: parsed.capture,
46+
}
47+
}
48+
49+
const isEventListenerObject = (value: unknown): value is EventListenerObject => {
50+
if (!value || typeof value !== 'object') {
51+
return false
52+
}
53+
54+
return (
55+
'handleEvent' in (value as Record<string, unknown>) &&
56+
typeof (value as EventListenerObject).handleEvent === 'function'
57+
)
58+
}
59+
60+
export type EventHandlerDescriptor = {
61+
handler: EventListenerOrEventListenerObject
62+
capture?: boolean
63+
once?: boolean
64+
passive?: boolean
65+
signal?: AbortSignal | null
66+
options?: AddEventListenerOptions
67+
}
68+
69+
const isEventHandlerDescriptor = (value: unknown): value is EventHandlerDescriptor => {
70+
if (!value || typeof value !== 'object' || !('handler' in value)) {
71+
return false
72+
}
73+
74+
const handler = (value as EventHandlerDescriptor).handler
75+
if (typeof handler === 'function') {
76+
return true
77+
}
78+
79+
return isEventListenerObject(handler)
80+
}
81+
82+
export type ResolvedEventHandler = {
83+
listener: EventListenerOrEventListenerObject
84+
options?: AddEventListenerOptions
85+
}
86+
87+
export const resolveEventHandlerValue = (value: unknown): ResolvedEventHandler | null => {
88+
if (typeof value === 'function' || isEventListenerObject(value)) {
89+
return { listener: value as EventListenerOrEventListenerObject }
90+
}
91+
92+
if (!isEventHandlerDescriptor(value)) {
93+
return null
94+
}
95+
96+
const descriptor = value
97+
let options = descriptor.options ? { ...descriptor.options } : undefined
98+
99+
const assignOption = <K extends keyof AddEventListenerOptions>(
100+
key: K,
101+
optionValue: AddEventListenerOptions[K] | null | undefined,
102+
) => {
103+
if (optionValue === undefined || optionValue === null) {
104+
return
105+
}
106+
if (!options) {
107+
options = {}
108+
}
109+
options[key] = optionValue
110+
}
111+
112+
assignOption('capture', descriptor.capture)
113+
assignOption('once', descriptor.once)
114+
assignOption('passive', descriptor.passive)
115+
assignOption('signal', descriptor.signal ?? undefined)
116+
117+
return {
118+
listener: descriptor.handler,
119+
options,
120+
}
121+
}

0 commit comments

Comments
 (0)