Skip to content

Commit ae32caf

Browse files
committed
✨ add ops validation with TypeBox schemas
Introduce a `validate` module that uses @sinclair/typebox to enforce correct op structure and numeric ranges (u8/u16/u32) before rendering. Exports `validate`, `assert`, and a `validated` Term wrapper. Adds the `./validate` subpath export.
1 parent 6c87ca7 commit ae32caf

4 files changed

Lines changed: 271 additions & 3 deletions

File tree

deno.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@
1111
"imports": {
1212
"@std/testing": "jsr:@std/testing@1",
1313
"@std/expect": "jsr:@std/expect@1",
14+
"@sinclair/typebox": "npm:@sinclair/typebox@^0.34",
1415
"dnt": "jsr:@deno/dnt@0.42.3"
1516
},
16-
"exports": "./mod.ts",
17+
"exports": {
18+
".": "./mod.ts",
19+
"./validate": "./validate.ts"
20+
},
1721
"publish": {
1822
"include": ["*.ts", "clayterm.wasm"]
1923
},

deno.lock

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

test/validate.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { beforeEach, describe, expect, it } from "./suite.ts";
2+
import { createTerm, type Term } from "../term.ts";
3+
import { close, grow, open, rgba, text } from "../ops.ts";
4+
import { assert, validate, validated } from "../validate.ts";
5+
import { print } from "./print.ts";
6+
7+
const decode = (bytes: Uint8Array) => new TextDecoder().decode(bytes);
8+
9+
describe("validate", () => {
10+
it("accepts valid ops", () => {
11+
expect(validate([
12+
open("root", { layout: { width: grow(), height: grow() } }),
13+
text("hello"),
14+
close(),
15+
])).toBe(true);
16+
});
17+
18+
it("accepts empty array", () => {
19+
expect(validate([])).toBe(true);
20+
});
21+
22+
it("rejects ops with wrong id", () => {
23+
expect(validate([{ id: 0xff }])).toBe(false);
24+
});
25+
26+
it("rejects open element missing name", () => {
27+
expect(validate([{ id: 0x02 }])).toBe(false);
28+
});
29+
30+
it("rejects text missing content", () => {
31+
expect(validate([{ id: 0x03 }])).toBe(false);
32+
});
33+
34+
it("rejects non-array", () => {
35+
expect(validate("garbage")).toBe(false);
36+
});
37+
38+
it("rejects null", () => {
39+
expect(validate(null)).toBe(false);
40+
});
41+
42+
it("assert throws TypeError on bad input", () => {
43+
expect(() => assert([{ id: 0x02 }])).toThrow(TypeError);
44+
});
45+
46+
it("rejects padding > 255 (u8 overflow)", () => {
47+
expect(validate([
48+
open("x", { layout: { padding: { left: 300 } } }),
49+
close(),
50+
])).toBe(false);
51+
});
52+
53+
it("rejects fractional padding", () => {
54+
expect(validate([
55+
open("x", { layout: { padding: { left: 1.5 } } }),
56+
close(),
57+
])).toBe(false);
58+
});
59+
60+
it("rejects fontSize > 255", () => {
61+
expect(validate([text("hi", { fontSize: 256 })])).toBe(false);
62+
});
63+
64+
it("rejects gap > 65535 (u16 overflow)", () => {
65+
expect(validate([
66+
open("x", { layout: { gap: 70000 } }),
67+
close(),
68+
])).toBe(false);
69+
});
70+
71+
it("rejects negative border width", () => {
72+
expect(validate([
73+
open("x", { border: { color: 0xFF0000, left: -1 } }),
74+
close(),
75+
])).toBe(false);
76+
});
77+
78+
it("rejects fractional color", () => {
79+
expect(validate([text("hi", { color: 1.5 })])).toBe(false);
80+
});
81+
});
82+
83+
describe("validated", () => {
84+
let term: Term;
85+
86+
beforeEach(async () => {
87+
term = validated(await createTerm({ width: 40, height: 10 }));
88+
});
89+
90+
it("renders valid ops normally", () => {
91+
const out = print(
92+
decode(term.render([
93+
open("root", {
94+
layout: { width: grow(), height: grow(), direction: "ttb" },
95+
}),
96+
text("Hello, World!"),
97+
close(),
98+
])),
99+
40,
100+
10,
101+
);
102+
expect(out).toContain("Hello, World!");
103+
});
104+
105+
it("throws on invalid ops", () => {
106+
// deno-lint-ignore no-explicit-any
107+
expect(() => term.render([{ id: 0xff }] as any)).toThrow(TypeError);
108+
});
109+
});

