Skip to content

Commit 243db28

Browse files
masonwyatt23claude
andcommitted
feat: markdown linter autofix + LinterToast overlay + Settings → Linter rules
- mdlint.ts: add orphaned-heading, invalid-link-target, missing-alt-text rules with fix callbacks; export LINTER_DEFAULT_ENABLED_RULES constant - LinterToast.tsx: read-view overlay showing up to 5 fixable violations with inline Fix / Fix all buttons; applies corrections via documentStore.setContent(); violation history log (getLintHistory / clearLintHistory) for MCP + tests - LinterRulesSection.tsx: Settings → Linter rules panel to enable/disable rules - settingsStore.ts: add linterConfig (LintRuleConfig), toggleLinterRule, setLinterConfig; version bumped to 4 with migration - SettingsPanel.tsx + icons.tsx: wire LinterRulesSection with LinterIcon - bridge.ts: mcp://lint-document tool (lint_document with auto_fix option) - mdlint-autofix.test.ts: 48-case suite covering all new rules, chained fixes, multi-line fixes, history helpers, settingsStore linter config, edge cases Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d1b431d commit 243db28

8 files changed

Lines changed: 1135 additions & 1 deletion

File tree

src/components/LinterToast.tsx

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/**
2+
* LinterToast.tsx — Overlay that surfaces fixable linter violations in the
3+
* read view with inline Fix buttons.
4+
*
5+
* Appears automatically when `lintDocument()` detects violations on the active
6+
* document. Shows up to 5 summaries; each fixable violation carries a "Fix"
7+
* button that applies the correction via `documentStore.setContent()`, updates
8+
* the preview live, and appends the event to the linter's violation history.
9+
*
10+
* Rendered at the viewer level (not Shell level) so it stays visually anchored
11+
* to the reading pane.
12+
*
13+
* Usage:
14+
* <LinterToast content={doc} onContentChange={(c) => store.setContent(c)} />
15+
*/
16+
17+
import { useEffect, useMemo, useRef, useState } from "react";
18+
import {
19+
applyFix,
20+
BUILTIN_RULES,
21+
LINTER_DEFAULT_ENABLED_RULES,
22+
lintDocument,
23+
type LintViolation,
24+
} from "../lib/mdlint";
25+
import { useSettingsStore } from "../store/settingsStore";
26+
27+
// ── Violation history (module-level singleton, cleared on document open) ──────
28+
29+
export interface LintHistoryEntry {
30+
ruleId: string;
31+
message: string;
32+
fixedAt: number;
33+
docSnippet: string;
34+
}
35+
36+
const _violationHistory: LintHistoryEntry[] = [];
37+
38+
/** Read the full violation-fix history (for MCP and tests). */
39+
export function getLintHistory(): readonly LintHistoryEntry[] {
40+
return _violationHistory;
41+
}
42+
43+
/** Clear the history (called when a new document is opened). */
44+
export function clearLintHistory(): void {
45+
_violationHistory.length = 0;
46+
}
47+
48+
function appendHistory(v: LintViolation, docSnippet: string): void {
49+
_violationHistory.push({
50+
ruleId: v.ruleId,
51+
message: v.message,
52+
fixedAt: Date.now(),
53+
docSnippet,
54+
});
55+
}
56+
57+
// ── Icons ─────────────────────────────────────────────────────────────────────
58+
59+
function WrenchIcon() {
60+
return (
61+
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" width="14" height="14">
62+
<path
63+
d="M10.5 2.5a3.5 3.5 0 0 1 .5 5.5L5 14a1.5 1.5 0 0 1-2-2l6-6.5a3.5 3.5 0 0 1-1.5-3"
64+
stroke="currentColor"
65+
strokeWidth="1.4"
66+
strokeLinecap="round"
67+
strokeLinejoin="round"
68+
/>
69+
<circle cx="11" cy="4" r="1" fill="currentColor" />
70+
</svg>
71+
);
72+
}
73+
74+
function ChevronIcon({ open }: { open: boolean }) {
75+
return (
76+
<svg
77+
viewBox="0 0 12 12"
78+
fill="none"
79+
aria-hidden="true"
80+
width="12"
81+
height="12"
82+
style={{ transform: open ? "rotate(180deg)" : "rotate(0deg)", transition: "transform 0.15s" }}
83+
>
84+
<path
85+
d="M2.5 4.5l3.5 3 3.5-3"
86+
stroke="currentColor"
87+
strokeWidth="1.4"
88+
strokeLinecap="round"
89+
strokeLinejoin="round"
90+
/>
91+
</svg>
92+
);
93+
}
94+
95+
function CloseSmallIcon() {
96+
return (
97+
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true" width="12" height="12">
98+
<path
99+
d="M3 3l6 6M9 3l-6 6"
100+
stroke="currentColor"
101+
strokeWidth="1.4"
102+
strokeLinecap="round"
103+
/>
104+
</svg>
105+
);
106+
}
107+
108+
// ── Severity badge colour ─────────────────────────────────────────────────────
109+
110+
function severityColor(severity: LintViolation["severity"]): string {
111+
if (severity === "error") return "var(--color-error, #d33)";
112+
if (severity === "warning") return "var(--color-warning, #c80)";
113+
return "var(--color-fg-muted, #888)";
114+
}
115+
116+
// ── Component ─────────────────────────────────────────────────────────────────
117+
118+
export interface LinterToastProps {
119+
/** Current document content to lint. */
120+
content: string;
121+
/** Called when a fix is applied — passes the corrected content. */
122+
onContentChange: (content: string) => void;
123+
/** Maximum violations to show (default 5). */
124+
maxVisible?: number;
125+
}
126+
127+
export function LinterToast({
128+
content,
129+
onContentChange,
130+
maxVisible = 5,
131+
}: LinterToastProps) {
132+
const linterConfig = useSettingsStore((s) => s.linterConfig);
133+
const [dismissed, setDismissed] = useState(false);
134+
const [expanded, setExpanded] = useState(true);
135+
// Track the content snapshot at the time we last computed violations so we
136+
// can re-run the linter only when content actually changes.
137+
const lastContent = useRef<string | null>(null);
138+
const [violations, setViolations] = useState<LintViolation[]>([]);
139+
140+
// Reset dismissed state whenever content changes substantially (new document).
141+
const prevContent = useRef(content);
142+
useEffect(() => {
143+
if (prevContent.current !== content) {
144+
// Only reset the dismissed flag when the document identity changes
145+
// (more than minor edits). We use a simple length threshold here.
146+
const prevLen = prevContent.current.length;
147+
const nextLen = content.length;
148+
if (Math.abs(prevLen - nextLen) > 100) {
149+
setDismissed(false);
150+
setExpanded(true);
151+
clearLintHistory();
152+
}
153+
prevContent.current = content;
154+
}
155+
}, [content]);
156+
157+
// Re-run the linter on content + config changes (debounced 300 ms to avoid
158+
// running on every keystroke in split-view mode).
159+
useEffect(() => {
160+
const handle = setTimeout(() => {
161+
if (lastContent.current === content) return;
162+
lastContent.current = content;
163+
const result = lintDocument(content, {
164+
rules: BUILTIN_RULES,
165+
disabledRules: linterConfig.disabledRules,
166+
});
167+
// Only surface violations from the four default-enabled rules in the toast
168+
// (the others appear as editor squiggles).
169+
const toastRules = new Set(
170+
LINTER_DEFAULT_ENABLED_RULES.filter(
171+
(id) => !linterConfig.disabledRules.includes(id),
172+
),
173+
);
174+
setViolations(result.filter((v) => toastRules.has(v.ruleId)));
175+
}, 300);
176+
return () => clearTimeout(handle);
177+
}, [content, linterConfig]);
178+
179+
// Memoize the slice to avoid re-slicing on every render.
180+
const visible = useMemo(
181+
() => violations.slice(0, maxVisible),
182+
[violations, maxVisible],
183+
);
184+
const overflow = violations.length - visible.length;
185+
186+
if (dismissed || violations.length === 0) return null;
187+
188+
function handleFix(v: LintViolation) {
189+
if (!v.fix) return;
190+
const snippet = content.slice(
191+
Math.max(0, (v.range?.from.offset ?? 0) - 20),
192+
Math.min(content.length, (v.range?.to.offset ?? 0) + 20),
193+
);
194+
const fixed = applyFix(content, v);
195+
appendHistory(v, snippet);
196+
onContentChange(fixed);
197+
}
198+
199+
function handleFixAll() {
200+
let current = content;
201+
for (const v of violations) {
202+
if (!v.fix) continue;
203+
const snippet = current.slice(
204+
Math.max(0, (v.range?.from.offset ?? 0) - 20),
205+
Math.min(current.length, (v.range?.to.offset ?? 0) + 20),
206+
);
207+
current = applyFix(current, v);
208+
appendHistory(v, snippet);
209+
}
210+
onContentChange(current);
211+
}
212+
213+
const fixableCount = violations.filter((v) => v.fix !== null).length;
214+
215+
return (
216+
<div
217+
className="linter-toast"
218+
role="region"
219+
aria-label="Linter suggestions"
220+
aria-live="polite"
221+
>
222+
{/* Header row */}
223+
<div className="linter-toast__header">
224+
<span className="linter-toast__icon">
225+
<WrenchIcon />
226+
</span>
227+
<span className="linter-toast__title">
228+
{violations.length} linter {violations.length === 1 ? "suggestion" : "suggestions"}
229+
</span>
230+
{fixableCount > 1 && (
231+
<button
232+
type="button"
233+
className="linter-toast__fix-all"
234+
onClick={handleFixAll}
235+
title={`Fix all ${fixableCount} auto-fixable violations`}
236+
>
237+
Fix all
238+
</button>
239+
)}
240+
<button
241+
type="button"
242+
className="linter-toast__toggle"
243+
aria-expanded={expanded}
244+
aria-label={expanded ? "Collapse linter suggestions" : "Expand linter suggestions"}
245+
onClick={() => setExpanded((e) => !e)}
246+
>
247+
<ChevronIcon open={expanded} />
248+
</button>
249+
<button
250+
type="button"
251+
className="linter-toast__dismiss"
252+
aria-label="Dismiss linter suggestions"
253+
onClick={() => setDismissed(true)}
254+
>
255+
<CloseSmallIcon />
256+
</button>
257+
</div>
258+
259+
{/* Violation list */}
260+
{expanded && (
261+
<ul className="linter-toast__list" role="list">
262+
{visible.map((v, i) => (
263+
<li key={`${v.ruleId}-${i}`} className="linter-toast__item">
264+
<span
265+
className="linter-toast__severity"
266+
style={{ color: severityColor(v.severity) }}
267+
aria-label={v.severity}
268+
/>
269+
<span className="linter-toast__message">
270+
{v.range
271+
? `Line ${v.range.from.line}: ${v.message}`
272+
: v.message}
273+
</span>
274+
{v.fix && (
275+
<button
276+
type="button"
277+
className="linter-toast__fix-btn"
278+
onClick={() => handleFix(v)}
279+
>
280+
Fix
281+
</button>
282+
)}
283+
</li>
284+
))}
285+
{overflow > 0 && (
286+
<li className="linter-toast__overflow">
287+
+{overflow} more — open Settings → Linter rules to manage
288+
</li>
289+
)}
290+
</ul>
291+
)}
292+
</div>
293+
);
294+
}

