/** * 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; }