7 Commits

Author SHA1 Message Date
4c396d9f60 Add repository field to package.json (#9)
Required for npm provenance verification — npm checks that the
repository URL matches the GitHub repo publishing the package.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 01:32:17 +00:00
8680ef66c8 Add setup-node step for npm registry auth (#8)
setup-node with registry-url configures the .npmrc with the OIDC
token needed for trusted publishing via bunx npm publish.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 01:29:36 +00:00
3827ccff23 Add top-level OIDC permissions to publish workflow (#7)
Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 01:23:37 +00:00
650e2141d8 Use bunx npm publish for trusted publishing (#6)
bun publish does not pick up the OIDC token for npm trusted publishing.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 01:19:35 +00:00
2d75600913 Bump version to 0.2.0 (#5)
Breaking: JrxNode is now a union type (JrxElement | null | undefined),
isJrxNode renamed to isJrxElement.

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 01:11:45 +00:00
69a8a8fe67 Merge pull request #4 from kennethnym/jrx-node-null-undefined
Support null and undefined in JrxNode type
2026-03-14 01:10:25 +00:00
4dcd184de0 Support null and undefined in JrxNode type
Split JrxNode into JrxElement (concrete branded object) and JrxNode
(JrxElement | null | undefined), mirroring React's ReactElement vs
ReactNode pattern. Components can now return null/undefined to render
nothing.

- Rename isJrxNode to isJrxElement (deprecated alias kept)
- Handle null-returning components in jsx/jsxs factories
- Add tests for null/undefined returns, children filtering, render()
  rejection

Co-authored-by: Ona <no-reply@ona.com>
2026-03-14 01:06:22 +00:00
8 changed files with 141 additions and 60 deletions

View File

@@ -4,6 +4,10 @@ on:
release: release:
types: [published] types: [published]
permissions:
id-token: write # Required for OIDC
contents: read
jobs: jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -15,12 +19,17 @@ jobs:
- uses: oven-sh/setup-bun@v2 - uses: oven-sh/setup-bun@v2
- uses: actions/setup-node@v4
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- run: bun install - run: bun install
- run: bun run build - run: bun run build
- run: bun test - run: bun test
- run: bun publish --access public --provenance - run: bunx npm publish --provenance --access public
env: env:
NPM_CONFIG_REGISTRY: https://registry.npmjs.org NPM_CONFIG_REGISTRY: https://registry.npmjs.org

View File

@@ -1,11 +1,15 @@
{ {
"name": "@nym.sh/jrx", "name": "@nym.sh/jrx",
"version": "0.1.0", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"description": "JSX factory for json-render. Write JSX, get Spec JSON.", "description": "JSX factory for json-render. Write JSX, get Spec JSON.",
"repository": {
"type": "git",
"url": "https://github.com/kennethnym/jrx"
},
"type": "module", "type": "module",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",

View File

@@ -1,3 +1,3 @@
export { render } from "./render"; export { render } from "./render";
export { isJrxNode, JRX_NODE, FRAGMENT } from "./types"; export { isJrxElement, isJrxNode, JRX_NODE, FRAGMENT } from "./types";
export type { JrxNode, JrxComponent, RenderOptions } from "./types"; export type { JrxElement, JrxNode, JrxComponent, RenderOptions } from "./types";

View File

@@ -2,7 +2,7 @@ import type {
ActionBinding, ActionBinding,
VisibilityCondition, VisibilityCondition,
} from "@json-render/core"; } from "@json-render/core";
import { JRX_NODE, FRAGMENT, type JrxNode } from "./types"; import { JRX_NODE, FRAGMENT, type JrxElement, type JrxNode } from "./types";
import type { JrxComponent } from "./types"; import type { JrxComponent } from "./types";
export { FRAGMENT as Fragment }; export { FRAGMENT as Fragment };
@@ -18,26 +18,26 @@ const RESERVED_PROPS = new Set([
]); ]);
/** /**
* Normalize a raw `children` value from JSX props into a flat array of JrxNodes. * Normalize a raw `children` value from JSX props into a flat array of JrxElements.
* Handles: undefined, single node, nested arrays, and filters out nulls/booleans. * Handles: undefined, single node, nested arrays, and filters out nulls/booleans.
*/ */
function normalizeChildren(raw: unknown): JrxNode[] { function normalizeChildren(raw: unknown): JrxElement[] {
if (raw == null || typeof raw === "boolean") return []; if (raw == null || typeof raw === "boolean") return [];
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
const result: JrxNode[] = []; const result: JrxElement[] = [];
for (const child of raw) { for (const child of raw) {
if (child == null || typeof child === "boolean") continue; if (child == null || typeof child === "boolean") continue;
if (Array.isArray(child)) { if (Array.isArray(child)) {
result.push(...normalizeChildren(child)); result.push(...normalizeChildren(child));
} else { } else {
result.push(child as JrxNode); result.push(child as JrxElement);
} }
} }
return result; return result;
} }
return [raw as JrxNode]; return [raw as JrxElement];
} }
/** /**
@@ -62,9 +62,9 @@ type JsxType = string | typeof FRAGMENT | JrxComponent;
* Core factory — shared by `jsx` and `jsxs`. * Core factory — shared by `jsx` and `jsxs`.
* *
* If `type` is a function, it is called with props (like React calls * If `type` is a function, it is called with props (like React calls
* function components). The function returns a JrxNode directly. * function components). The function may return null/undefined.
* *
* If `type` is a string or Fragment, a JrxNode is constructed inline. * If `type` is a string or Fragment, a JrxElement is constructed inline.
*/ */
function createNode( function createNode(
type: JsxType, type: JsxType,
@@ -73,6 +73,7 @@ function createNode(
const p = rawProps ?? {}; const p = rawProps ?? {};
// Function component — call it, just like React does. // Function component — call it, just like React does.
// May return null/undefined to render nothing.
if (typeof type === "function") { if (typeof type === "function") {
return type(p); return type(p);
} }
@@ -104,7 +105,9 @@ export function jsx(
key?: string, key?: string,
): JrxNode { ): JrxNode {
const node = createNode(type, props); const node = createNode(type, props);
if (key != null) node.key = String(key); // Key is intentionally dropped when a component returns null/undefined —
// there is no element to attach it to.
if (node != null && key != null) node.key = String(key);
return node; return node;
} }
@@ -118,7 +121,7 @@ export function jsxs(
key?: string, key?: string,
): JrxNode { ): JrxNode {
const node = createNode(type, props); const node = createNode(type, props);
if (key != null) node.key = String(key); if (node != null && key != null) node.key = String(key);
return node; return node;
} }
@@ -132,7 +135,7 @@ export namespace JSX {
[tag: string]: Record<string, unknown>; [tag: string]: Record<string, unknown>;
} }
/** The type returned by JSX expressions. */ /** The type returned by JSX expressions (may be null/undefined). */
export type Element = JrxNode; export type Element = JrxNode;
export interface ElementChildrenAttribute { export interface ElementChildrenAttribute {

View File

@@ -1,6 +1,6 @@
import { describe, it, expect } from "bun:test"; import { describe, it, expect } from "bun:test";
import { render } from "./render"; import { render } from "./render";
import { isJrxNode, FRAGMENT } from "./types"; import { isJrxElement, FRAGMENT } from "./types";
import { jsx, jsxs, Fragment } from "./jsx-runtime"; import { jsx, jsxs, Fragment } from "./jsx-runtime";
import { import {
Stack, Stack,
@@ -18,26 +18,26 @@ import {
// ============================================================================= // =============================================================================
describe("jsx factory", () => { describe("jsx factory", () => {
it("jsx() with string type returns a JrxNode", () => { it("jsx() with string type returns a JrxElement", () => {
const node = jsx("Card", { title: "Hello" }); const node = jsx("Card", { title: "Hello" });
expect(isJrxNode(node)).toBe(true); expect(isJrxElement(node)).toBe(true);
expect(node.type).toBe("Card"); expect(node!.type).toBe("Card");
expect(node.props).toEqual({ title: "Hello" }); expect(node!.props).toEqual({ title: "Hello" });
}); });
it("jsx() with component function resolves typeName", () => { it("jsx() with component function resolves typeName", () => {
const node = jsx(Card, { title: "Hello" }); const node = jsx(Card, { title: "Hello" });
expect(isJrxNode(node)).toBe(true); expect(isJrxElement(node)).toBe(true);
expect(node.type).toBe("Card"); expect(node!.type).toBe("Card");
expect(node.props).toEqual({ title: "Hello" }); expect(node!.props).toEqual({ title: "Hello" });
}); });
it("jsxs() returns a JrxNode with children", () => { it("jsxs() returns a JrxElement with children", () => {
const node = jsxs(Stack, { const node = jsxs(Stack, {
children: [jsx(Text, { content: "A" }), jsx(Text, { content: "B" })], children: [jsx(Text, { content: "A" }), jsx(Text, { content: "B" })],
}); });
expect(isJrxNode(node)).toBe(true); expect(isJrxElement(node)).toBe(true);
expect(node.children).toHaveLength(2); expect(node!.children).toHaveLength(2);
}); });
it("Fragment is the FRAGMENT symbol", () => { it("Fragment is the FRAGMENT symbol", () => {
@@ -46,8 +46,8 @@ describe("jsx factory", () => {
it("jsx() extracts key from third argument", () => { it("jsx() extracts key from third argument", () => {
const node = jsx(Card, { title: "Hi" }, "my-key"); const node = jsx(Card, { title: "Hi" }, "my-key");
expect(node.key).toBe("my-key"); expect(node!.key).toBe("my-key");
expect(node.props).toEqual({ title: "Hi" }); expect(node!.props).toEqual({ title: "Hi" });
}); });
it("jsx() extracts reserved props", () => { it("jsx() extracts reserved props", () => {
@@ -64,26 +64,70 @@ describe("jsx factory", () => {
watch, watch,
}); });
expect(node.props).toEqual({ label: "Go" }); expect(node!.props).toEqual({ label: "Go" });
expect(node.visible).toEqual(vis); expect(node!.visible).toEqual(vis);
expect(node.on).toEqual(on); expect(node!.on).toEqual(on);
expect(node.repeat).toEqual(repeat); expect(node!.repeat).toEqual(repeat);
expect(node.watch).toEqual(watch); expect(node!.watch).toEqual(watch);
}); });
it("jsx() handles null props", () => { it("jsx() handles null props", () => {
const node = jsx("Divider", null); const node = jsx("Divider", null);
expect(isJrxNode(node)).toBe(true); expect(isJrxElement(node)).toBe(true);
expect(node.props).toEqual({}); expect(node!.props).toEqual({});
expect(node.children).toEqual([]); expect(node!.children).toEqual([]);
}); });
it("jsx() filters null/boolean children", () => { it("jsx() filters null/boolean children", () => {
const node = jsxs(Stack, { const node = jsxs(Stack, {
children: [null, jsx(Text, { content: "A" }), false, undefined, true], children: [null, jsx(Text, { content: "A" }), false, undefined, true],
}); });
expect(node.children).toHaveLength(1); expect(node!.children).toHaveLength(1);
expect(node.children[0].type).toBe("Text"); expect(node!.children[0].type).toBe("Text");
});
});
// =============================================================================
// Null / undefined component returns
// =============================================================================
describe("null/undefined component returns", () => {
it("jsx() with a component returning null produces null", () => {
const NullComponent = (_props: Record<string, unknown>) => null;
const node = jsx(NullComponent, {});
expect(node).toBeNull();
expect(isJrxElement(node)).toBe(false);
});
it("jsx() with a component returning undefined produces undefined", () => {
const UndefinedComponent = (_props: Record<string, unknown>) => undefined;
const node = jsx(UndefinedComponent, {});
expect(node).toBeUndefined();
expect(isJrxElement(node)).toBe(false);
});
it("key argument is ignored when component returns null", () => {
const NullComponent = (_props: Record<string, unknown>) => null;
const node = jsx(NullComponent, {}, "my-key");
expect(node).toBeNull();
});
it("null-returning component children are filtered out", () => {
const NullComponent = (_props: Record<string, unknown>) => null;
const node = jsxs(Stack, {
children: [jsx(NullComponent, {}), jsx(Text, { content: "A" })],
});
expect(node!.children).toHaveLength(1);
expect(node!.children[0].type).toBe("Text");
});
it("undefined-returning component children are filtered out", () => {
const UndefinedComponent = (_props: Record<string, unknown>) => undefined;
const node = jsxs(Stack, {
children: [jsx(Text, { content: "A" }), jsx(UndefinedComponent, {})],
});
expect(node!.children).toHaveLength(1);
expect(node!.children[0].type).toBe("Text");
}); });
}); });

View File

@@ -300,7 +300,15 @@ describe("state passthrough", () => {
// ============================================================================= // =============================================================================
describe("error handling", () => { describe("error handling", () => {
it("throws for non-JrxNode input", () => { it("throws for non-JrxElement input", () => {
expect(() => render({} as JrxNode)).toThrow(/expects a JrxNode/); expect(() => render({} as JrxNode)).toThrow(/expects a JrxElement/);
});
it("throws when given null", () => {
expect(() => render(null)).toThrow(/expects a JrxElement/);
});
it("throws when given undefined", () => {
expect(() => render(undefined)).toThrow(/expects a JrxElement/);
}); });
}); });

View File

@@ -1,5 +1,5 @@
import type { Spec, UIElement } from "@json-render/core"; import type { Spec, UIElement } from "@json-render/core";
import { FRAGMENT, type JrxNode, type RenderOptions, isJrxNode } from "./types"; import { FRAGMENT, type JrxElement, type JrxNode, type RenderOptions, isJrxElement } from "./types";
/** /**
* Flatten a JrxNode tree into a json-render `Spec`. * Flatten a JrxNode tree into a json-render `Spec`.
@@ -21,8 +21,8 @@ import { FRAGMENT, type JrxNode, type RenderOptions, isJrxNode } from "./types";
* ``` * ```
*/ */
export function render(node: JrxNode, options?: RenderOptions): Spec { export function render(node: JrxNode, options?: RenderOptions): Spec {
if (!isJrxNode(node)) { if (!isJrxElement(node)) {
throw new Error("render() expects a JrxNode produced by JSX."); throw new Error("render() expects a JrxElement produced by JSX.");
} }
if (node.type === FRAGMENT) { if (node.type === FRAGMENT) {
@@ -66,12 +66,12 @@ function generateKey(
/** /**
* Resolve the children of a node, expanding fragments inline. * Resolve the children of a node, expanding fragments inline.
* Returns an array of concrete (non-fragment) JrxNodes. * Returns an array of concrete (non-fragment) JrxElements.
*/ */
function expandChildren(children: JrxNode[]): JrxNode[] { function expandChildren(children: JrxElement[]): JrxElement[] {
const result: JrxNode[] = []; const result: JrxElement[] = [];
for (const child of children) { for (const child of children) {
if (!isJrxNode(child)) continue; if (!isJrxElement(child)) continue;
if (child.type === FRAGMENT) { if (child.type === FRAGMENT) {
// Recursively expand nested fragments // Recursively expand nested fragments
result.push(...expandChildren(child.children)); result.push(...expandChildren(child.children));
@@ -83,11 +83,11 @@ function expandChildren(children: JrxNode[]): JrxNode[] {
} }
/** /**
* Recursively flatten a JrxNode into the elements map. * Recursively flatten a JrxElement into the elements map.
* Returns the key assigned to this node. * Returns the key assigned to this node.
*/ */
function flattenNode( function flattenNode(
node: JrxNode, node: JrxElement,
elements: Record<string, UIElement>, elements: Record<string, UIElement>,
counters: Map<string, number>, counters: Map<string, number>,
usedKeys: Set<string>, usedKeys: Set<string>,

View File

@@ -19,12 +19,14 @@ export const JRX_NODE = Symbol.for("jrx.node");
export const FRAGMENT = Symbol.for("jrx.fragment"); export const FRAGMENT = Symbol.for("jrx.fragment");
/** /**
* A node in the intermediate JSX tree. * A concrete element in the intermediate JSX tree.
* *
* Created by the `jsx` / `jsxs` factory functions and consumed by `render()` * Created by the `jsx` / `jsxs` factory functions and consumed by `render()`
* which flattens the tree into a json-render `Spec`. * which flattens the tree into a json-render `Spec`.
*
* Analogous to React's `ReactElement`.
*/ */
export interface JrxNode { export interface JrxElement {
/** Brand symbol — always `JRX_NODE` */ /** Brand symbol — always `JRX_NODE` */
$$typeof: typeof JRX_NODE; $$typeof: typeof JRX_NODE;
@@ -38,7 +40,7 @@ export interface JrxNode {
props: Record<string, unknown>; props: Record<string, unknown>;
/** Child nodes */ /** Child nodes */
children: JrxNode[]; children: JrxElement[];
// -- Reserved / meta fields (extracted from JSX props) -- // -- Reserved / meta fields (extracted from JSX props) --
@@ -58,6 +60,14 @@ export interface JrxNode {
watch: Record<string, ActionBinding | ActionBinding[]> | undefined; watch: Record<string, ActionBinding | ActionBinding[]> | undefined;
} }
/**
* Any value that can appear as a JSX child or component return value.
*
* Analogous to React's `ReactNode` — includes `null` and `undefined`
* so components can conditionally render nothing.
*/
export type JrxNode = JrxElement | null | undefined;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// JrxComponent — a function usable as a JSX tag that maps to a type string // JrxComponent — a function usable as a JSX tag that maps to a type string
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -65,7 +75,7 @@ export interface JrxNode {
/** /**
* A jrx component function. Works like a React function component: * A jrx component function. Works like a React function component:
* when used as a JSX tag (`<Card />`), the factory calls the function * when used as a JSX tag (`<Card />`), the factory calls the function
* with props and gets back a JrxNode. * with props and gets back a JrxNode (which may be null/undefined).
*/ */
export type JrxComponent = (props: Record<string, unknown>) => JrxNode; export type JrxComponent = (props: Record<string, unknown>) => JrxNode;
@@ -85,7 +95,7 @@ export type JrxComponent = (props: Record<string, unknown>) => JrxNode;
export function component(typeName: string): JrxComponent { export function component(typeName: string): JrxComponent {
// Import createNodeFromString lazily to avoid circular dep // Import createNodeFromString lazily to avoid circular dep
// (jsx-runtime imports types). Instead, we build the node inline. // (jsx-runtime imports types). Instead, we build the node inline.
return (props: Record<string, unknown>) => { return (props: Record<string, unknown>): JrxElement => {
return { return {
$$typeof: JRX_NODE, $$typeof: JRX_NODE,
type: typeName, type: typeName,
@@ -110,21 +120,21 @@ function filterReserved(props: Record<string, unknown>): Record<string, unknown>
return out; return out;
} }
function normalizeChildrenRaw(raw: unknown): JrxNode[] { function normalizeChildrenRaw(raw: unknown): JrxElement[] {
if (raw == null || typeof raw === "boolean") return []; if (raw == null || typeof raw === "boolean") return [];
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
const result: JrxNode[] = []; const result: JrxElement[] = [];
for (const child of raw) { for (const child of raw) {
if (child == null || typeof child === "boolean") continue; if (child == null || typeof child === "boolean") continue;
if (Array.isArray(child)) { if (Array.isArray(child)) {
result.push(...normalizeChildrenRaw(child)); result.push(...normalizeChildrenRaw(child));
} else { } else {
result.push(child as JrxNode); result.push(child as JrxElement);
} }
} }
return result; return result;
} }
return [raw as JrxNode]; return [raw as JrxElement];
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -140,10 +150,13 @@ export interface RenderOptions {
// Type guard // Type guard
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function isJrxNode(value: unknown): value is JrxNode { export function isJrxElement(value: unknown): value is JrxElement {
return ( return (
typeof value === "object" && typeof value === "object" &&
value !== null && value !== null &&
(value as JrxNode).$$typeof === JRX_NODE (value as JrxElement).$$typeof === JRX_NODE
); );
} }
/** @deprecated Use `isJrxElement` instead. */
export const isJrxNode = isJrxElement;