validate.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { Type } from "@sinclair/typebox";
2+
import { TypeCompiler } from "@sinclair/typebox/compiler";
3+
import type { Op } from "./ops.ts";
4+
import type { Term } from "./term.ts";
5+
6+
/* ── Range helpers (match bit-packing in pack()) ──────────────────── */
7+
8+
const u8 = Type.Integer({ minimum: 0, maximum: 255 });
9+
const u16 = Type.Integer({ minimum: 0, maximum: 65535 });
10+
const u32 = Type.Integer({ minimum: 0, maximum: 0xFFFFFFFF });
11+
12+
/* ── Sizing axis (discriminated union) ────────────────────────────── */
13+
14+
const Fit = Type.Object({
15+
type: Type.Literal("fit"),
16+
min: Type.Optional(Type.Number()),
17+
max: Type.Optional(Type.Number()),
18+
});
19+
20+
const Grow = Type.Object({
21+
type: Type.Literal("grow"),
22+
min: Type.Optional(Type.Number()),
23+
max: Type.Optional(Type.Number()),
24+
});
25+
26+
const Percent = Type.Object({
27+
type: Type.Literal("percent"),
28+
value: Type.Number(),
29+
});
30+
31+
const Fixed = Type.Object({
32+
type: Type.Literal("fixed"),
33+
value: Type.Number(),
34+
});
35+
36+
const SizingAxis = Type.Union([Fit, Grow, Percent, Fixed]);
37+
38+
/* ── Sub-objects ──────────────────────────────────────────────────── */
39+
40+
const Padding = Type.Object({
41+
left: Type.Optional(u8),
42+
right: Type.Optional(u8),
43+
top: Type.Optional(u8),
44+
bottom: Type.Optional(u8),
45+
});
46+
47+
const Layout = Type.Object({
48+
width: Type.Optional(SizingAxis),
49+
height: Type.Optional(SizingAxis),
50+
padding: Type.Optional(Padding),
51+
gap: Type.Optional(u16),
52+
direction: Type.Optional(
53+
Type.Union([Type.Literal("ltr"), Type.Literal("ttb")]),
54+
),
55+
alignX: Type.Optional(u8),
56+
alignY: Type.Optional(u8),
57+
});
58+
59+
const CornerRadius = Type.Object({
60+
tl: Type.Optional(u8),
61+
tr: Type.Optional(u8),
62+
bl: Type.Optional(u8),
63+
br: Type.Optional(u8),
64+
});
65+
66+
const Border = Type.Object({
67+
color: u32,
68+
left: Type.Optional(u8),
69+
right: Type.Optional(u8),
70+
top: Type.Optional(u8),
71+
bottom: Type.Optional(u8),
72+
});
73+
74+
const Clip = Type.Object({
75+
horizontal: Type.Optional(Type.Boolean()),
76+
vertical: Type.Optional(Type.Boolean()),
77+
});
78+
79+
const Floating = Type.Object({
80+
x: Type.Optional(Type.Number()),
81+
y: Type.Optional(Type.Number()),
82+
parent: Type.Optional(Type.Integer({ minimum: 0 })),
83+
attachTo: Type.Optional(u8),
84+
attachPoints: Type.Optional(u8),
85+
zIndex: Type.Optional(u16),
86+
});
87+
88+
/* ── Op types (discriminated on `id`) ─────────────────────────────── */
89+
90+
const CloseElement = Type.Object({ id: Type.Literal(0x04) });
91+
92+
const OpenElement = Type.Object({
93+
id: Type.Literal(0x02),
94+
name: Type.String(),
95+
layout: Type.Optional(Layout),
96+
bg: Type.Optional(u32),
97+
cornerRadius: Type.Optional(CornerRadius),
98+
border: Type.Optional(Border),
99+
clip: Type.Optional(Clip),
100+
floating: Type.Optional(Floating),
101+
});
102+
103+
const TextOp = Type.Object({
104+
id: Type.Literal(0x03),
105+
content: Type.String(),
106+
color: Type.Optional(u32),
107+
fontSize: Type.Optional(u8),
108+
fontId: Type.Optional(u8),
109+
wrap: Type.Optional(u8),
110+
attrs: Type.Optional(u8),
111+
});
112+
113+
const Ops = Type.Array(Type.Union([OpenElement, TextOp, CloseElement]));
114+
115+
/* ── Compiled validator ───────────────────────────────────────────── */
116+
117+
const compiled = TypeCompiler.Compile(Ops);
118+
119+
export function validate(ops: unknown): ops is Op[] {
120+
return compiled.Check(ops);
121+
}
122+
123+
export function assert(ops: unknown): asserts ops is Op[] {
124+
if (!compiled.Check(ops)) {
125+
const errors = [...compiled.Errors(ops)];
126+
const msg = errors
127+
.slice(0, 5)
128+
.map((e) => `${e.path}: ${e.message}`)
129+
.join("\n");
130+
throw new TypeError(`Invalid ops:\n${msg}`);
131+
}
132+
}
133+
134+
/* ── Term wrapper ─────────────────────────────────────────────────── */
135+
136+
export function validated(term: Term): Term {
137+
return {
138+
render(ops: Op[]): Uint8Array {
139+
assert(ops);
140+
return term.render(ops);
141+
},
142+
};
143+
}

0 commit comments

Comments
 (0)