Files
ttrpg-tools/src/cli/validate/checks/roles.ts
T
hyper fadfd4cba6 feat(declare)!: parse role=declare blocks as YAML
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.
2026-09-12 22:50:48 +08:00

182 lines
4.4 KiB
TypeScript

/**
* Code-block role checks.
*/
import { parseDeclare, type DeclareResult } from "../../completions/declare-parser.js";
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
import type { RawFence } from "../scan.js";
import type { Check, CheckContext } from "./context.js";
import { readTagBlock } from "./tag-block.js";
export const rolesCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const fence of scan.fences) {
const role = fence.attrs.role;
if (!role) continue;
if (!KNOWN_ROLES.has(role)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R001",
"error",
`unknown code-block role "${role}"`,
`known roles: ${[...KNOWN_ROLES].join(", ")}. The block is stripped, silently dropping its content`,
),
);
continue;
}
if (role === "spark-table") checkSparkTable(fence, ctx);
else if (role === "declare") checkDeclare(fence, ctx);
else if (role === "tag") checkTag(fence, ctx);
}
};
function checkSparkTable(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const header = firstHeaderCell(fence.body, fence.lang);
if (!header) return;
if (!/^\s*\d*d\d+/i.test(header)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R002",
"warning",
`spark-table first header "${header}" is not a dice formula`,
"use a notation like d6, 2d6+1, or 3d6kh1",
),
);
}
}
function checkDeclare(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
if (/^csv$/i.test(fence.lang)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R003",
"error",
"role=declare blocks are YAML, not CSV",
"use ```yaml role=declare with `vars:` and `tags:` maps",
),
);
return;
}
let result: DeclareResult;
try {
result = parseDeclare(fence.body);
} catch (error) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R003",
"error",
error instanceof Error ? error.message : String(error),
"role=declare is YAML: `vars:` maps `$name` to an expression; `tags:` maps a tag name to threshold maps",
),
);
return;
}
if (result.variables.length === 0 && result.tagModifiers.length === 0) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R004",
"warning",
"role=declare block declares nothing",
"add a `vars:` or `tags:` mapping; the block has no effect",
),
);
}
}
function checkTag(fence: RawFence, ctx: CheckContext): void {
const { source, file, sink } = ctx;
const { tag, error } = readTagBlock(fence);
if (error) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R005",
"error",
`role=tag body is not valid YAML: ${error.message}`,
"check the indentation and quoting of the YAML mapping",
),
);
return;
}
if (!tag) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R006",
"error",
"role=tag block has no `tag:`",
"set `tag:` to a registered element, e.g. `tag: md-deck`",
),
);
return;
}
if (!ELEMENT_NAMES.has(tag)) {
sink.report(
diagnosticAt(
file,
source,
fence.offset,
"TTTK-R006",
"error",
`role=tag names unknown element "${tag}"`,
`known elements: ${[...ELEMENT_NAMES].join(", ")}`,
),
);
}
}
/** First header cell of a spark-table body (CSV or markdown pipe table). */
function firstHeaderCell(body: string, lang: string): string | null {
if (/^markdown$/i.test(lang)) {
for (const line of body.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed.startsWith("|")) continue;
const cells = trimmed
.split("|")
.map((cell) => cell.trim())
.filter(Boolean);
return cells[0] ?? null;
}
return null;
}
for (const line of body.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
return trimmed.split(",")[0].trim();
}
return null;
}