[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay
5

Configure Feed

Select the types of activity you want to include in your feed.

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

Charles Lowell (Mar 23, 2026, 11:18 AM -0500) ae32caf4 6c87ca79

+271 -3
+5 -1
deno.json
··· 11 11 "imports": { 12 12 "@std/testing": "jsr:@std/testing@1", 13 13 "@std/expect": "jsr:@std/expect@1", 14 + "@sinclair/typebox": "npm:@sinclair/typebox@^0.34", 14 15 "dnt": "jsr:@deno/dnt@0.42.3" 15 16 }, 16 - "exports": "./mod.ts", 17 + "exports": { 18 + ".": "./mod.ts", 19 + "./validate": "./validate.ts" 20 + }, 17 21 "publish": { 18 22 "include": ["*.ts", "clayterm.wasm"] 19 23 },
+14 -2
deno.lock
··· 14 14 "jsr:@std/path@^1.1.4": "1.1.4", 15 15 "jsr:@std/testing@1": "1.0.17", 16 16 "jsr:@ts-morph/bootstrap@0.27": "0.27.0", 17 - "jsr:@ts-morph/common@0.27": "0.27.0" 17 + "jsr:@ts-morph/common@0.27": "0.27.0", 18 + "npm:@sinclair/typebox@*": "0.34.48", 19 + "npm:@sinclair/typebox@0.34": "0.34.48", 20 + "npm:valrs@*": "0.1.0" 18 21 }, 19 22 "jsr": { 20 23 "@david/code-block-writer@13.0.3": { ··· 83 86 ] 84 87 } 85 88 }, 89 + "npm": { 90 + "@sinclair/typebox@0.34.48": { 91 + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==" 92 + }, 93 + "valrs@0.1.0": { 94 + "integrity": "sha512-BqVkjx3qhsRLHerblLDoqEx0OEx7ms0DB6LPv40oWkMfFKUVKrqVuklaGdrPrHyubC5hSHYfEtUiQXrCkC6xHQ==" 95 + } 96 + }, 86 97 "workspace": { 87 98 "dependencies": [ 88 99 "jsr:@deno/dnt@0.42.3", 89 100 "jsr:@std/expect@1", 90 - "jsr:@std/testing@1" 101 + "jsr:@std/testing@1", 102 + "npm:@sinclair/typebox@0.34" 91 103 ] 92 104 } 93 105 }
+109
test/validate.test.ts
··· 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 + });
+143
validate.ts
··· 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 + }