/*
Tier 3 advisory typography rules (RFC-1071). Implements 5 rules across
3 families: UNICODE (2), LINK (2), SENT (1). All rules emit severity: "warning"
and do not block commits. Rules operate on TextSegment objects produced by the
text-surface extractor (RFC-1068). UNICODE rules check segment.text; LINK rules
check segment.raw (which preserves markdown link syntax stripped from segment.text);
SENT rules check segment.text via splitSentences.
- 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 Tier 1 or Tier 2 rules — that is rules-tier1.ts and rules-tier2-*.ts.
- RFC-1071: initial creation with 5 Tier 3 advisory rules.
*/
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 Tier3AdvisoryRuleId = `TYPO-${"UNICODE" | "LINK" | "SENT"}-${string}`;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const INLINE_CODE_SPAN = /`[^`]*`/g;
function stripInlineCodeSpans(raw: string): string {
return raw.replace(INLINE_CODE_SPAN, "");
}
function countWords(text: string): number {
const words = text.split(/\s+/).filter((w) => w.length > 0);
let count = 0;
for (const word of words) {
const stripped = word.replace(/\p{P}/gu, "");
if (stripped.length > 0) count++;
}
return count;
}
// ---------------------------------------------------------------------------
// TYPO-UNICODE-01: Zero-width characters
// ---------------------------------------------------------------------------
const ZERO_WIDTH = /[\u200B\u200C\u200D\uFEFF]/gu;
const UNICODE_01: TypographyRule = {
id: "TYPO-UNICODE-01",
family: "UNICODE",
tier: 3,
severity: "warning",
check(segment: TextSegment): TypographyFinding[] {
const findings: TypographyFinding[] = [];
let match: RegExpExecArray | null;
const regex = new RegExp(ZERO_WIDTH.source, "gu");
while ((match = regex.exec(segment.text)) !== null) {
const charName =
match[0] === "\u200B"
? "U+200B (zero-width space)"
: match[0] === "\u200C"
? "U+200C (zero-width non-joiner)"
: match[0] === "\u200D"
? "U+200D (zero-width joiner)"
: "U+FEFF (zero-width no-break space / BOM)";
findings.push(
finding(
"TYPO-UNICODE-01",
segment,
match[0],
match.index,
`Invisible character ${charName} in text.`,
"Remove the zero-width character from the source.",
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-UNICODE-02: Soft hyphen (U+00AD)
// ---------------------------------------------------------------------------
const SOFT_HYPHEN = /\u00AD/gu;
const UNICODE_02: TypographyRule = {
id: "TYPO-UNICODE-02",
family: "UNICODE",
tier: 3,
severity: "warning",
check(segment: TextSegment): TypographyFinding[] {
const findings: TypographyFinding[] = [];
let match: RegExpExecArray | null;
const regex = new RegExp(SOFT_HYPHEN.source, "gu");
while ((match = regex.exec(segment.text)) !== null) {
findings.push(
finding(
"TYPO-UNICODE-02",
segment,
match[0],
match.index,
"Soft hyphen (U+00AD) in text — invisible in most editors but affects rendering.",
"Remove the soft hyphen from the source.",
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-LINK-01: Link text is a bare URL
// ---------------------------------------------------------------------------
const BARE_URL_LINK = /\[https?:\/\/[^\]]+\]\(/giu;
const LINK_01: TypographyRule = {
id: "TYPO-LINK-01",
family: "LINK",
tier: 3,
severity: "warning",
check(segment: TextSegment): TypographyFinding[] {
if (segment.source !== "body") return [];
const findings: TypographyFinding[] = [];
const cleaned = stripInlineCodeSpans(segment.raw);
let match: RegExpExecArray | null;
const regex = new RegExp(BARE_URL_LINK.source, "giu");
while ((match = regex.exec(cleaned)) !== null) {
findings.push(
finding(
"TYPO-LINK-01",
segment,
match[0],
match.index,
"Link text is a bare URL — use descriptive link text instead.",
"Replace the URL in link text with a descriptive phrase.",
),
);
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-LINK-02: Link text is a generic phrase
// ---------------------------------------------------------------------------
const LINK_TEXT_EXTRACTOR = /\[([^\]]+)\]\([^)]*\)/gu;
const GENERIC_LINK_PHRASES = /^(click here|hier klicken|тут|here|hier)$/i;
const LINK_02: TypographyRule = {
id: "TYPO-LINK-02",
family: "LINK",
tier: 3,
severity: "warning",
check(segment: TextSegment): TypographyFinding[] {
if (segment.source !== "body") return [];
const findings: TypographyFinding[] = [];
const cleaned = stripInlineCodeSpans(segment.raw);
let match: RegExpExecArray | null;
const regex = new RegExp(LINK_TEXT_EXTRACTOR.source, "gu");
while ((match = regex.exec(cleaned)) !== null) {
const linkText = match[1];
if (GENERIC_LINK_PHRASES.test(linkText)) {
findings.push(
finding(
"TYPO-LINK-02",
segment,
match[0],
match.index,
`Link text "${linkText}" is generic — use descriptive link text instead.`,
`Replace "${linkText}" with a phrase that describes the link target.`,
),
);
}
}
return findings;
},
};
// ---------------------------------------------------------------------------
// TYPO-SENT-01: Sentence longer than 40 words
// ---------------------------------------------------------------------------
const SENTENCE_WORD_LIMIT = 40;
const SENT_01: TypographyRule = {
id: "TYPO-SENT-01",
family: "SENT",
tier: 3,
severity: "warning",
check(segment: TextSegment): TypographyFinding[] {
if (segment.source !== "body") return [];
const findings: TypographyFinding[] = [];
const sentences = splitSentences(segment.text, segment.locale);
let searchOffset = 0;
for (const sentence of sentences) {
const wordCount = countWords(sentence);
if (wordCount > SENTENCE_WORD_LIMIT) {
const sentenceStart = segment.text.indexOf(sentence, searchOffset);
const column = sentenceStart >= 0 ? sentenceStart : 0;
if (sentenceStart >= 0) searchOffset = sentenceStart + sentence.length;
findings.push(
finding(
"TYPO-SENT-01",
segment,
sentence,
column,
`Sentence has ${wordCount} words (limit: ${SENTENCE_WORD_LIMIT}) — consider splitting for readability.`,
"Split the long sentence into shorter ones.",
),
);
}
}
return findings;
},
};
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const TIER3_ADVISORY_RULES: readonly TypographyRule[] = [
UNICODE_01,
UNICODE_02,
LINK_01,
LINK_02,
SENT_01,
];