/** * Declare parser — parses ```yaml role=declare code blocks from markdown. * * Shared between the CLI scanner/validator and the browser-side journal. * * The body is YAML with two optional maps (`declareSchema` below): * * vars: * $hp: $con*5 + $mod_hp * $ac: 10 + $dex * tags: * warrior: * 1: * $mod_hp: 20 * 2: * $mod_str: 1 * * `vars` entries are reactively computed variables. `tags` entries are * modifiers: when a source variable's tag map holds `#warrior` at a count * >= the threshold key, each `$target` gains the modifier value. Tag names * may be written with or without the leading `#`. */ import yaml from "js-yaml"; import { z } from "zod"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface VarDeclaration { key: string; // "$hp" (always starts with $) expression: string; // "$con*5+$mod_hp" } export interface TagModifier { tag: string; // "#warrior" target: string; // "$mod_hp" expression: string; // "20" threshold: number; // minimum tagmap count to activate (default 1) } export interface DeclareResult { variables: VarDeclaration[]; tagModifiers: TagModifier[]; } // --------------------------------------------------------------------------- // Schema // --------------------------------------------------------------------------- /** A `$name` variable key. */ const VAR_NAME = /^\$[A-Za-z_][A-Za-z0-9_]*$/; /** A tag name, optionally carrying its leading `#`. */ const TAG_NAME = /^#?[A-Za-z_][A-Za-z0-9_-]*$/; /** Threshold map keys are positive integers (1, 2, 3, …). */ const THRESHOLD = /^[1-9][0-9]*$/; /** * Expression text. YAML resolves bare numbers to `number`, so both strings and * numbers are accepted and normalized to a trimmed string. A missing value * loads as `null` and becomes empty text, which the refine rejects. Anything * else (boolean, date) is a mistake in the source. Unknown references inside * the expression are resolved by the reactivity engine, not here. */ const exprSchema = z .union([z.string(), z.number(), z.null()]) .transform((value) => (value === null ? "" : String(value).trim())) .refine((value) => value.length > 0, "expression must not be empty"); /** A map whose keys must look like `$name`. */ function varMap(message: string) { return z.record(z.string(), exprSchema).superRefine((obj, ctx) => { for (const key of Object.keys(obj)) { if (!VAR_NAME.test(key)) { ctx.addIssue({ code: "custom", message, path: [key] }); } } }); } /** `threshold -> { $target: expression }`. */ const thresholdMap = z .record( z.string(), varMap("tag modifier targets must start with $, e.g. `$mod_hp`"), ) .superRefine((obj, ctx) => { for (const key of Object.keys(obj)) { if (!THRESHOLD.test(key)) { ctx.addIssue({ code: "custom", message: "threshold must be a positive integer", path: [key], }); } } }); /** The accepted `role=declare` shape. Exported for tests and reuse. */ export const declareSchema = z.strictObject({ vars: varMap("variable names must start with $, e.g. `$hp`").optional(), tags: z .record(z.string(), thresholdMap) .superRefine((obj, ctx) => { for (const key of Object.keys(obj)) { if (!TAG_NAME.test(key)) { ctx.addIssue({ code: "custom", message: "tag names may not contain spaces or punctuation", path: [key], }); } } }) .optional(), }); // --------------------------------------------------------------------------- // Parsing // --------------------------------------------------------------------------- /** * Parse a single ```yaml role=declare block body. * * Throws with a `path: message` summary when the body is not valid YAML or * does not match the schema. Callers attach the file/line context. */ export function parseDeclare(body: string): DeclareResult { const trimmed = body.trim(); if (!trimmed) return { variables: [], tagModifiers: [] }; let data: unknown; try { data = yaml.load(trimmed); } catch (error) { throw new Error( `role=declare body is not valid YAML: ${errorText(error)}`, ); } const parsed = declareSchema.safeParse(data ?? {}); if (!parsed.success) { throw new Error(`role=declare: ${formatIssues(parsed.error)}`); } const variables: VarDeclaration[] = []; for (const [key, expression] of Object.entries(parsed.data.vars ?? {})) { variables.push({ key, expression }); } const tagModifiers: TagModifier[] = []; for (const [name, thresholds] of Object.entries(parsed.data.tags ?? {})) { const tag = name.startsWith("#") ? name : `#${name}`; for (const [rawThreshold, targets] of Object.entries(thresholds)) { const threshold = Number(rawThreshold); for (const [target, expression] of Object.entries(targets)) { tagModifiers.push({ tag, target, expression, threshold }); } } } return { variables, tagModifiers }; } function formatIssues(error: z.ZodError): string { return error.issues .map((issue) => issue.path.length ? `${issue.path.join(".")}: ${issue.message}` : issue.message, ) .join("; "); } function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); }