Statically validate a directory of tttk documents and report structured diagnostics with file/line positions, exit codes, and --json output. Checks cover code-block roles, unresolved content references, tag blocks, element names, links, anchors, and assets. Runs over the same content registry as the app.
150 lines
3.7 KiB
TypeScript
150 lines
3.7 KiB
TypeScript
/**
|
|
* Code-block role checks.
|
|
*/
|
|
|
|
import { parseDeclareCsv } 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;
|
|
try {
|
|
parseDeclareCsv(fence.body, file);
|
|
} catch (error) {
|
|
sink.report(
|
|
diagnosticAt(
|
|
file,
|
|
source,
|
|
fence.offset,
|
|
"TTTK-R003",
|
|
"error",
|
|
error instanceof Error ? error.message : String(error),
|
|
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional",
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|