diff --git a/src/demo/DslExplorerPage.tsx b/src/demo/DslExplorerPage.tsx
new file mode 100644
index 0000000..14dee4d
--- /dev/null
+++ b/src/demo/DslExplorerPage.tsx
@@ -0,0 +1,82 @@
+import { useState } from "react";
+import { tokenizeExpression, parseExpression, validateAst, extractDependencies, FUNCTION_SIGNATURES, type Expr, type Token } from "../lib/questionnaire/dsl";
+import { QuestionnaireEngine } from "../lib/questionnaire/questionnaire-engine";
+import starter from "./editor-questionnaire.json";
+import { JsonViewer } from "./JsonViewer";
+
+const examples = [
+ { name: "Eligibility", expression: "age(answer('dateOfBirth'), today()) >= 18 && answer('consent') === true" },
+ { name: "Precedence", expression: "1 + 2 * 3" },
+ { name: "Branching", expression: "derived('inPerson') ? 'Welcome to the venue' : 'We will email your joining link'" },
+ { name: "Collections", expression: "contains(answer('topics'), 'DESIGN') && count(answer('topics')) <= 2" },
+ { name: "Validation value", expression: "length(trim(value)) >= 3" },
+ { name: "Null fallback", expression: "answer('experience') ?? 0" },
+ { name: "Short circuit", expression: "false && (1 / 0 > 0)" },
+ { name: "Parser error", expression: "1 +" },
+ { name: "Validation error", expression: "unknown(1)" },
+ { name: "Evaluation error", expression: "1 / 0" },
+];
+const initialContext = JSON.stringify({
+ answers: { name: "Alex", dateOfBirth: "1992-03-04", attendance: "IN_PERSON", guests: 1, needsSupport: false, topics: ["DESIGN"], experience: 2, consent: true },
+ value: "Alex", today: "2026-09-28",
+}, null, 2);
+const engine = new QuestionnaireEngine(starter);
+type Stage = { name: string; status: "Passed" | "Failed" | "Not run"; detail: string };
+function inspect(expression: string, context: string) {
+ const stages: Stage[] = [
+ { name: "Tokenizer", status: "Not run", detail: "Split source into tokens." },
+ { name: "Parser", status: "Not run", detail: "Build an AST using operator precedence." },
+ { name: "Validation", status: "Not run", detail: "Check allowed identifiers, function names, arity, and literal IDs." },
+ { name: "Evaluation", status: "Not run", detail: "Run with the questionnaire session and input context." },
+ ];
+ let tokens: Token[] = [], ast: Expr | undefined, result: unknown;
+ let index = 0;
+ try {
+ tokens = tokenizeExpression(expression); stages[0] = { ...stages[0], status: "Passed", detail: `${tokens.length} tokens, including end of input.` }; index++;
+ ast = parseExpression(expression); stages[1] = { ...stages[1], status: "Passed", detail: `Root node: ${ast.type}.` }; index++;
+ validateAst(ast); stages[2] = { ...stages[2], status: "Passed", detail: "Expression passes DSL validation." }; index++;
+ const input = JSON.parse(context);
+ if (!input || typeof input !== "object" || Array.isArray(input) || !input.answers || typeof input.answers !== "object" || Array.isArray(input.answers) || typeof input.today !== "string") throw new Error("Context needs an answers object and a today date string.");
+ result = engine.createSession(input.answers, { today: input.today }).evaluate(expression, input.value);
+ stages[3] = { ...stages[3], status: "Passed", detail: `Result type: ${result === null ? "null" : Array.isArray(result) ? "array" : typeof result}.` };
+ } catch (cause) { stages[index] = { ...stages[index], status: "Failed", detail: cause instanceof Error ? cause.message : String(cause) }; }
+ return { expression, context, stages, tokens, ast, result };
+}
+
+function AstTree({ node, edge = "root" }: { node: Expr; edge?: string }) {
+ const title = node.type === "literal" ? `literal ${JSON.stringify(node.value)}` : node.type === "call" ? `call ${node.name}()` : node.type === "identifier" ? `identifier ${node.name}` : node.type === "binary" || node.type === "unary" ? `${node.type} ${node.operator}` : node.type;
+ const children: [string, Expr][] = node.type === "binary" ? [["left", node.left], ["right", node.right]] : node.type === "unary" ? [["argument", node.argument]] : node.type === "conditional" ? [["condition", node.condition], ["true", node.whenTrue], ["false", node.whenFalse]] : node.type === "call" ? node.args.map((arg, i) => [`arg ${i + 1}`, arg]) : node.type === "array" ? node.items.map((item, i) => [`item ${i}`, item]) : [];
+ return
{children.length ? {edge} {title}{children.map(([key, child]) => )} : {edge} {title}
} ;
+}
+
+export function DslExplorerPage() {
+ const [expression, setExpression] = useState(examples[0].expression);
+ const [context, setContext] = useState(initialContext);
+ const [analysis, setAnalysis] = useState(() => inspect(examples[0].expression, initialContext));
+ const [tab, setTab] = useState("AST tree");
+ const stale = expression !== analysis.expression || context !== analysis.context;
+ const tabs = ["AST tree", "AST JSON", "Dependencies", "Functions"];
+ return
+
+
+
Expression Load an example { const item = examples[Number(event.target.value)]; if (item) { setExpression(item.expression); setAnalysis(inspect(item.expression, context)); } }}>Choose an example… {examples.map((item, index) => {item.name} )}
+ DSL expression
+ Run with Ctrl/⌘ + Enter. Strings use quotes; comparisons use === and !==. Arithmetic binds before comparisons, then &&, ||, and ??.
+ {stale ? "Inputs changed — run to update" : "Evaluation result"} {analysis.stages[3].status === "Passed" ?
{JSON.stringify(analysis.result, null, 2)} :
{analysis.stages.find(stage => stage.status === "Failed")?.detail}
}
+
+
Evaluation context Uses the Workshop registration schema. Edit plain answer values, the validation variable value, and the date returned by today(). Hidden or invalid answers resolve to null.
Context JSON Available questionnaire fields and derived values
+
+ {analysis.stages.map((stage, index) => {index + 1} · {stage.status} {stage.name} {stage.detail}
)}
+ Tokenizer output Actual lexer output. Positions are zero-based offsets in the last run’s source; string token values are decoded. The parser turns true, false, and null identifiers into literals.
{analysis.expression}
+ {analysis.tokens.length ? {analysis.tokens.map((token, index) =>
{token.type} · @{token.position} {token.type === "eof" ? "EOF" : JSON.stringify(token.value)}
)}
: No token stream: tokenization failed.
}
+
+ {tabs.map((name, index) => setTab(name)} onKeyDown={event => {
+ const next = event.key === "ArrowRight" ? (index + 1) % tabs.length : event.key === "ArrowLeft" ? (index + tabs.length - 1) % tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : undefined;
+ if (next !== undefined) { event.preventDefault(); setTab(tabs[next]); document.getElementById(`dsl-tab-${next}`)?.focus(); }
+ }}>{name} )}
+
+ {tab === "Functions" ? <>
Built-in function signatures from the engine. The result type of answer and derived depends on the referenced field or expression.
Function Argument types Returns {Object.entries(FUNCTION_SIGNATURES).map(([name, signature]) => {name}() {signature.args.map(arg => arg.join(" | ")).join(", ") || "No arguments"} {name === "answer" || name === "derived" ? "Depends on reference" : signature.result} )}
> : !analysis.ast ?
No AST: the parser did not complete.
: tab === "AST tree" ? <>
The parser groups tokens by precedence. Expand or collapse nodes to explore their children. This is the syntax tree, not an execution trace; short-circuited branches still appear here.
> :
}
+
+
+ ;
+}
diff --git a/src/demo/SiteLayout.tsx b/src/demo/SiteLayout.tsx
index f47d265..bf1cac6 100644
--- a/src/demo/SiteLayout.tsx
+++ b/src/demo/SiteLayout.tsx
@@ -1,11 +1,13 @@
import { useEffect, useState, type ReactNode, type MouseEvent } from "react";
import { DocsPage } from "./DocsPage";
import { SchemaEditorPage } from "./SchemaEditorPage";
+import { DslExplorerPage } from "./DslExplorerPage";
export function SiteLayout({ children }: { children: ReactNode }) {
const [path, setPath] = useState(window.location.pathname);
const docs = path.replace(/\/$/, "") === "/docs";
const editor = path.replace(/\/$/, "") === "/editor";
+ const dsl = path.replace(/\/$/, "") === "/dsl";
useEffect(() => {
const sync = () => setPath(window.location.pathname);
@@ -14,11 +16,11 @@ export function SiteLayout({ children }: { children: ReactNode }) {
}, []);
useEffect(() => {
- document.title = docs ? "Docs | QuestionGraph" : editor ? "Editor | QuestionGraph" : "QuestionGraph";
+ document.title = docs ? "Docs | QuestionGraph" : editor ? "Editor | QuestionGraph" : dsl ? "DSL playground | QuestionGraph" : "QuestionGraph";
if (docs && window.location.hash) {
document.getElementById(decodeURIComponent(window.location.hash.slice(1)))?.scrollIntoView();
}
- }, [docs, editor]);
+ }, [docs, editor, dsl]);
function navigate(event: MouseEvent, target: string) {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
@@ -26,20 +28,22 @@ export function SiteLayout({ children }: { children: ReactNode }) {
window.history.pushState(null, "", target);
setPath(target);
window.scrollTo(0, 0);
- requestAnimationFrame(() => document.querySelector(target === "/docs" ? ".docs-page h1" : target === "/editor" ? ".schema-editor-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true }));
+ requestAnimationFrame(() => document.querySelector(target === "/docs" ? ".docs-page h1" : target === "/dsl" ? ".dsl-page h1" : target === "/editor" ? ".schema-editor-page h1" : ".questionnaire-pane h1")?.focus({ preventScroll: true }));
}
return <>
- {children}
+ {children}
+
{docs && }
>;
}
diff --git a/src/demo/styles.css b/src/demo/styles.css
index faf8d53..284fe21 100644
--- a/src/demo/styles.css
+++ b/src/demo/styles.css
@@ -47,6 +47,33 @@ body { margin: 0; min-width: 320px; min-height: 100vh; }
.schema-result { padding: 28px; }
.schema-result .json-code { border-radius: 10px; }
.schema-editor-page button:focus-visible { outline: 2px solid #1f5eff; outline-offset: 3px; }
+.dsl-page { width: min(1320px, calc(100% - 40px)); margin: 40px auto 80px; }
+.dsl-workbench { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
+.dsl-card { min-width: 0; padding: 24px; margin-bottom: 20px; border: 1px solid #dfe5ee; border-radius: 14px; background: #fff; }
+.dsl-card h2 { margin: 0 0 16px; font-size: 18px; }
+.dsl-code-input { width: 100%; resize: vertical; font: 13px/1.65 ui-monospace, Consolas, monospace; background: #111827; color: #e5e7eb; }
+.dsl-result { padding: 16px; border-radius: 10px; background: #edf5ff; overflow-wrap: anywhere; }
+.dsl-result strong { font-size: 12px; color: #355a91; }
+.dsl-result pre { white-space: pre-wrap; font-size: 20px; margin-bottom: 0; }
+.dsl-pipeline { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 20px; }
+.dsl-stage { border: 1px solid #dfe5ee; border-top: 3px solid #a7b3c6; border-radius: 10px; padding: 18px; background: white; }
+.dsl-stage span { font-size: 11px; color: #627089; }
+.dsl-stage h2 { font-size: 16px; margin: 10px 0; }
+.dsl-stage p, .dsl-inspector-body > p { font-size: 13px; line-height: 1.7; color: #627089; overflow-wrap: anywhere; }
+.dsl-stage-passed { border-top-color: #279074; }
+.dsl-stage-failed { border-top-color: #d34464; background: #fff5f7; }
+.dsl-source { white-space: pre-wrap; overflow-wrap: anywhere; padding: 16px; border-radius: 8px; background: #f2f5fa; }
+.dsl-tokens { display: flex; flex-wrap: wrap; gap: 8px; max-height: 340px; overflow: auto; }
+.dsl-token { display: grid; gap: 7px; padding: 10px 12px; border: 1px solid #dfe5ee; border-radius: 8px; background: #f8faff; max-width: 100%; }
+.dsl-token small { color: #627089; font-size: 10px; }
+.dsl-token code { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
+.dsl-token-string { background: #eef9f3; }.dsl-token-operator { background: #f5f0ff; }.dsl-token-number { background: #fff5e9; }
+.dsl-inspector { padding: 0; overflow: hidden; }.dsl-inspector-body { padding: 24px; min-width: 0; overflow: auto; }
+.dsl-ast, .dsl-ast ul { list-style: none; margin: 12px 0; padding-left: 20px; }
+.dsl-ast ul { border-left: 1px solid #cad7ea; }.dsl-ast li { margin: 10px 0; }
+.dsl-ast summary, .dsl-leaf { padding: 10px 12px; border: 1px solid #dfe5ee; border-radius: 8px; background: #f8faff; width: fit-content; }
+.dsl-ast summary { cursor: pointer; }.dsl-ast span { color: #74839a; font-size: 11px; margin-right: 12px; }.dsl-ast code { font-size: 12px; overflow-wrap: anywhere; }
+@media (max-width: 750px) { .dsl-workbench { grid-template-columns: 1fr; gap: 0; }.dsl-pipeline { grid-template-columns: 1fr 1fr; }.dsl-card, .dsl-inspector-body { padding: 16px; }.dsl-inspector { padding: 0; }.site-header nav { flex-wrap: wrap; } }
.schema-editor-page:has(.schema-visual-diagram) { width: min(1600px, calc(100% - 40px)); }
.schema-visual-diagram { display: grid; grid-template-columns: minmax(0, 1fr) 350px; padding: 0; }
.schema-node-editor { padding: 20px; border-left: 1px solid #dfe5ee; max-height: 760px; overflow: auto; }
diff --git a/src/lib/index.ts b/src/lib/index.ts
index b7d4cee..13098f9 100644
--- a/src/lib/index.ts
+++ b/src/lib/index.ts
@@ -1,7 +1,8 @@
/** Public entry point for the reusable questionnaire library. */
export { QuestionnaireEngine, QuestionnaireSession } from "./questionnaire/questionnaire-engine";
export { compileQuestionnaire } from "./questionnaire/questionnaire-compiler";
-export { compileExpression, evaluateExpression, extractDependencies, FUNCTION_SIGNATURES } from "./questionnaire/dsl";
+export { compileExpression, parseExpression, tokenizeExpression, validateAst, evaluateExpression, extractDependencies, FUNCTION_SIGNATURES } from "./questionnaire/dsl";
+export type { Expr, Token } from "./questionnaire/dsl";
export { parseResumeDraft } from "./questionnaire/resume-draft";
export { QuestionnaireRenderer } from "./react/QuestionnaireRenderer";
export type { SaveRequest, SaveResponse, TypedAnswers } from "./persistence/types";
diff --git a/src/lib/questionnaire/dsl.ts b/src/lib/questionnaire/dsl.ts
index 59c8e1e..4b429ff 100644
--- a/src/lib/questionnaire/dsl.ts
+++ b/src/lib/questionnaire/dsl.ts
@@ -9,7 +9,7 @@ export type Expr =
type TokenType = "number" | "string" | "identifier" | "operator" | "punctuation" | "eof";
-interface Token {
+export interface Token {
type: TokenType;
value: string;
position: number;
@@ -274,10 +274,15 @@ export const FUNCTION_SIGNATURES: Record 4096) throw new Error("Expression exceeds 4096 characters");
- const tokens = new Lexer(expression).tokenize();
- if (tokens.length > 512) throw new Error("Expression exceeds 512 tokens");
+export function tokenizeExpression(expression: string): Token[] {
+ if (expression.length > 4096) throw new Error("Expression exceeds 4096 characters");
+ const tokens = new Lexer(expression).tokenize();
+ if (tokens.length > 512) throw new Error("Expression exceeds 512 tokens");
+ return tokens;
+}
+
+export function parseExpression(expression: string): Expr {
+ const tokens = tokenizeExpression(expression);
let depth = 0;
for (const token of tokens) {
if (token.type === "punctuation" && ["(", "["].includes(token.value) && ++depth > 64) throw new Error("Expression nesting exceeds 64 levels");
diff --git a/tests/questionnaire.test.ts b/tests/questionnaire.test.ts
index 7081481..0e4b527 100644
--- a/tests/questionnaire.test.ts
+++ b/tests/questionnaire.test.ts
@@ -2,13 +2,26 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { QuestionnaireEngine, type Answers, type QuestionnaireDefinition, type QuestionDefinition } from "../src/lib/questionnaire/questionnaire-engine";
-import { compileExpression, evaluateExpression } from "../src/lib/questionnaire/dsl";
+import { compileExpression, evaluateExpression, tokenizeExpression, parseExpression, validateAst } from "../src/lib/questionnaire/dsl";
import { parseResumeDraft } from "../src/lib/questionnaire/resume-draft";
import { SaveEventRecorder, type SaveEvent } from "../src/lib/persistence/save-events";
const claim = JSON.parse(readFileSync(new URL("../src/demo/claim-questionnaire.json", import.meta.url), "utf8"));
const example: QuestionnaireDefinition = JSON.parse(readFileSync(new URL("../src/demo/example-questionnaire.json", import.meta.url), "utf8"));
const today = { today: "2026-09-12" };
+test("DSL inspection exposes lexer offsets and keeps parsing separate from validation", () => {
+ assert.deepEqual(tokenizeExpression(" 'hi' !== null"), [
+ { type: "string", value: "hi", position: 2 },
+ { type: "operator", value: "!==", position: 7 },
+ { type: "identifier", value: "null", position: 11 },
+ { type: "eof", value: "", position: 15 },
+ ]);
+ const ast = parseExpression("unknown(1)");
+ assert.equal(ast.type, "call");
+ assert.throws(() => validateAst(ast), /not allowed/);
+ assert.throws(() => tokenizeExpression("1 @ 2"), /position 2/);
+ assert.throws(() => tokenizeExpression("1".repeat(4097)), /4096/);
+});
const basic = (fields: QuestionDefinition[]): QuestionnaireDefinition => ({ schemaVersion: 1, id: "test", version: "1", title: "Test", pages: [{ id: "first", title: "First", fields }] });
const bool = (id: string): QuestionDefinition => ({ id, type: "boolean", label: id });
const text = (id: string): QuestionDefinition => ({ id, type: "text", label: id });