Replace the tag/threshold/key/expr CSV body with YAML `vars:` and `tags:` maps. `parseDeclare` validates the body with an exported Zod schema and returns the same declaration and modifier shapes the reactivity engine already consumes. The breaking rename from `parseDeclareCsv` updates the registry, the journal re-exports, and the `tttk check` role checks, where R003 now reports schema errors with their path and R004 warns when a block declares nothing.
179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|