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:
@@ -0,0 +1,23 @@
|
||||
import type { CheckCommandHandler } from "../types.js";
|
||||
import { formatHuman, formatJson } from "../validate/diagnostics.js";
|
||||
import { runCheck } from "../validate/index.js";
|
||||
|
||||
export const checkCommand: CheckCommandHandler = async (dir, options) => {
|
||||
let report;
|
||||
try {
|
||||
report = runCheck(dir);
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
options.json ? formatJson(report) : formatHuman(report, options.quiet),
|
||||
);
|
||||
|
||||
const failed =
|
||||
report.summary.errors > 0 ||
|
||||
(options.strict && report.summary.warnings > 0);
|
||||
if (failed) process.exitCode = 1;
|
||||
};
|
||||
@@ -494,7 +494,7 @@ export function deriveBlocks(registry: ContentRegistry): {
|
||||
* Remove fenced code blocks (backtick or tilde) from content, so text
|
||||
* scanners (headings, dice directives) don't match example code.
|
||||
*/
|
||||
function stripFencedBlocks(content: string): string {
|
||||
export function stripFencedBlocks(content: string): string {
|
||||
const out: string[] = [];
|
||||
let fence: string | null = null;
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Command } from "commander";
|
||||
import { serveCommand } from "./commands/serve.js";
|
||||
import { compileCommand } from "./commands/compile.js";
|
||||
import { checkCommand } from "./commands/check.js";
|
||||
import { mcpCommand } from "./commands/mcp.js";
|
||||
import type { ServeOptions, CompileOptions } from "./types.js";
|
||||
|
||||
@@ -22,6 +23,16 @@ program
|
||||
.option("--host <host>", "主机地址", "0.0.0.0")
|
||||
.action(serveCommand);
|
||||
|
||||
program
|
||||
.command("check")
|
||||
.alias("lint")
|
||||
.description("校验目录内的 tttk 文档,输出结构化诊断")
|
||||
.argument("[dir]", "要校验的目录", ".")
|
||||
.option("--json", "以 JSON 输出诊断", false)
|
||||
.option("--strict", "将警告视为错误", false)
|
||||
.option("--quiet", "只报告错误", false)
|
||||
.action(checkCommand);
|
||||
|
||||
program
|
||||
.command("compile")
|
||||
.description("[未实现] 存根命令:不会生成任何输出。预览内容请使用 tttk serve")
|
||||
|
||||
@@ -16,6 +16,17 @@ export type CompileCommandHandler = (
|
||||
options: CompileOptions,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface CheckOptions {
|
||||
json: boolean;
|
||||
strict: boolean;
|
||||
quiet: boolean;
|
||||
}
|
||||
|
||||
export type CheckCommandHandler = (
|
||||
dir: string,
|
||||
options: CheckOptions,
|
||||
) => Promise<void>;
|
||||
|
||||
export interface MarkdownFile {
|
||||
path: string;
|
||||
route: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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$/, "");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user