feat(cli): Complete the check rule catalog
Add D002 (malformed attribute blocks), R004 (skipped declare rows), and V001-V005 (CSV data files). CSV checks run once per referenced file and also cover inline spark-table bodies. Generate docs/reference.md from the known.ts catalog via npm run gen:reference, and add drift guards asserting the catalog matches the customElement call sites and the committed reference.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* CSV data-file checks (V001–V005).
|
||||
*
|
||||
* Two entry points:
|
||||
* - `csvFilesCheck` validates every CSV referenced by a content directive,
|
||||
* once per file (a shared CSV must not be reported twice).
|
||||
* - `sparkTableCheck` validates inline `role=spark-table` bodies, which live
|
||||
* in the document itself and so carry document line numbers.
|
||||
*
|
||||
* Parsing mirrors `parseCSVString` (`src/components/utils/csv-loader.ts`):
|
||||
* BOM stripped, `#` lines are comments, cells trimmed, empty lines skipped,
|
||||
* and YAML front matter split off first.
|
||||
*/
|
||||
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
import yaml from "js-yaml";
|
||||
import {
|
||||
isMarkdownTableLang,
|
||||
isSparkTableHeader,
|
||||
resolveContentEntry,
|
||||
type ContentRegistry,
|
||||
} from "../../content-registry.js";
|
||||
import { diagnosticAt, type DiagnosticSink } from "../diagnostics.js";
|
||||
import { elementFor } from "../known.js";
|
||||
import type { RawFence, ScanResult } from "../scan.js";
|
||||
import { SourceFile } from "../source.js";
|
||||
import type { Check, CheckContext } from "./context.js";
|
||||
import { displayPath } from "./context.js";
|
||||
import { readTagBlock } from "./tag-block.js";
|
||||
|
||||
/** A CSV body to validate, plus where it lives in its source. */
|
||||
export interface CsvTarget {
|
||||
/** Display path for diagnostics. */
|
||||
file: string;
|
||||
source: SourceFile;
|
||||
body: string;
|
||||
/** Offset in `source` where `body` begins. */
|
||||
bodyOffset: number;
|
||||
}
|
||||
|
||||
const PARSE_OPTIONS = {
|
||||
columns: false as const,
|
||||
relax_column_count: true,
|
||||
info: true as const,
|
||||
comment: "#",
|
||||
trim: true,
|
||||
skipEmptyLines: true,
|
||||
bom: true,
|
||||
};
|
||||
|
||||
interface CsvRecord {
|
||||
record: string[];
|
||||
info: { lines: number };
|
||||
}
|
||||
|
||||
/** Run V001–V005 over one CSV body. */
|
||||
export function checkCsvBody(target: CsvTarget, sink: DiagnosticSink): void {
|
||||
const { file, source, body, bodyOffset } = target;
|
||||
const { csv, csvOffset, frontmatter } = splitFrontMatter(body);
|
||||
const baseLine = source.position(bodyOffset + csvOffset).line;
|
||||
const offsetAt = (csvLine: number): number =>
|
||||
source.lineStart(baseLine + csvLine - 1);
|
||||
|
||||
let records: CsvRecord[];
|
||||
try {
|
||||
records = parse(csv, PARSE_OPTIONS) as unknown as CsvRecord[];
|
||||
} catch (error) {
|
||||
const detail = error as { lines?: number; message?: string };
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
offsetAt(detail.lines ?? 1),
|
||||
"TTTK-V001",
|
||||
"error",
|
||||
`CSV failed to parse: ${detail.message ?? String(error)}`,
|
||||
"check quoting; wrap values containing commas or quotes in double quotes",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (records.length === 0) return;
|
||||
const header = records[0].record;
|
||||
const headerSet = new Set(header);
|
||||
const labelIndex = header.indexOf("label");
|
||||
const bodyIndex = header.indexOf("body");
|
||||
const sparkTable = isSparkTableHeader(header[0] ?? "");
|
||||
const seenLabels = new Map<string, number>();
|
||||
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const { record, info } = records[i];
|
||||
const line = info.lines;
|
||||
const offset = offsetAt(line);
|
||||
|
||||
if (record.length !== header.length) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
offset,
|
||||
"TTTK-V002",
|
||||
"warning",
|
||||
`row has ${record.length} column${record.length === 1 ? "" : "s"}, header has ${header.length}`,
|
||||
"align the row with the header, or quote cells containing commas",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (labelIndex >= 0) {
|
||||
const label = (record[labelIndex] ?? "").trim();
|
||||
if (label) {
|
||||
const first = seenLabels.get(label);
|
||||
if (first !== undefined) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
offset,
|
||||
"TTTK-V003",
|
||||
"warning",
|
||||
`duplicate label "${label}" (first at line ${first})`,
|
||||
"labels identify rows; make each unique",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
seenLabels.set(label, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bodyIndex >= 0) {
|
||||
const cell = record[bodyIndex] ?? "";
|
||||
for (const match of cell.matchAll(/\{\{([^}]+)\}\}/g)) {
|
||||
const key = match[1].trim();
|
||||
if (headerSet.has(key) || frontmatter.has(key)) continue;
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
offset,
|
||||
"TTTK-V004",
|
||||
"warning",
|
||||
`{{${key}}} is not a column or front-matter key`,
|
||||
`available: ${[...headerSet].join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (sparkTable) {
|
||||
const roll = (record[0] ?? "").trim();
|
||||
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
offset,
|
||||
"TTTK-V005",
|
||||
"warning",
|
||||
`spark-table roll value "${roll}" is not a number or range`,
|
||||
"use a number (3) or a range (3-5)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every CSV referenced by a content directive, once per file.
|
||||
* Inline spark-table bodies are handled by `sparkTableCheck`.
|
||||
*/
|
||||
export function csvFilesCheck(
|
||||
mdKeys: string[],
|
||||
scans: Map<string, ScanResult>,
|
||||
registry: ContentRegistry,
|
||||
index: Record<string, string>,
|
||||
sink: DiagnosticSink,
|
||||
): void {
|
||||
const targets = new Map<string, CsvTarget>();
|
||||
|
||||
const add = (docKey: string, ref: string): void => {
|
||||
const resolved = resolveContentEntry(registry, docKey, ref);
|
||||
if (!resolved || resolved.inline) return;
|
||||
if (!resolved.path.endsWith(".csv")) return;
|
||||
if (targets.has(resolved.path)) return;
|
||||
const body = index[resolved.path];
|
||||
if (body == null) return;
|
||||
targets.set(resolved.path, {
|
||||
file: displayPath(resolved.path),
|
||||
source: new SourceFile(body),
|
||||
body,
|
||||
bodyOffset: 0,
|
||||
});
|
||||
};
|
||||
|
||||
for (const docKey of mdKeys) {
|
||||
const scan = scans.get(docKey);
|
||||
if (!scan) continue;
|
||||
|
||||
for (const directive of scan.directives) {
|
||||
if (directive.icon) continue;
|
||||
const spec = elementFor(directive.name);
|
||||
if (!spec || spec.refKind !== "csv") continue;
|
||||
const ref = directive.body.trim();
|
||||
if (ref) add(docKey, ref);
|
||||
}
|
||||
|
||||
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 !== "csv") continue;
|
||||
add(docKey, body.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets.values()) checkCsvBody(target, sink);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate inline `role=spark-table` bodies. CSV bodies get the full V001–V005
|
||||
* treatment; markdown pipe tables only get V005 (their structure is markdown,
|
||||
* not CSV).
|
||||
*/
|
||||
export const sparkTableCheck: Check = (ctx) => {
|
||||
const { scan, source, file, sink } = ctx;
|
||||
|
||||
for (const fence of scan.fences) {
|
||||
if (fence.attrs.role !== "spark-table") continue;
|
||||
if (isMarkdownTableLang(fence.lang)) {
|
||||
checkMarkdownSparkTable(fence, ctx);
|
||||
continue;
|
||||
}
|
||||
checkCsvBody(
|
||||
{ file, source, body: fence.body, bodyOffset: fence.bodyOffset },
|
||||
sink,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** V005 for a markdown pipe-table spark table. */
|
||||
function checkMarkdownSparkTable(fence: RawFence, ctx: CheckContext): void {
|
||||
const { source, file, sink } = ctx;
|
||||
const baseLine = source.position(fence.bodyOffset).line;
|
||||
const lines = fence.body.split(/\r?\n/);
|
||||
let dataRows = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (!trimmed.startsWith("|")) continue;
|
||||
dataRows++;
|
||||
if (dataRows <= 2) continue; // header + separator
|
||||
const cells = trimmed
|
||||
.split("|")
|
||||
.map((cell) => cell.trim())
|
||||
.filter(Boolean);
|
||||
const roll = cells[0] ?? "";
|
||||
if (roll && !/^\d+(\s*-\s*\d+)?$/.test(roll)) {
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
source.lineStart(baseLine + i),
|
||||
"TTTK-V005",
|
||||
"warning",
|
||||
`spark-table roll value "${roll}" is not a number or range`,
|
||||
"use a number (3) or a range (3-5)",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split YAML front matter off a CSV body, mirroring `parseFrontMatter`
|
||||
* (`src/cli/tools/frontmatter/shared.ts`). Returns the remaining CSV, its
|
||||
* offset within `content`, and the front-matter keys.
|
||||
*/
|
||||
function splitFrontMatter(content: string): {
|
||||
csv: string;
|
||||
csvOffset: number;
|
||||
frontmatter: Set<string>;
|
||||
} {
|
||||
const trimmed = content.trim();
|
||||
const parts = trimmed.split(/(?:^|\n)---\s*\n/g);
|
||||
if (parts.length !== 3 || parts[0] !== "") {
|
||||
return { csv: content, csvOffset: 0, frontmatter: new Set() };
|
||||
}
|
||||
|
||||
let frontmatter = new Set<string>();
|
||||
try {
|
||||
const data = yaml.load(parts[1].trim());
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
frontmatter = new Set(Object.keys(data as Record<string, unknown>));
|
||||
}
|
||||
} catch {
|
||||
return { csv: content, csvOffset: 0, frontmatter: new Set() };
|
||||
}
|
||||
|
||||
const csv = parts.slice(2).join("---\n").trimStart();
|
||||
const lead = content.length - content.trimStart().length;
|
||||
const csvOffset = lead + (trimmed.length - csv.length);
|
||||
return { csv, csvOffset, frontmatter };
|
||||
}
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* Directive checks: `:md-*` element names must be registered.
|
||||
* Directive checks: `:md-*` element names must be registered, and attribute
|
||||
* blocks must be well formed.
|
||||
*/
|
||||
|
||||
import { diagnosticAt } from "../diagnostics.js";
|
||||
import { ELEMENT_NAMES, KNOWN_ELEMENTS } from "../known.js";
|
||||
import type { Check } from "./context.js";
|
||||
import type { RawDirective } from "../scan.js";
|
||||
import type { Check, CheckContext } from "./context.js";
|
||||
|
||||
export const directivesCheck: Check = (ctx) => {
|
||||
const { scan, source, file, sink } = ctx;
|
||||
|
||||
for (const directive of scan.directives) {
|
||||
checkAttrs(directive, ctx);
|
||||
|
||||
if (directive.icon) continue;
|
||||
if (!directive.name.startsWith("md-")) continue;
|
||||
if (ELEMENT_NAMES.has(directive.name)) continue;
|
||||
@@ -31,6 +35,67 @@ export const directivesCheck: Check = (ctx) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* D002 — an attribute block contains a token that is neither `key=value`, a
|
||||
* `.class`/`#id` shorthand, nor a bare boolean flag. This is what an unquoted
|
||||
* value with a space looks like: `{pins=A:30, 40}` parses as `pins=A:30,` plus
|
||||
* a stray `40`, silently dropping the rest of the value.
|
||||
*/
|
||||
function checkAttrs(directive: RawDirective, ctx: CheckContext): void {
|
||||
const raw = directive.rawAttrs;
|
||||
if (!raw.trim()) return;
|
||||
|
||||
for (const { token, index } of attrTokens(raw)) {
|
||||
if (/^[.#][\w-]+(?:[.#][\w-]+)*$/.test(token)) continue; // .class / #id
|
||||
if (/^[A-Za-z_][\w-]*=/.test(token)) continue; // key=value
|
||||
if (/^[A-Za-z_][\w-]*$/.test(token)) continue; // boolean flag
|
||||
|
||||
ctx.sink.report(
|
||||
diagnosticAt(
|
||||
ctx.file,
|
||||
ctx.source,
|
||||
attrBlockOffset(directive) + index,
|
||||
"TTTK-D002",
|
||||
"error",
|
||||
`malformed attribute \`${token}\` in the attribute block`,
|
||||
"quote values containing spaces, e.g. {pins=\"A:30, 40\"}",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset of the `{` that opens a directive's attribute block. */
|
||||
function attrBlockOffset(directive: RawDirective): number {
|
||||
// `:name[body]{` or `:[body]{`
|
||||
const prefix = directive.icon
|
||||
? 3 + directive.body.length
|
||||
: 2 + directive.name.length + 1 + directive.body.length;
|
||||
return directive.offset + prefix;
|
||||
}
|
||||
|
||||
/** Split an attribute block on whitespace, honoring quoted values. */
|
||||
function* attrTokens(raw: string): Generator<{ token: string; index: number }> {
|
||||
let i = 0;
|
||||
while (i < raw.length) {
|
||||
while (i < raw.length && /\s/.test(raw[i])) i++;
|
||||
if (i >= raw.length) break;
|
||||
const start = i;
|
||||
let quote: string | null = null;
|
||||
while (i < raw.length) {
|
||||
const ch = raw[i];
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
} else if (/\s/.test(ch)) {
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
yield { token: raw.slice(start, i), index: start };
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the closest candidate within edit distance 2, if any. */
|
||||
function closestName(name: string, candidates: string[]): string | null {
|
||||
let best: string | null = null;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Code-block role checks.
|
||||
*/
|
||||
|
||||
import { parse } from "csv-parse/browser/esm/sync";
|
||||
import { parseDeclareCsv } from "../../completions/declare-parser.js";
|
||||
import { diagnosticAt } from "../diagnostics.js";
|
||||
import { ELEMENT_NAMES, KNOWN_ROLES } from "../known.js";
|
||||
@@ -73,6 +74,50 @@ function checkDeclare(fence: RawFence, ctx: CheckContext): void {
|
||||
"role=declare needs `key` and `expr` columns; `tag` and `threshold` are optional",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
checkDeclareRows(fence, ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* R004 — `parseDeclareCsv` silently skips rows with an empty `key` or `expr`.
|
||||
* Re-parse with line info so the skipped row can be localized.
|
||||
*/
|
||||
function checkDeclareRows(fence: RawFence, ctx: CheckContext): void {
|
||||
const { source, file, sink } = ctx;
|
||||
let records: Array<{ record: Record<string, string>; info: { lines: number } }>;
|
||||
try {
|
||||
records = parse(fence.body, {
|
||||
columns: true,
|
||||
trim: true,
|
||||
skipEmptyLines: true,
|
||||
info: true,
|
||||
}) as unknown as typeof records;
|
||||
} catch {
|
||||
return; // R003 already reported the parse failure
|
||||
}
|
||||
if (records.length === 0) return;
|
||||
|
||||
const headers = Object.keys(records[0].record).map((h) => h.toLowerCase());
|
||||
if (!headers.includes("key") || !headers.includes("expr")) return;
|
||||
|
||||
const baseLine = source.position(fence.bodyOffset).line;
|
||||
for (const { record, info } of records) {
|
||||
const key = record.key ?? "";
|
||||
const expr = record.expr ?? "";
|
||||
if (key && expr) continue;
|
||||
sink.report(
|
||||
diagnosticAt(
|
||||
file,
|
||||
source,
|
||||
source.lineStart(baseLine + info.lines - 1),
|
||||
"TTTK-R004",
|
||||
"warning",
|
||||
`declare row skipped: ${!key ? "empty key" : "empty expr"}`,
|
||||
"every declare row needs both a key and an expression",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { csvFilesCheck, sparkTableCheck } from "./checks/csv.js";
|
||||
import { directivesCheck } from "./checks/directives.js";
|
||||
import { linksCheck } from "./checks/links.js";
|
||||
import { refsCheck } from "./checks/refs.js";
|
||||
@@ -35,6 +36,7 @@ const CHECKS: readonly Check[] = [
|
||||
structuralCheck,
|
||||
directivesCheck,
|
||||
rolesCheck,
|
||||
sparkTableCheck,
|
||||
refsCheck,
|
||||
linksCheck,
|
||||
assetsCheck,
|
||||
@@ -81,6 +83,13 @@ export function runCheck(dir: string): CheckReport {
|
||||
}
|
||||
|
||||
checkEmbedCycles(mdKeys, docs, registry, sink);
|
||||
csvFilesCheck(
|
||||
mdKeys,
|
||||
new Map(mdKeys.map((key) => [key, docs.get(key)!.scan])),
|
||||
registry,
|
||||
index,
|
||||
sink,
|
||||
);
|
||||
|
||||
return buildReport(rootDir, mdKeys.length, sink.items);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Drift guard: `KNOWN_ELEMENTS` in `known.ts` must match the `customElement`
|
||||
* registrations under `src/components`. This is what would have caught
|
||||
* `:md-card` — matched by the completion scanner but registered nowhere.
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { ELEMENT_NAMES } from "./known.js";
|
||||
import { renderReference } from "./reference.js";
|
||||
|
||||
const COMPONENTS_DIR = join(process.cwd(), "src", "components");
|
||||
const CUSTOM_ELEMENT_RE = /customElement(?:<[^>]*>)?\(\s*["'](md-[\w-]+)["']/g;
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) files.push(...sourceFiles(full));
|
||||
else if (/\.tsx?$/.test(entry.name)) files.push(full);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function registeredElements(): Set<string> {
|
||||
const names = new Set<string>();
|
||||
for (const file of sourceFiles(COMPONENTS_DIR)) {
|
||||
const source = readFileSync(file, "utf-8");
|
||||
for (const match of source.matchAll(CUSTOM_ELEMENT_RE)) {
|
||||
names.add(match[1]);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("known element catalog", () => {
|
||||
test("matches the customElement registrations under src/components", () => {
|
||||
const registered = [...registeredElements()].sort();
|
||||
const known = [...ELEMENT_NAMES].sort();
|
||||
|
||||
expect(known).toEqual(registered);
|
||||
});
|
||||
|
||||
test("docs/reference.md is up to date", () => {
|
||||
const committed = readFileSync(
|
||||
join(process.cwd(), "docs", "reference.md"),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
expect(committed).toBe(renderReference());
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,8 @@
|
||||
* 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.
|
||||
* 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. */
|
||||
@@ -19,6 +19,8 @@ export interface ElementSpec {
|
||||
attrs: string[];
|
||||
summary: string;
|
||||
refKind: RefKind;
|
||||
/** Canonical usage examples, rendered into the reference. */
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
/** The registered `md-*` custom elements (mirrors `customElement(...)` calls). */
|
||||
@@ -29,6 +31,11 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["key"],
|
||||
summary: "Clickable dice roller; `key` stores the result in the URL.",
|
||||
refKind: "none",
|
||||
examples: [
|
||||
':md-dice[1d20+5]{key="attack"}',
|
||||
":md-dice[2d6+3]",
|
||||
":md-dice[3d6kh1]",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "md-table",
|
||||
@@ -36,6 +43,11 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["roll", "remix"],
|
||||
summary: "Tab-separated view of a CSV with optional group tabs.",
|
||||
refKind: "csv",
|
||||
examples: [
|
||||
":md-table[./npcs.csv]",
|
||||
":md-table[./encounters.csv]{roll=true}",
|
||||
":md-table[./quests.csv]{roll=true remix=true}",
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "md-deck",
|
||||
@@ -43,6 +55,9 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["size", "grid", "layers"],
|
||||
summary: "Card layout from a CSV (legacy; prefer `yaml role=tag`).",
|
||||
refKind: "csv",
|
||||
examples: [
|
||||
':md-deck[./cards.csv]{size="63x88" grid="5x5" layers="title:1,1-5,1f8 body:1,5-5,8f3"}',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "md-link",
|
||||
@@ -50,6 +65,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: [],
|
||||
summary: "Clickable link that expands the target article inline.",
|
||||
refKind: "md",
|
||||
examples: [":md-link[./rules.md]", ":md-link[./rules.md#combat]"],
|
||||
},
|
||||
{
|
||||
name: "md-embed",
|
||||
@@ -57,6 +73,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["headingBase"],
|
||||
summary: "Renders the target article or section inline.",
|
||||
refKind: "md",
|
||||
examples: [":md-embed[./rules.md#combat]{headingBase=1}"],
|
||||
},
|
||||
{
|
||||
name: "md-bg",
|
||||
@@ -64,6 +81,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["fit"],
|
||||
summary: "Sets the surrounding article card background.",
|
||||
refKind: "image",
|
||||
examples: [':md-bg[./images/dungeon-bg.jpg]{fit="cover"}', ":md-bg[#2a1a3a]"],
|
||||
},
|
||||
{
|
||||
name: "md-border",
|
||||
@@ -71,6 +89,10 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["width", "slice"],
|
||||
summary: "Applies a border to the article card.",
|
||||
refKind: "image",
|
||||
examples: [
|
||||
":md-border[crimson]{.b}",
|
||||
':md-border[./frame.png]{width="2mm" slice="10" .all.round}',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "md-font",
|
||||
@@ -78,6 +100,9 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["source", "weight", "color"],
|
||||
summary: "Sets the article font.",
|
||||
refKind: "none",
|
||||
examples: [
|
||||
':md-font[Noto Sans SC]{source="google" weight="700" color="#1a1a2e"}',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "md-pins",
|
||||
@@ -85,6 +110,10 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["pins", "fixed", "labelStart"],
|
||||
summary: "Numbered markers on an image.",
|
||||
refKind: "image",
|
||||
examples: [
|
||||
':md-pins[./images/battle-map.png]{pins="A:25,50 B:75,30 C:50,80" fixed}',
|
||||
':md-pins[./images/dungeon.png]{labelStart="1"}',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "md-commander",
|
||||
@@ -92,6 +121,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["placeholder", "class", "height"],
|
||||
summary: "Interactive command history / tracker panel.",
|
||||
refKind: "csv",
|
||||
examples: [':md-commander[./commands.csv]{placeholder="输入命令…" height="400px"}'],
|
||||
},
|
||||
{
|
||||
name: "md-yarn-spinner",
|
||||
@@ -99,6 +129,7 @@ export const KNOWN_ELEMENTS: readonly ElementSpec[] = [
|
||||
attrs: ["start"],
|
||||
summary: "Runs a Yarn Spinner dialogue file.",
|
||||
refKind: "yarn",
|
||||
examples: [':md-yarn-spinner[./story.yarn]{start="start"}'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -122,15 +153,46 @@ export const COLOR_BODY_ELEMENTS: ReadonlySet<string> = new Set([
|
||||
|
||||
export interface RoleSpec {
|
||||
role: string;
|
||||
/** Example info string, e.g. "csv role=spark-table". */
|
||||
info: string;
|
||||
summary: string;
|
||||
/** Canonical fenced-block examples, rendered into the reference. */
|
||||
examples: 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." },
|
||||
{
|
||||
role: "spark-table",
|
||||
info: "csv role=spark-table",
|
||||
summary: "CSV (or markdown pipe table) rendered as `:md-table`.",
|
||||
examples: [
|
||||
"```csv role=spark-table\nd6,结果\n1,遭遇强盗\n2,平安无事\n```",
|
||||
"```markdown role=spark-table\n| d6 | 结果 |\n|----|------|\n| 1 | 遭遇强盗 |\n| 2 | 平安无事 |\n```",
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "declare",
|
||||
info: "csv role=declare",
|
||||
summary: "Reactive variable / tag-modifier definitions.",
|
||||
examples: [
|
||||
"```csv role=declare\ntag,threshold,key,expr\n,,$hp,$con*5+$mod_hp\n#warrior,2,$mod_hp,20\n```",
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "file",
|
||||
info: "<lang> role=file",
|
||||
summary: "Stored as inline text content and stripped.",
|
||||
examples: ["```text role=file\nStored as inline content.\n```"],
|
||||
},
|
||||
{
|
||||
role: "tag",
|
||||
info: "yaml role=tag",
|
||||
summary: "YAML rendered as a custom element.",
|
||||
examples: [
|
||||
"```yaml role=tag\ntag: md-deck\nbody: ./cards.csv\nsize: 63x88\ngrid: 5x5\n```",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const KNOWN_ROLES: ReadonlySet<string> = new Set(
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Render the `known.ts` catalog to markdown.
|
||||
*
|
||||
* `docs/reference.md` is generated from this; do not hand-edit it. Regenerate
|
||||
* with `npm run gen:reference`. The skill copy can be refreshed with
|
||||
* `npm run gen:reference -- --out <path>`.
|
||||
*/
|
||||
|
||||
import {
|
||||
COLOR_BODY_ELEMENTS,
|
||||
KNOWN_ELEMENTS,
|
||||
ROLE_SPECS,
|
||||
type ElementSpec,
|
||||
} from "./known.js";
|
||||
|
||||
/** Wrap `code` in a fenced block, widening the fence when it contains one. */
|
||||
function fence(code: string, lang = "markdown"): string {
|
||||
const ticks = code.includes("```") ? "````" : "```";
|
||||
return `${ticks}${lang}\n${code}\n${ticks}`;
|
||||
}
|
||||
|
||||
function elementSection(element: ElementSpec): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`### \`:${element.name}\``);
|
||||
lines.push("");
|
||||
lines.push(element.summary);
|
||||
lines.push("");
|
||||
lines.push(`- Body: ${element.body}`);
|
||||
if (element.attrs.length) {
|
||||
lines.push(`- Attributes: ${element.attrs.map((a) => `\`${a}\``).join(", ")}`);
|
||||
}
|
||||
if (COLOR_BODY_ELEMENTS.has(element.name)) {
|
||||
lines.push("- Body may also be a CSS color (e.g. `#2a1a3a`, `crimson`).");
|
||||
}
|
||||
lines.push("");
|
||||
for (const example of element.examples) {
|
||||
lines.push(fence(example));
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
/** Render the full reference document. */
|
||||
export function renderReference(): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push("# tttk syntax reference");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Generated from `src/cli/validate/known.ts` by `npm run gen:reference`.",
|
||||
);
|
||||
lines.push("Do not edit by hand.");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Everything not listed here is standard GFM markdown. Element names are",
|
||||
);
|
||||
lines.push("prefixed `md-` and must match exactly.");
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Directive syntax");
|
||||
lines.push("");
|
||||
lines.push(fence(":name[content]{key=\"value\" .class #id}", "text"));
|
||||
lines.push("");
|
||||
lines.push("- `[content]` is the directive body — a path, dice formula, color, etc.");
|
||||
lines.push("- `{key=\"value\"}` sets attributes; `{.class}` and `{#id}` are shorthands.");
|
||||
lines.push("- Container directives use `::: name` … `:::`; nest with `::::`, `:::::`.");
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Custom elements");
|
||||
lines.push("");
|
||||
lines.push("| Directive | Body | Key attributes |");
|
||||
lines.push("|---|---|---|");
|
||||
for (const element of KNOWN_ELEMENTS) {
|
||||
const attrs = element.attrs.length
|
||||
? element.attrs.map((a) => `\`${a}\``).join(", ")
|
||||
: "—";
|
||||
lines.push(`| \`:${element.name}\` | ${element.body} | ${attrs} |`);
|
||||
}
|
||||
lines.push("");
|
||||
for (const element of KNOWN_ELEMENTS) {
|
||||
lines.push(elementSection(element));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("## Inline directives");
|
||||
lines.push("");
|
||||
lines.push("### Icons — `:[name]`");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Inserts an icon looked up under `assets/` (searching upward from the file).",
|
||||
);
|
||||
lines.push(
|
||||
"Default extension is `.png`; `.svg`, `.gif`, `.jpg`, `.jpeg`, and `.webp`",
|
||||
);
|
||||
lines.push("are also supported.");
|
||||
lines.push("");
|
||||
lines.push(fence(":[attack] :[shield.svg] :[potion]"));
|
||||
lines.push("");
|
||||
lines.push("### Command links — `:cmd[command]{label=...}`");
|
||||
lines.push("");
|
||||
lines.push("Renders a clickable span that dispatches a journal command.");
|
||||
lines.push("");
|
||||
lines.push(fence(":cmd[roll 1d20+5]{label=Roll initiative}"));
|
||||
lines.push("");
|
||||
|
||||
lines.push("## Code block roles");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"The `role=` attribute on the info string determines how a fenced block is",
|
||||
);
|
||||
lines.push("handled.");
|
||||
lines.push("");
|
||||
for (const role of ROLE_SPECS) {
|
||||
lines.push(`### \`role=${role.role}\``);
|
||||
lines.push("");
|
||||
lines.push(role.summary);
|
||||
lines.push("");
|
||||
for (const example of role.examples) {
|
||||
lines.push(fence(example));
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push("### No role / unknown role");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"No role → a normal visible code block. Unknown role → warned about and",
|
||||
);
|
||||
lines.push("stripped.");
|
||||
lines.push("");
|
||||
|
||||
lines.push("## CSV data files");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Keep CSV files clean — a header row and data rows. Put deck configuration",
|
||||
);
|
||||
lines.push("and templates in a `yaml role=tag` block's `data-config`.");
|
||||
lines.push("");
|
||||
lines.push("- `label` — unique row id (number or text).");
|
||||
lines.push("- `body` — main content, supports markdown.");
|
||||
lines.push("- Any other columns are custom fields.");
|
||||
lines.push(
|
||||
"- `{{prop}}` in `body` interpolates another column (or front-matter key)",
|
||||
);
|
||||
lines.push(" from the same row; with `{remix}` it pulls from a random row.");
|
||||
lines.push(
|
||||
"- Wrap values containing `,`, `\"`, or newlines in double quotes; escape",
|
||||
);
|
||||
lines.push(" `\"` as `\"\"`. Lines beginning with `#` are comments.");
|
||||
lines.push("");
|
||||
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export interface RawDirective {
|
||||
body: string;
|
||||
/** Parsed `{key=value}` attributes (shorthands such as `.class` ignored). */
|
||||
attrs: Record<string, string>;
|
||||
/** Raw attribute-block contents, without the braces; "" when absent. */
|
||||
rawAttrs: string;
|
||||
/** Offset of the leading ":". */
|
||||
offset: number;
|
||||
/** True for the icon form `:[name]`. */
|
||||
@@ -172,6 +174,7 @@ function scanLine(line: Line, result: ScanResult): void {
|
||||
name: m[1],
|
||||
body: m[2],
|
||||
attrs: parseDirectiveAttrs(m[3] || ""),
|
||||
rawAttrs: m[3] || "",
|
||||
offset: line.offset + m.index,
|
||||
icon: false,
|
||||
});
|
||||
@@ -183,6 +186,7 @@ function scanLine(line: Line, result: ScanResult): void {
|
||||
name: "",
|
||||
body: m[1],
|
||||
attrs: parseDirectiveAttrs(m[2] || ""),
|
||||
rawAttrs: m[2] || "",
|
||||
offset: line.offset + m.index,
|
||||
icon: true,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,12 @@ export class SourceFile {
|
||||
return { line: lo + 1, column: clamped - this.lineStarts[lo] + 1 };
|
||||
}
|
||||
|
||||
/** Offset of the start of a 1-based line (clamped to the file). */
|
||||
lineStart(line: number): number {
|
||||
const index = Math.max(0, Math.min(line - 1, this.lineStarts.length - 1));
|
||||
return this.lineStarts[index];
|
||||
}
|
||||
|
||||
/** The text of a 1-based line, without its terminator. */
|
||||
lineText(line: number): string {
|
||||
const start = this.lineStarts[line - 1] ?? 0;
|
||||
|
||||
@@ -144,6 +144,134 @@ describe("runCheck", () => {
|
||||
expect(codes(runCheck(dir))).toContain("TTTK-C004");
|
||||
});
|
||||
|
||||
test("D002 flags an unquoted attribute value containing a space", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": ":md-pins[./map.png]{pins=A:30, 40}\n",
|
||||
});
|
||||
|
||||
expect(codes(runCheck(dir))).toContain("TTTK-D002");
|
||||
});
|
||||
|
||||
test("D002 accepts a quoted attribute value containing a space", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": ':md-pins[./map.png]{pins="A:30, 40"}\n',
|
||||
});
|
||||
|
||||
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
|
||||
});
|
||||
|
||||
test("D002 accepts concatenated class shorthands and boolean flags", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": ':md-border[./frame.png]{width="2mm" slice="10" .all.round}\n',
|
||||
});
|
||||
|
||||
expect(codes(runCheck(dir))).not.toContain("TTTK-D002");
|
||||
});
|
||||
|
||||
test("R004 flags a declare row with an empty key or expr", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "```csv role=declare\nkey,expr\n$hp,1\n,2\n```\n",
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-R004");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.line).toBe(4);
|
||||
});
|
||||
|
||||
test("V001 flags a CSV that fails to parse", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "See :md-table[./bad.csv].\n",
|
||||
"bad.csv": 'label,body\n1,"unterminated\n',
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V001");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.file).toBe("bad.csv");
|
||||
});
|
||||
|
||||
test("V002 flags a row whose column count differs from the header", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "See :md-table[./data.csv].\n",
|
||||
"data.csv": "label,body\n1,a\n2,b,c\n",
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V002");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.line).toBe(3);
|
||||
});
|
||||
|
||||
test("V003 flags duplicate label values", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "See :md-table[./data.csv].\n",
|
||||
"data.csv": "label,body\n1,a\n1,b\n",
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V003");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.line).toBe(3);
|
||||
});
|
||||
|
||||
test("V004 flags an unresolved {{prop}} in the body column", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "See :md-table[./data.csv].\n",
|
||||
"data.csv": "label,body\n1,{{missing}}\n",
|
||||
});
|
||||
|
||||
expect(codes(runCheck(dir))).toContain("TTTK-V004");
|
||||
});
|
||||
|
||||
test("V004 accepts a {{prop}} naming a column or front-matter key", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "See :md-table[./data.csv].\n",
|
||||
"data.csv": "---\nsystem: D&D\n---\nlabel,body,name\n1,{{name}} of {{system}}\n",
|
||||
});
|
||||
|
||||
expect(codes(runCheck(dir))).not.toContain("TTTK-V004");
|
||||
});
|
||||
|
||||
test("V005 flags a spark-table roll value that is not a number or range", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "```csv role=spark-table\nd6,result\nx,foo\n```\n",
|
||||
});
|
||||
|
||||
const report = runCheck(dir);
|
||||
const diagnostic = report.diagnostics.find((d) => d.code === "TTTK-V005");
|
||||
|
||||
expect(diagnostic).toBeDefined();
|
||||
expect(diagnostic!.line).toBe(3);
|
||||
});
|
||||
|
||||
test("a clean CSV produces no diagnostics", () => {
|
||||
const dir = fixture({
|
||||
"doc.md": "See :md-table[./data.csv].\n",
|
||||
"data.csv": "label,body\n1,a\n2,b\n",
|
||||
});
|
||||
|
||||
expect(runCheck(dir).diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
test("a CSV referenced by two documents is only reported once", () => {
|
||||
const dir = fixture({
|
||||
"a.md": "See :md-table[./data.csv].\n",
|
||||
"b.md": "See :md-table[./data.csv].\n",
|
||||
"data.csv": "label,body\n1,a\n1,b\n",
|
||||
});
|
||||
|
||||
const v003 = runCheck(dir).diagnostics.filter(
|
||||
(d) => d.code === "TTTK-V003",
|
||||
);
|
||||
|
||||
expect(v003).toHaveLength(1);
|
||||
});
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user