src/components/settings/SettingsPanel.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import {
88
DatabaseIcon,
99
ExportIcon,
1010
LinkIcon,
11+
LinterIcon,
1112
MemoryIcon,
1213
TerminalIcon,
1314
VaultIcon,
1415
} from "./icons";
1516
import { AiProviderSection } from "./sections/AiProviderSection";
17+
import { LinterRulesSection } from "./sections/LinterRulesSection";
1618
import { ExportTemplateSection } from "./sections/ExportTemplateSection";
1719
import { SectionHeader } from "./SectionHeader";
1820
import {
@@ -186,6 +188,14 @@ export function SettingsPanel() {
186188
<SectionHeader icon={<ExportIcon />} title="Export templates" />
187189
<ExportTemplateSection />
188190
</section>
191+
192+
<div className="settings-divider" />
193+
194+
{/* 10 · Linter rules */}
195+
<section className="settings-section">
196+
<SectionHeader icon={<LinterIcon />} title="Linter rules" />
197+
<LinterRulesSection />
198+
</section>
189199
</div>
190200

191201
{/* ── Footer ──────────────────────────────────────────────────────── */}

src/components/settings/icons.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,22 @@ export function ExportIcon() {
217217
);
218218
}
219219

220+
/** Wrench glyph for the Linter rules section header. */
221+
export function LinterIcon() {
222+
return (
223+
<svg viewBox="0 0 20 20" fill="none" aria-hidden="true">
224+
<path
225+
d="M13 3.5a4 4 0 0 1 .6 6.3L7 16.5A2 2 0 1 1 4.5 14l6.7-7a4 4 0 0 1-1.7-3.5"
226+
stroke="currentColor"
227+
strokeWidth="1.4"
228+
strokeLinecap="round"
229+
strokeLinejoin="round"
230+
/>
231+
<circle cx="13.5" cy="5.5" r="1.2" fill="currentColor" />
232+
</svg>
233+
);
234+
}
235+
220236
/** Gear/sun glyph for the Appearance section header. */
221237
export function AppearanceIcon() {
222238
return (

0 commit comments

Comments
 (0)