feat(cli): Add tttk check validator

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.
This commit is contained in:
2026-09-12 16:38:21 +08:00
parent d1384c2ccc
commit 9def4d17aa
18 changed files with 1638 additions and 1 deletions
+108
View File
@@ -0,0 +1,108 @@
/**
* Asset checks: icons and image references.
*
* Uses the filesystem, since `png`/`jpg` files are not in the content index.
* The icon search assumes no `assets/` directory exists above the check root
* (see docs/check-plan.md §5), which makes the lookup definitive.
*/
import { existsSync } from "fs";
import { join, posix } from "path";
import { diagnosticAt } from "../diagnostics.js";
import { elementFor, looksLikeColor, COLOR_BODY_ELEMENTS } from "../known.js";
import type { Check, CheckContext } from "./context.js";
import { resolveKey } from "./context.js";
import { readTagBlock } from "./tag-block.js";
const ICON_EXTENSIONS = ["svg", "png", "gif", "jpg", "jpeg", "webp"];
export const assetsCheck: Check = (ctx) => {
const { scan, source, file, pathKey, rootDir, sink } = ctx;
for (const directive of scan.directives) {
if (directive.icon) {
const value = directive.body.trim();
if (!value) continue;
const { name, ext } = splitIcon(value);
if (!iconExists(pathKey, name, ext, rootDir)) {
sink.report(
diagnosticAt(
file,
source,
directive.offset,
"TTTK-A001",
"warning",
`icon "${value}" not found`,
`looked for assets/${name}.${ext} from this file up to the project root`,
),
);
}
continue;
}
const spec = elementFor(directive.name);
if (!spec || spec.refKind !== "image") continue;
const ref = directive.body.trim();
if (!ref) continue;
if (COLOR_BODY_ELEMENTS.has(spec.name) && looksLikeColor(ref)) continue;
reportMissingImage(ref, directive.offset, ctx);
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind !== "image") continue;
if (COLOR_BODY_ELEMENTS.has(tag) && looksLikeColor(body)) continue;
reportMissingImage(body.trim(), fence.offset, ctx);
}
};
function reportMissingImage(
ref: string,
offset: number,
ctx: CheckContext,
): void {
const { source, file, pathKey, rootDir, sink } = ctx;
const key = resolveKey(pathKey, ref);
if (existsSync(join(rootDir, key.replace(/^\//, "")))) return;
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-A002",
"warning",
`image "${ref}" does not exist`,
),
);
}
function splitIcon(value: string): { name: string; ext: string } {
const dot = value.lastIndexOf(".");
if (dot > 0) {
const ext = value.slice(dot + 1).toLowerCase();
if (ICON_EXTENSIONS.includes(ext)) {
return { name: value.slice(0, dot), ext };
}
}
return { name: value, ext: "png" };
}
/** Mirror `buildIconSrc`: search `assets/` from the doc dir up to the root. */
function iconExists(
pathKey: string,
name: string,
ext: string,
rootDir: string,
): boolean {
const parts = posix.dirname(pathKey).split("/").filter(Boolean);
for (let i = parts.length; i >= 0; i--) {
const prefix = `/${parts.slice(0, i).join("/")}`;
const base = prefix === "/" ? "/assets" : `${prefix}/assets`;
const rel = `${base}/${name}.${ext}`.replace(/^\//, "");
if (existsSync(join(rootDir, rel))) return true;
}
return false;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Shared context passed to every per-document check.
*/
import { posix } from "path";
import type { ContentRegistry } from "../../content-registry.js";
import type { DiagnosticSink } from "../diagnostics.js";
import type { ScanResult } from "../scan.js";
import type { SourceFile } from "../source.js";
export interface CheckContext {
source: SourceFile;
scan: ScanResult;
/** Display path: relative to the root, posix, no leading slash. */
file: string;
/** Registry key: posix with a leading slash. */
pathKey: string;
registry: ContentRegistry;
/** Raw contents of every indexable file, keyed like the registry. */
index: Record<string, string>;
/** Absolute root directory of the check. */
rootDir: string;
sink: DiagnosticSink;
}
export type Check = (ctx: CheckContext) => void;
/** Strip the registry key's leading slash for display. */
export function displayPath(key: string): string {
return key.replace(/^\//, "");
}
/** Resolve a reference written in `pathKey` to a registry key. */
export function resolveKey(pathKey: string, ref: string): string {
if (ref.startsWith("/")) return posix.normalize(ref);
const dir = posix.dirname(pathKey);
const joined = posix.normalize(posix.join(dir, ref));
return joined.startsWith("/") ? joined : `/${joined}`;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Directive checks: `:md-*` element names must be registered.
*/
import { diagnosticAt } from "../diagnostics.js";
import { ELEMENT_NAMES, KNOWN_ELEMENTS } from "../known.js";
import type { Check } from "./context.js";
export const directivesCheck: Check = (ctx) => {
const { scan, source, file, sink } = ctx;
for (const directive of scan.directives) {
if (directive.icon) continue;
if (!directive.name.startsWith("md-")) continue;
if (ELEMENT_NAMES.has(directive.name)) continue;
const suggestion = closestName(directive.name, [...ELEMENT_NAMES]);
sink.report(
diagnosticAt(
file,
source,
directive.offset,
"TTTK-D001",
"error",
`unknown element ":${directive.name}"`,
suggestion
? `did you mean ":${suggestion}"?`
: `custom elements are: ${KNOWN_ELEMENTS.map((e) => e.name).join(", ")}`,
),
);
}
};
/** Return the closest candidate within edit distance 2, if any. */
function closestName(name: string, candidates: string[]): string | null {
let best: string | null = null;
let bestDistance = 3;
for (const candidate of candidates) {
const distance = editDistance(name, candidate);
if (distance < bestDistance) {
bestDistance = distance;
best = candidate;
}
}
return best;
}
function editDistance(a: string, b: string): number {
const prev = new Array<number>(b.length + 1);
const curr = new Array<number>(b.length + 1);
for (let j = 0; j <= b.length; j++) prev[j] = j;
for (let i = 1; i <= a.length; i++) {
curr[0] = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
}
for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
}
return prev[b.length];
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Markdown link and image checks.
*
* File existence uses the filesystem (not the registry) because images are not
* indexed. Section anchors are validated against heading slugs.
*/
import { existsSync } from "fs";
import { join } from "path";
import Slugger from "github-slugger";
import { stripFencedBlocks } from "../../content-registry.js";
import { diagnosticAt } from "../diagnostics.js";
import type { Check } from "./context.js";
import { resolveKey } from "./context.js";
const EXTERNAL_RE = /^[a-z][a-z0-9+.-]*:/i;
export const linksCheck: Check = (ctx) => {
const { scan, source, file, pathKey, registry, index, rootDir, sink } = ctx;
const slugCache = new Map<string, Set<string>>();
const slugsFor = (key: string): Set<string> => {
const cached = slugCache.get(key);
if (cached) return cached;
const markdown = index[key] ?? registry.pathIndex[key] ?? "";
const slugs = headingSlugs(markdown);
slugCache.set(key, slugs);
return slugs;
};
const exists = (key: string): boolean => {
if (key in registry.pathIndex || key in index) return true;
return existsSync(join(rootDir, key.replace(/^\//, "")));
};
for (const link of scan.links) {
const target = link.target.trim();
if (!target || EXTERNAL_RE.test(target) || target.startsWith("//")) continue;
const hashIndex = target.indexOf("#");
const pathPart = hashIndex >= 0 ? target.slice(0, hashIndex) : target;
const section = hashIndex >= 0 ? target.slice(hashIndex + 1) : "";
const baseKey = pathPart ? resolveKey(pathKey, pathPart) : pathKey;
if (pathPart && !exists(baseKey)) {
sink.report(
diagnosticAt(
file,
source,
link.targetOffset,
link.image ? "TTTK-A002" : "TTTK-L001",
link.image ? "warning" : "error",
`${link.image ? "image" : "link target"} "${target}" does not exist`,
),
);
continue;
}
if (section && baseKey.endsWith(".md")) {
const decoded = decodeURIComponent(section).toLowerCase();
if (!sectionMatches(slugsFor(baseKey), decoded)) {
sink.report(
diagnosticAt(
file,
source,
link.targetOffset,
"TTTK-L002",
"warning",
`section "#${section}" not found in "${pathPart || file}"`,
"anchors come from headings, lowercased and hyphenated",
),
);
}
}
}
};
/**
* A section matches an exact heading slug, or a heading slug with a suffix
* (e.g. `#珊瑚粉` for the heading `珊瑚粉 - 成瘾`) — a common authoring habit.
*/
function sectionMatches(slugs: Set<string>, section: string): boolean {
if (slugs.has(section)) return true;
for (const slug of slugs) {
if (!slug.startsWith(section)) continue;
const next = slug[section.length];
if (next === undefined || !/[A-Za-z0-9]/.test(next)) return true;
}
return false;
}
function headingSlugs(markdown: string): Set<string> {
const slugger = new Slugger();
const slugs = new Set<string>();
const content = stripFencedBlocks(markdown);
const re = /^(#{1,6})\s+(.+?)\s*$/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(content)) !== null) {
slugs.add(slugger.slug(match[2].trim().toLowerCase()));
}
return slugs;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Content reference checks for directives and `yaml role=tag` blocks.
*
* Image references are handled by the assets check, because image files are
* not part of the content registry (only `.md`, `.csv`, `.yarn`, `.svg` are
* indexed).
*/
import { extname } from "path";
import { resolveContentEntry } from "../../content-registry.js";
import { diagnosticAt } from "../diagnostics.js";
import { REF_EXTENSIONS, elementFor, type RefKind } from "../known.js";
import type { Check, CheckContext } from "./context.js";
import { readTagBlock } from "./tag-block.js";
export const refsCheck: Check = (ctx) => {
const { scan, source, file, pathKey, registry, sink } = ctx;
const tableSeen = new Map<string, { count: number; offset: number }>();
const verify = (
ref: string,
offset: number,
kind: RefKind,
element: string,
): boolean => {
const resolved = resolveContentEntry(registry, pathKey, ref);
if (!resolved) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-C001",
"error",
`:${element} reference "${ref}" does not resolve`,
"expected a file relative to this document, or an inline id defined in it",
),
);
return false;
}
if (!resolved.inline && kind !== "none") {
const ext = extname(resolved.path).toLowerCase();
const allowed = REF_EXTENSIONS[kind as Exclude<RefKind, "none">];
if (allowed && !allowed.includes(ext)) {
sink.report(
diagnosticAt(
file,
source,
offset,
"TTTK-C002",
"warning",
`:${element} reference "${ref}" resolves to "${ext || "no extension"}", expected ${allowed.join(" / ")}`,
),
);
}
}
return true;
};
for (const directive of scan.directives) {
if (directive.icon) continue;
const spec = elementFor(directive.name);
if (!spec || spec.refKind === "none" || spec.refKind === "image") continue;
let ref = directive.body.trim();
if (spec.name === "md-link" || spec.name === "md-embed") {
ref = ref.split("#")[0];
}
if (!ref) continue;
const ok = verify(ref, directive.offset, spec.refKind, spec.name);
if (spec.name === "md-table" && ok) {
const seen = tableSeen.get(ref);
if (seen) seen.count++;
else tableSeen.set(ref, { count: 1, offset: directive.offset });
}
}
for (const fence of scan.fences) {
if (fence.attrs.role !== "tag") continue;
const { tag, body } = readTagBlock(fence);
if (!tag || !body) continue;
const spec = elementFor(tag);
if (!spec || spec.refKind === "none" || spec.refKind === "image") continue;
verify(body.trim(), fence.offset, spec.refKind, tag);
}
for (const [ref, seen] of tableSeen) {
if (seen.count > 1) {
sink.report(
diagnosticAt(
file,
source,
seen.offset,
"TTTK-C003",
"info",
`:md-table reference "${ref}" is used ${seen.count} times in this document`,
"reference each CSV once; use group tabs instead of repeating the table",
),
);
}
}
};
+149
View File
@@ -0,0 +1,149 @@
/**
* 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;
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Structural checks: the markdown pipeline parses, and container directives
* are balanced.
*/
import { parseMarkdown } from "../../../markdown/index.js";
import { diagnosticAt } from "../diagnostics.js";
import type { RawContainerOpen } from "../scan.js";
import type { Check } from "./context.js";
export const structuralCheck: Check = (ctx) => {
const { source, scan, file, pathKey, sink } = ctx;
try {
parseMarkdown(source.text, pathKey);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
sink.report(
diagnosticAt(
file,
source,
0,
"TTTK-S001",
"error",
`markdown failed to parse: ${detail}`,
"check for an unterminated directive or malformed attribute block",
),
);
}
const events = [
...scan.containerOpens.map((open) => ({ offset: open.offset, open })),
...scan.containerCloses.map((close) => ({ offset: close.offset, open: null })),
].sort((a, b) => a.offset - b.offset);
const stack: RawContainerOpen[] = [];
for (const event of events) {
if (event.open) stack.push(event.open);
else stack.pop();
}
for (const open of stack) {
sink.report(
diagnosticAt(
file,
source,
open.offset,
"TTTK-S002",
"error",
`container directive "::: ${open.name}" is never closed`,
`add a closing "${":".repeat(open.colons)}" line`,
),
);
}
};
+38
View File
@@ -0,0 +1,38 @@
/**
* Read a `yaml role=tag` block: the element name and body, honoring fence-line
* attribute overrides (which win over the YAML mapping).
*/
import yaml from "js-yaml";
import type { RawFence } from "../scan.js";
export interface TagBlock {
tag?: string;
body?: string;
/** Set when the YAML itself failed to parse. */
error?: Error;
}
export function readTagBlock(fence: RawFence): TagBlock {
let data: unknown;
try {
data = yaml.load(fence.body);
} catch (error) {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
const mapping =
data && typeof data === "object" && !Array.isArray(data)
? (data as Record<string, unknown>)
: {};
const fenceTag = fence.attrs.extra.tag;
const fenceBody = fence.attrs.extra.body;
const yamlTag = mapping.tag;
const yamlBody = mapping.body;
return {
tag: fenceTag ?? (typeof yamlTag === "string" ? yamlTag : undefined),
body: fenceBody ?? (typeof yamlBody === "string" ? yamlBody : undefined),
};
}
+146
View File
@@ -0,0 +1,146 @@
/**
* Diagnostic model, collection, and reporting.
*
* `check` reports problems; it never edits content. Diagnostics carry stable
* codes so an authoring agent can key off them.
*/
import type { SourceFile } from "./source.js";
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
/** Stable code, e.g. "TTTK-C001". */
code: string;
severity: Severity;
message: string;
/** Path relative to the check root, posix, no leading slash. */
file: string;
line: number;
column: number;
/** Concrete, actionable fix. */
hint?: string;
/** The offending source line, for context. */
snippet?: string;
}
export interface DiagnosticSink {
report(diagnostic: Diagnostic): void;
}
/** Collects diagnostics in memory. */
export class Collector implements DiagnosticSink {
readonly items: Diagnostic[] = [];
report(diagnostic: Diagnostic): void {
this.items.push(diagnostic);
}
}
export function diagnosticAt(
file: string,
source: SourceFile,
offset: number,
code: string,
severity: Severity,
message: string,
hint?: string,
): Diagnostic {
const { line, column } = source.position(offset);
return {
code,
severity,
message,
file,
line,
column,
hint,
snippet: source.lineText(line).trimEnd() || undefined,
};
}
const SEVERITY_ORDER: Record<Severity, number> = {
error: 0,
warning: 1,
info: 2,
};
export function compareDiagnostics(a: Diagnostic, b: Diagnostic): number {
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
if (a.line !== b.line) return a.line - b.line;
if (a.column !== b.column) return a.column - b.column;
if (a.severity !== b.severity) {
return SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
}
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
return 0;
}
export interface DiagnosticSummary {
errors: number;
warnings: number;
infos: number;
}
export interface CheckReport {
root: string;
filesChecked: number;
diagnostics: Diagnostic[];
summary: DiagnosticSummary;
}
export function summarize(diagnostics: Diagnostic[]): DiagnosticSummary {
const summary: DiagnosticSummary = { errors: 0, warnings: 0, infos: 0 };
for (const d of diagnostics) {
if (d.severity === "error") summary.errors++;
else if (d.severity === "warning") summary.warnings++;
else summary.infos++;
}
return summary;
}
export function buildReport(
root: string,
filesChecked: number,
diagnostics: Diagnostic[],
): CheckReport {
const sorted = [...diagnostics].sort(compareDiagnostics);
return {
root,
filesChecked,
diagnostics: sorted,
summary: summarize(sorted),
};
}
/** Human-readable, grouped by file. */
export function formatHuman(report: CheckReport, quiet = false): string {
const lines: string[] = [];
let currentFile: string | null = null;
for (const d of report.diagnostics) {
if (quiet && d.severity !== "error") continue;
if (d.file !== currentFile) {
if (lines.length) lines.push("");
lines.push(d.file);
currentFile = d.file;
}
const loc = `${d.line}:${d.column}`.padEnd(8);
lines.push(` ${loc} ${d.severity.padEnd(7)} ${d.code} ${d.message}`);
if (d.hint) lines.push(` ${" ".repeat(8)} hint: ${d.hint}`);
}
if (lines.length) lines.push("");
const { errors, warnings, infos } = report.summary;
lines.push(
`${errors} error${errors === 1 ? "" : "s"}, ` +
`${warnings} warning${warnings === 1 ? "" : "s"}, ` +
`${infos} info`,
`${report.filesChecked} file${report.filesChecked === 1 ? "" : "s"} checked`,
);
return lines.join("\n");
}
export function formatJson(report: CheckReport): string {
return JSON.stringify(report, null, 2);
}
+142
View File
@@ -0,0 +1,142 @@
/**
* `tttk check` — run all validation checks over a directory and return a
* sorted report.
*
* Design notes live in docs/check-plan.md:
* - §3.1 build the whole-tree registry first, so cross-file refs resolve
* - §3.2 diagnose the *raw* source so line numbers are accurate
*/
import { existsSync, statSync } from "fs";
import { resolve } from "path";
import {
buildRegistryFromIndex,
type ContentRegistry,
} from "../content-registry.js";
import { readContentIndex } from "../content-index.js";
import { assetsCheck } from "./checks/assets.js";
import type { Check, CheckContext } from "./checks/context.js";
import { displayPath, resolveKey } from "./checks/context.js";
import { directivesCheck } from "./checks/directives.js";
import { linksCheck } from "./checks/links.js";
import { refsCheck } from "./checks/refs.js";
import { rolesCheck } from "./checks/roles.js";
import { structuralCheck } from "./checks/structural.js";
import {
buildReport,
Collector,
diagnosticAt,
type CheckReport,
} from "./diagnostics.js";
import { scanSource, type ScanResult } from "./scan.js";
import { SourceFile } from "./source.js";
const CHECKS: readonly Check[] = [
structuralCheck,
directivesCheck,
rolesCheck,
refsCheck,
linksCheck,
assetsCheck,
];
interface ScannedDoc {
source: SourceFile;
scan: ScanResult;
}
export function runCheck(dir: string): CheckReport {
const rootDir = resolve(dir);
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
throw new Error(`not a directory: ${rootDir}`);
}
const index = readContentIndex(rootDir);
const registry = buildRegistryFromIndex(index);
const sink = new Collector();
const mdKeys = Object.keys(index)
.filter((key) => key.endsWith(".md"))
.sort();
const docs = new Map<string, ScannedDoc>();
for (const key of mdKeys) {
const text = index[key];
docs.set(key, { source: new SourceFile(text), scan: scanSource(text) });
}
for (const key of mdKeys) {
const doc = docs.get(key)!;
const ctx: CheckContext = {
source: doc.source,
scan: doc.scan,
file: displayPath(key),
pathKey: key,
registry,
index,
rootDir,
sink,
};
for (const check of CHECKS) check(ctx);
}
checkEmbedCycles(mdKeys, docs, registry, sink);
return buildReport(rootDir, mdKeys.length, sink.items);
}
/** Detect `:md-embed` cycles, which would expand infinitely. */
function checkEmbedCycles(
mdKeys: string[],
docs: Map<string, ScannedDoc>,
registry: ContentRegistry,
sink: Collector,
): void {
const graph = new Map<string, { target: string; offset: number }[]>();
for (const key of mdKeys) {
const edges: { target: string; offset: number }[] = [];
for (const directive of docs.get(key)!.scan.directives) {
if (directive.icon || directive.name !== "md-embed") continue;
const ref = directive.body.trim().split("#")[0];
if (!ref) continue;
const target = resolveKey(key, ref);
if (!target.endsWith(".md")) continue;
if (!(target in registry.pathIndex)) continue; // C001 already reports
edges.push({ target, offset: directive.offset });
}
graph.set(key, edges);
}
const state = new Map<string, 1 | 2>();
const reported = new Set<string>();
const visit = (key: string): void => {
state.set(key, 1);
for (const edge of graph.get(key) ?? []) {
const targetState = state.get(edge.target);
if (targetState === 1) {
const id = `${key}->${edge.target}`;
if (reported.has(id)) continue;
reported.add(id);
sink.report(
diagnosticAt(
displayPath(key),
docs.get(key)!.source,
edge.offset,
"TTTK-C004",
"error",
`:md-embed cycle: "${displayPath(key)}" embeds "${displayPath(edge.target)}", which embeds it back`,
"break the cycle by linking instead of embedding",
),
);
} else if (targetState === undefined) {
visit(edge.target);
}
}
state.set(key, 2);
};
for (const key of mdKeys) {
if (!state.has(key)) visit(key);
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Catalog of tttk syntax: custom elements, inline directives, and code-block
* roles.
*
* This is the single source of truth for validation. `docs/reference.md` is
* intended to be generated from it (see docs/check-plan.md §7.11); a drift
* test asserts this list matches the `customElement(...)` call sites.
*/
/** What a directive's body refers to, used for reference checks. */
export type RefKind = "csv" | "md" | "yarn" | "image" | "none";
export interface ElementSpec {
/** Registered custom element name, e.g. "md-dice". */
name: string;
/** Human description of the body. */
body: string;
/** Key attribute names. */
attrs: string[];
summary: string;
refKind: RefKind;
}
/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */
export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
{
name: "md-dice",
body: "dice formula",
attrs: ["key"],
summary: "Clickable dice roller; `key` stores the result in the URL.",
refKind: "none",
},
{
name: "md-table",
body: "CSV path or inline id",
attrs: ["roll", "remix"],
summary: "Tab-separated view of a CSV with optional group tabs.",
refKind: "csv",
},
{
name: "md-deck",
body: "CSV path",
attrs: ["size", "grid", "layers"],
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
refKind: "csv",
},
{
name: "md-link",
body: "path, optional #section",
attrs: [],
summary: "Clickable link that expands the target article inline.",
refKind: "md",
},
{
name: "md-embed",
body: "path, optional #section",
attrs: ["headingBase"],
summary: "Renders the target article or section inline.",
refKind: "md",
},
{
name: "md-bg",
body: "image path or CSS color",
attrs: ["fit"],
summary: "Sets the surrounding article card background.",
refKind: "image",
},
{
name: "md-border",
body: "color or border image",
attrs: ["width", "slice"],
summary: "Applies a border to the article card.",
refKind: "image",
},
{
name: "md-font",
body: "font family",
attrs: ["source", "weight", "color"],
summary: "Sets the article font.",
refKind: "none",
},
{
name: "md-pins",
body: "image path",
attrs: ["pins", "fixed", "labelStart"],
summary: "Numbered markers on an image.",
refKind: "image",
},
{
name: "md-commander",
body: "command template CSV",
attrs: ["placeholder", "class", "height"],
summary: "Interactive command history / tracker panel.",
refKind: "csv",
},
{
name: "md-yarn-spinner",
body: "Yarn Spinner file",
attrs: ["start"],
summary: "Runs a Yarn Spinner dialogue file.",
refKind: "yarn",
},
];
export const ELEMENT_NAMES: ReadonlySet<string> = new Set(
KNOWN_ELEMENTS.map((e) => e.name),
);
const ELEMENT_BY_NAME: ReadonlyMap<string, ElementSpec> = new Map(
KNOWN_ELEMENTS.map((e) => [e.name, e]),
);
export function elementFor(name: string): ElementSpec | undefined {
return ELEMENT_BY_NAME.get(name);
}
/** Elements whose body may also be a CSS color rather than a file path. */
export const COLOR_BODY_ELEMENTS: ReadonlySet<string> = new Set([
"md-bg",
"md-border",
]);
export interface RoleSpec {
role: string;
summary: string;
}
/** Code-block `role=` values the scanner understands. */
export const ROLE_SPECS: readonly RoleSpec[] = [
{ role: "spark-table", summary: "CSV (or markdown pipe table) rendered as `:md-table`." },
{ role: "declare", summary: "Reactive variable / tag-modifier definitions." },
{ role: "file", summary: "Stored as inline text content and stripped." },
{ role: "tag", summary: "YAML rendered as a custom element." },
];
export const KNOWN_ROLES: ReadonlySet<string> = new Set(
ROLE_SPECS.map((r) => r.role),
);
/** Inline directives that are not custom elements. */
export const KNOWN_INLINE: ReadonlySet<string> = new Set(["cmd"]);
/** Expected file extensions per reference kind. */
export const REF_EXTENSIONS: Record<Exclude<RefKind, "none">, readonly string[]> =
{
csv: [".csv"],
md: [".md"],
yarn: [".yarn"],
image: [".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"],
};
/** Whether a directive body is a CSS color rather than a path. */
export function isColorValue(value: string): boolean {
const v = value.trim();
return (
/^#([0-9a-f]{3,8})$/i.test(v) ||
/^(rgb|rgba|hsl|hsla|var)\(/i.test(v) ||
v === "transparent" ||
v === "currentColor"
);
}
/**
* Whether a color-capable element body looks like a color rather than a path.
* A single alphabetic word is treated as a CSS named color (e.g. "crimson");
* image paths contain a separator or an extension.
*/
export function looksLikeColor(value: string): boolean {
const v = value.trim();
return isColorValue(v) || /^[A-Za-z]+$/.test(v);
}
+246
View File
@@ -0,0 +1,246 @@
/**
* Raw-source scanner for the validator.
*
* Unlike `scanDoc`, which rewrites fenced blocks and loses offsets, this walks
* the *raw* markdown and records character offsets so diagnostics can report
* accurate line/column positions. Directives inside fenced code blocks are
* skipped (documentation examples are not live content).
*/
import { parseBlockAttrs, type BlockAttrs } from "../completions/block-scanner.js";
export interface RawDirective {
/** Directive name, e.g. "md-dice"; "" for the bare icon form `:[name]`. */
name: string;
body: string;
/** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */
attrs: Record<string, string>;
/** Offset of the leading ":". */
offset: number;
/** True for the icon form `:[name]`. */
icon: boolean;
}
export interface RawFence {
lang: string;
info: string;
attrs: BlockAttrs;
/** Offset of the opening fence line. */
offset: number;
body: string;
/** Offset of the first body character. */
bodyOffset: number;
}
export interface RawLink {
image: boolean;
/** Link target as written, e.g. "./rules.md#combat". */
target: string;
/** Offset of the target text. */
targetOffset: number;
}
export interface RawContainerOpen {
name: string;
colons: number;
offset: number;
}
export interface RawContainerClose {
colons: number;
offset: number;
}
export interface ScanResult {
directives: RawDirective[];
fences: RawFence[];
links: RawLink[];
containerOpens: RawContainerOpen[];
containerCloses: RawContainerClose[];
}
interface Line {
text: string;
offset: number;
end: number;
}
const FENCE_OPEN_RE = /^\s*(`{3,}|~{3,})(.*)$/;
const FENCE_CLOSE_RE = /^\s*(`{3,}|~{3,})\s*$/;
const CONTAINER_OPEN_RE = /^\s*(:{3,})\s*([A-Za-z][\w-]*)\s*$/;
const CONTAINER_CLOSE_RE = /^\s*(:{3,})\s*$/;
const NAMED_DIRECTIVE_RE = /:([a-zA-Z][a-zA-Z0-9-]*)\[([^\[\]]*)\](?:\{([^}]*)\})?/g;
const ICON_DIRECTIVE_RE = /:\[([^\[\]]*)\](?:\{([^}]*)\})?/g;
const LINK_RE = /(!?)\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+"[^"]*")?\s*\)/g;
export function scanSource(text: string): ScanResult {
const result: ScanResult = {
directives: [],
fences: [],
links: [],
containerOpens: [],
containerCloses: [],
};
let fence:
| {
marker: string;
lang: string;
info: string;
attrs: BlockAttrs;
offset: number;
lineEnd: number;
}
| null = null;
for (const line of splitLines(text)) {
if (!fence) {
const open = FENCE_OPEN_RE.exec(line.text);
if (open) {
const rest = open[2];
const langMatch = /^\s*(\w*)\s*/.exec(rest)!;
const lang = langMatch[1] || "";
const info = rest.slice(langMatch[0].length).trim();
fence = {
marker: open[1],
lang,
info,
attrs: parseBlockAttrs(info),
offset: line.offset,
lineEnd: line.end,
};
continue;
}
scanLine(line, result);
continue;
}
const close = FENCE_CLOSE_RE.exec(line.text);
if (close && close[1][0] === fence.marker[0] && close[1].length >= fence.marker.length) {
result.fences.push({
lang: fence.lang,
info: fence.info,
attrs: fence.attrs,
offset: fence.offset,
body: text.slice(fence.lineEnd, line.offset),
bodyOffset: fence.lineEnd,
});
fence = null;
}
}
if (fence) {
result.fences.push({
lang: fence.lang,
info: fence.info,
attrs: fence.attrs,
offset: fence.offset,
body: text.slice(fence.lineEnd),
bodyOffset: fence.lineEnd,
});
}
return result;
}
function scanLine(line: Line, result: ScanResult): void {
const open = CONTAINER_OPEN_RE.exec(line.text);
if (open) {
result.containerOpens.push({
name: open[2],
colons: open[1].length,
offset: line.offset + line.text.indexOf(":"),
});
return;
}
const close = CONTAINER_CLOSE_RE.exec(line.text);
if (close) {
result.containerCloses.push({
colons: close[1].length,
offset: line.offset + line.text.indexOf(":"),
});
return;
}
// Directives and links inside inline code or HTML comments are examples.
const inert = maskedRanges(line.text);
for (const m of line.text.matchAll(NAMED_DIRECTIVE_RE)) {
if (inRanges(m.index, inert)) continue;
result.directives.push({
name: m[1],
body: m[2],
attrs: parseDirectiveAttrs(m[3] || ""),
offset: line.offset + m.index,
icon: false,
});
}
for (const m of line.text.matchAll(ICON_DIRECTIVE_RE)) {
if (inRanges(m.index, inert)) continue;
result.directives.push({
name: "",
body: m[1],
attrs: parseDirectiveAttrs(m[2] || ""),
offset: line.offset + m.index,
icon: true,
});
}
for (const m of line.text.matchAll(LINK_RE)) {
if (inRanges(m.index, inert)) continue;
const target = m[3];
result.links.push({
image: m[1] === "!",
target,
targetOffset: line.offset + m.index + m[0].lastIndexOf(target),
});
}
}
/** Ranges of a line whose contents are inert (inline code, HTML comments). */
function maskedRanges(line: string): Array<[number, number]> {
const ranges: Array<[number, number]> = [];
const patterns = [/`+[^`]*`+/g, /<!--.*?-->/g];
for (const re of patterns) {
let m: RegExpExecArray | null;
while ((m = re.exec(line)) !== null) {
ranges.push([m.index, m.index + m[0].length]);
}
}
return ranges;
}
function inRanges(index: number, ranges: Array<[number, number]>): boolean {
return ranges.some(([start, end]) => index >= start && index < end);
}
/** Parse `{key="value" key=value}` ignoring `.class`/`#id` shorthands. */
function parseDirectiveAttrs(raw: string): Record<string, string> {
const attrs: Record<string, string> = {};
const re = /([A-Za-z_][\w-]*)\s*=\s*("[^"]*"|'[^']*'|\S+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
attrs[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
return attrs;
}
function splitLines(text: string): Line[] {
const lines: Line[] = [];
let i = 0;
while (i <= text.length) {
let j = i;
while (j < text.length && text[j] !== "\n" && text[j] !== "\r") j++;
let next = j;
if (next < text.length) {
if (text[next] === "\r" && text[next + 1] === "\n") next += 2;
else next += 1;
}
lines.push({ text: text.slice(i, j), offset: i, end: next });
if (next >= text.length) break;
i = next;
}
return lines;
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Source position mapping for diagnostics.
*
* Validation runs over raw file text and reports offsets; this converts an
* offset into a 1-based line/column and can read back the offending line for
* snippet display.
*/
export interface Position {
line: number;
column: number;
}
export class SourceFile {
private readonly lineStarts: number[];
constructor(readonly text: string) {
const starts = [0];
for (let i = 0; i < text.length; i++) {
if (text.charCodeAt(i) === 10) starts.push(i + 1);
}
this.lineStarts = starts;
}
/** Map a character offset to a 1-based line/column. */
position(offset: number): Position {
const clamped = Math.max(0, Math.min(offset, this.text.length));
let lo = 0;
let hi = this.lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (this.lineStarts[mid] <= clamped) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: clamped - this.lineStarts[lo] + 1 };
}
/** The text of a 1-based line, without its terminator. */
lineText(line: number): string {
const start = this.lineStarts[line - 1] ?? 0;
const end = this.lineStarts[line] ?? this.text.length;
return this.text.slice(start, end).replace(/\r?\n$/, "");
}
}
+187
View File
@@ -0,0 +1,187 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { parseMarkdown } from "../../markdown/index.js";
import { runCheck } from "./index.js";
import { formatHuman, type CheckReport } from "./diagnostics.js";
// `marked` ships ESM only, which jest's CJS runtime cannot load. The markdown
// pipeline is mocked here; its real behavior is covered outside jest.
jest.mock("../../markdown/index.js", () => ({
parseMarkdown: jest.fn((content: string) => content),
}));
const dirs: string[] = [];
function fixture(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "tttk-check-"));
dirs.push(dir);
for (const [rel, content] of Object.entries(files)) {
const full = join(dir, rel);
mkdirSync(dirname(full), { recursive: true });
writeFileSync(full, content, "utf-8");
}
return dir;
}
function codes(report: CheckReport): string[] {
return report.diagnostics.map((d) => d.code);
}
afterEach(() => {
jest.restoreAllMocks();
while (dirs.length) rmSync(dirs.pop()!, { recursive: true, force: true });
});
beforeEach(() => {
// The registry legitimately warns about unknown roles; keep test output clean.
jest.spyOn(console, "warn").mockImplementation(() => {});
jest.spyOn(console, "log").mockImplementation(() => {});
(parseMarkdown as jest.Mock).mockImplementation((content: string) => content);
});
describe("runCheck", () => {
test("a clean multi-file document produces no diagnostics", () => {
const dir = fixture({
"adventure.md":
"# Adventure\n\n:md-dice[2d6]{key=\"dmg\"}\n\n:md-table[./npcs.csv]\n",
"npcs.csv": "label,body\n1,Alice\n",
});
const report = runCheck(dir);
expect(report.diagnostics).toEqual([]);
expect(report.filesChecked).toBe(1);
});
test("R001 flags an unknown code-block role", () => {
const dir = fixture({
"doc.md": "```csv role=spark-tabel\nd6,Name\n1,Alice\n```\n",
});
const report = runCheck(dir);
expect(codes(report)).toContain("TTTK-R001");
expect(report.diagnostics[0].line).toBe(1);
});
test("R003 flags a declare block missing key/expr columns", () => {
const dir = fixture({
"doc.md": "```csv role=declare\nfoo,bar\n1,2\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R003");
});
test("R005 flags invalid YAML in a role=tag block", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: [unclosed\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R005");
});
test("R006 flags a role=tag naming an unknown element", () => {
const dir = fixture({
"doc.md": "```yaml role=tag\ntag: md-nope\n```\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-R006");
});
test("S001 reports when the markdown pipeline throws", () => {
(parseMarkdown as jest.Mock).mockImplementation(() => {
throw new Error("boom");
});
expect(codes(runCheck(fixture({ "doc.md": "# ok\n" })))).toContain(
"TTTK-S001",
);
});
test("C001 flags an unresolved content reference", () => {
const dir = fixture({
"doc.md": "See :md-table[./missing.csv].\n",
});
const report = runCheck(dir);
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-C001");
expect(diagnostic).toBeDefined();
expect(diagnostic!.line).toBe(1);
});
test("D001 flags an unregistered :md-* element", () => {
const dir = fixture({
"doc.md": "A card: :md-card[./cards.csv]\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-D001");
});
test("L001 flags a missing link target", () => {
const dir = fixture({
"doc.md": "See [rules](./rules.md).\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-L001");
});
test("S002 flags an unterminated container directive", () => {
const dir = fixture({
"doc.md": "::: box\nsome content\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-S002");
});
test("C004 flags an embed cycle", () => {
const dir = fixture({
"a.md": ":md-embed[./b.md]\n",
"b.md": ":md-embed[./a.md]\n",
});
expect(codes(runCheck(dir))).toContain("TTTK-C004");
});
test("directives inside fenced blocks are not live content", () => {
const dir = fixture({
"doc.md": "```\n:md-table[./missing.csv]\n:md-card[x]\n```\n",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("directives inside inline code are not live content", () => {
const dir = fixture({
"doc.md": "Use `:md-table[./missing.csv]` to embed a table.\n",
});
expect(runCheck(dir).diagnostics).toEqual([]);
});
test("diagnostics are sorted by file, then line", () => {
const dir = fixture({
"b.md": "See [x](./missing.md).\n",
"a.md": "\n\nSee [y](./missing.md).\n",
});
const files = runCheck(dir).diagnostics.map((d) => d.file);
expect(files).toEqual([...files].sort());
expect(files[0]).toBe("a.md");
});
});
describe("formatHuman", () => {
test("groups by file and prints a summary", () => {
const dir = fixture({ "doc.md": "See :md-table[./missing.csv].\n" });
const text = formatHuman(runCheck(dir));
expect(text).toContain("doc.md");
expect(text).toContain("TTTK-C001");
expect(text).toContain("1 error");
expect(text).toContain("1 file checked");
});
});