/*
Tier 2 structure typography rules (RFC-1069). Implements 10 rules across
3 families: HEAD (4), PAIR (3), MD (3). Rules operate on TextSegment objects
produced by the text-surface extractor (RFC-1068), never on raw file bytes.
All regexes use the `u` flag and Unicode property escapes where applicable.
- Do not extract text — that is text-surface.ts.
- Do not handle file I/O or command registration — that is the command adapter.
- Do not auto-fix — autofix is RFC-1072.
- Do not validate heading hierarchy — that is a semantic concern, not typographic.
- Do not validate list item punctuation — that is RFC-1067 (list.punctuation.validate).
- warpgogol-m000193: added TYPO-HEAD-04 (multi-sentence heading must end with terminal punctuation, autofixable) and gated TYPO-HEAD-01/03 to single-sentence headings — a heading composed of complete sentences may and must end with a sentence terminator, a title-fragment heading still may not.
RFC-1069
*/
import type { TextSegment } from "./text-surface.ts";
import { finding, type TypographyFinding, type TypographyRule } from "./rules-tier1.ts";
import { splitSentences } from "../semantic/extract.ts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type Tier2StructureRuleId = `TYPO-${"HEAD" | "PAIR" | "MD"}-${string}`;
// ---------------------------------------------------------------------------
// Helper: extract last path segment from a JSON-path
// e.g. "$.blocks[0].props.heading" → "heading"
// ---------------------------------------------------------------------------
function lastPathKey(path: string): string | undefined {
const parts = path.split(".");
const last = parts[parts.length - 1];
if (!last || last.startsWith("$") || last.includes("[")) return undefined;
return last;
}
// ---------------------------------------------------------------------------
// Helper: check if a segment is a heading (body or frontmatter)
// ---------------------------------------------------------------------------
function isBodyHeading(text: string): boolean {
// A body segment is a heading only if it is a single line starting with #
// (RFC-1083). Multi-line body fields starting with ## are NOT headings —
// they are body content. The `m` flag is unnecessary because
// !text.includes("\n") guarantees a single-line string.
return /^#{1,6}\s+.+$/u.test(text) && !text.includes("\n");
}
function isFrontmatterHeading(segment: TextSegment): boolean {
if (segment.source !== "frontmatter") return false;
const key = lastPathKey(segment.path);
return key === "heading" || key === "subheading";
}
function isHeadingSegment(segment: TextSegment): boolean {
return isBodyHeading(segment.text) || isFrontmatterHeading(segment);
}
/**
* Number of sentences inside a heading-class segment, via the locale-aware
* splitter (abbreviations, digit list markers, and lowercase continuations
* never produce a boundary). A single-sentence heading is a title fragment —
* terminal punctuation is forbidden (HEAD-01/02/03). A multi-sentence heading
* is prose in heading position — HEAD-04 requires the final sentence to be
* terminated, and HEAD-01/03 must not punish that terminator.
*/
function headingSentenceCount(segment: TextSegment): number {
return splitSentences(segment.text, segment.locale).length;
}
// ---------------------------------------------------------------------------
// TYPO-HEAD-01: Heading ends with a period
// ---------------------------------------------------------------------------
const HEAD01_PERIOD = /[.]\s*$/u;
const head01: TypographyRule = {
id: "TYPO-HEAD-01",
family: "HEAD",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
if (!isHeadingSegment(segment)) return [];
// Multi-sentence headings are required to end with terminal punctuation
// (HEAD-04) — a trailing period there is correct, not a defect.
if (headingSentenceCount(segment) > 1) return [];
const match = HEAD01_PERIOD.exec(text);
if (!match) return [];
const col = match.index;
return [
finding(
"TYPO-HEAD-01",
segment,
match[0],
col,
"Heading ends with a period — a heading is a title, not a sentence.",
"Remove the trailing period.",
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-HEAD-02: Heading ends with a colon or dash
// ---------------------------------------------------------------------------
const HEAD02_COLON_DASH = /[:\u2014\u2013-]\s*$/u;
const head02: TypographyRule = {
id: "TYPO-HEAD-02",
family: "HEAD",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
if (!isBodyHeading(text) && !isFrontmatterHeading(segment)) return [];
const match = HEAD02_COLON_DASH.exec(text);
if (!match) return [];
const col = match.index;
return [
finding(
"TYPO-HEAD-02",
segment,
match[0],
col,
"Heading ends with a colon or dash — trailing punctuation is always wrong in a heading.",
"Remove the trailing colon or dash.",
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-HEAD-03: Heading ends with other closing punctuation (!?)])
// ---------------------------------------------------------------------------
const HEAD03_OTHER = /[)!?]\s*$/u;
const head03: TypographyRule = {
id: "TYPO-HEAD-03",
family: "HEAD",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
if (!isHeadingSegment(segment)) return [];
// Same gate as HEAD-01: a multi-sentence heading may end with ! or ?
// (any sentence terminator satisfies HEAD-04).
if (headingSentenceCount(segment) > 1) return [];
const match = HEAD03_OTHER.exec(text);
if (!match) return [];
const col = match.index;
return [
finding(
"TYPO-HEAD-03",
segment,
match[0],
col,
"Heading ends with closing punctuation (!, ?, or )) — a heading should not end with these marks.",
"Remove the trailing punctuation.",
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-HEAD-04: Multi-sentence heading lacks terminal punctuation
// A heading composed of two or more sentences is prose in heading position —
// every sentence, including the last, must be terminated (. ! ?). Title
// fragments (single sentence) are governed by HEAD-01..03 instead.
// Autofixable: inserts "." at the end of the value.
// ---------------------------------------------------------------------------
const HEAD04_TERMINAL = /[.!?]\s*$/u;
// Chars that differ between the parsed value and the YAML source line —
// a fix match that cannot be located verbatim would corrupt the file.
const HEAD04_UNSAFE_MATCH = /["'\\\n]/u;
const head04: TypographyRule = {
id: "TYPO-HEAD-04",
family: "HEAD",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
if (!isHeadingSegment(segment)) return [];
if (headingSentenceCount(segment) < 2) return [];
if (HEAD04_TERMINAL.test(text)) return [];
// The fix locates the raw value verbatim on the source line — only offer
// it when the parsed value can appear there unescaped.
const rawMatch = segment.raw.trimEnd();
const fix =
rawMatch.length > 0 && !HEAD04_UNSAFE_MATCH.test(rawMatch)
? { type: "insert" as const, at: text.trimEnd().length, text: "." }
: null;
return [
finding(
"TYPO-HEAD-04",
segment,
rawMatch,
0,
"Heading contains multiple sentences but does not end with terminal punctuation (. ! ?).",
"End the final sentence with a period, question mark, or exclamation mark — or shorten the heading to a single title phrase.",
fix,
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-PAIR-01: Unbalanced round parentheses
// Exclusions: code spans (already stripped), formulas (replaced with placeholder)
// ---------------------------------------------------------------------------
function countChar(text: string, ch: string): number {
let count = 0;
for (const c of text) {
if (c === ch) count++;
}
return count;
}
const pair01: TypographyRule = {
id: "TYPO-PAIR-01",
family: "PAIR",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
const open = countChar(text, "(");
const close = countChar(text, ")");
if (open === close) return [];
const col = open > close ? text.lastIndexOf("(") : text.lastIndexOf(")");
return [
finding(
"TYPO-PAIR-01",
segment,
open > close ? "(" : ")",
col,
`Unbalanced round parentheses — ${open} opening, ${close} closing.`,
"Add or remove a parenthesis to balance the pair.",
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-PAIR-02: Orphaned closing square bracket
// After removing balanced [...] pairs, any ] remaining is an orphan.
// Exclusions: markdown link syntax (the link-stripping step removes ](...) leaving
// [text — orphaned [ is expected and not a defect); code spans
// ---------------------------------------------------------------------------
const pair02: TypographyRule = {
id: "TYPO-PAIR-02",
family: "PAIR",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
// Find orphan ] by scanning the original string and skipping balanced [..] pairs.
let i = 0;
let bracketDepth = 0;
while (i < text.length) {
if (text[i] === "[") {
bracketDepth++;
} else if (text[i] === "]") {
if (bracketDepth === 0) {
return [
finding(
"TYPO-PAIR-02",
segment,
"]",
i,
"Orphaned closing square bracket — a ] without a matching [.",
"Add the opening [ or remove the orphaned ].",
),
];
}
bracketDepth--;
}
i++;
}
return [];
},
};
// ---------------------------------------------------------------------------
// TYPO-PAIR-03: Unbalanced curly braces
// Exclusions: formula expressions (replaced with placeholder), frontmatter
// template variables (replaced with CMS placeholder by extractor)
// ---------------------------------------------------------------------------
const pair03: TypographyRule = {
id: "TYPO-PAIR-03",
family: "PAIR",
tier: 2,
severity: "error",
check(segment) {
const text = segment.text;
const open = countChar(text, "{");
const close = countChar(text, "}");
if (open === close) return [];
const col = open > close ? text.lastIndexOf("{") : text.lastIndexOf("}");
return [
finding(
"TYPO-PAIR-03",
segment,
open > close ? "{" : "}",
col,
`Unbalanced curly braces — ${open} opening, ${close} closing.`,
"Add or remove a brace to balance the pair.",
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-MD-01: Unclosed inline code span (odd number of backticks)
// Exclusions: fenced code blocks (removed by extractor)
// ---------------------------------------------------------------------------
const md01: TypographyRule = {
id: "TYPO-MD-01",
family: "MD",
tier: 2,
severity: "error",
check(segment) {
if (segment.source !== "body") return [];
const text = segment.text;
const backtickCount = countChar(text, "`");
if (backtickCount % 2 === 0) return [];
const col = text.lastIndexOf("`");
return [
finding(
"TYPO-MD-01",
segment,
"`",
col,
`Unclosed inline code span — odd number of backticks (${backtickCount}).`,
"Add a closing backtick to match the opening.",
),
];
},
};
// ---------------------------------------------------------------------------
// TYPO-MD-02: Broken link target syntax (](… without preceding [)
// Only checks ]( without a preceding ] on the same segment.
// Unclosed [text at end of segment is NOT checked because the link-stripping
// step removes ](...) making [text indistinguishable from a complete link.
// ---------------------------------------------------------------------------
const MD02_PATTERN = /(?