mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
feat(vscode-ext): add health status bar and validation checks
Add a persistent health indicator to the extension: a status bar item that shows pass/warn/error at a glance, plus a health summary node at the top of the Pre-commit tree view. Fast in-process TypeScript checks run on file save, editor switch, and startup (<100ms per file). Checks: duplicate labels, unclosed div fences, missing figure alt-text, and unresolved in-file cross-references. - Add src/validation/qmdChecks.ts with four pure check functions - Add src/validation/healthManager.ts with central status tracker - Wire HealthManager into extension.ts with status bar and event hooks - Add expandable health summary node to PrecommitTreeProvider - Register showHealthDetails command in package.json
This commit is contained in:
@@ -116,7 +116,8 @@
|
||||
{ "command": "mlsysbook.openLastFailureDetails", "title": "MLSysBook: Open Last Failure Details" },
|
||||
{ "command": "mlsysbook.setChapterOrderSource", "title": "MLSysBook: Set Chapter Order Source" },
|
||||
{ "command": "mlsysbook.setQmdVisualPreset", "title": "MLSysBook: Set QMD Visual Preset" },
|
||||
{ "command": "mlsysbook.openSettings", "title": "MLSysBook: Open Extension Settings" }
|
||||
{ "command": "mlsysbook.openSettings", "title": "MLSysBook: Open Extension Settings" },
|
||||
{ "command": "mlsysbook.showHealthDetails", "title": "MLSysBook: Show Health Details" }
|
||||
],
|
||||
"configuration": {
|
||||
"title": "MLSysBook Workbench",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
runInVisibleTerminal,
|
||||
} from './utils/terminal';
|
||||
import { ChapterOrderSource } from './types';
|
||||
import { HealthManager, HealthStatus } from './validation/healthManager';
|
||||
|
||||
export function activate(context: vscode.ExtensionContext): void {
|
||||
const root = getRepoRoot();
|
||||
@@ -52,6 +53,22 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
const qmdAutoFoldManager = new QmdAutoFoldManager();
|
||||
const chunkHighlighter = new QmdChunkHighlighter();
|
||||
|
||||
// Health manager + status bar
|
||||
const healthManager = new HealthManager();
|
||||
precommitProvider.setHealthManager(healthManager);
|
||||
|
||||
const statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50);
|
||||
statusBarItem.command = 'mlsysbook.showHealthDetails';
|
||||
updateStatusBar(statusBarItem, healthManager);
|
||||
statusBarItem.show();
|
||||
|
||||
healthManager.onDidUpdateHealth(() => {
|
||||
updateStatusBar(statusBarItem, healthManager);
|
||||
precommitProvider.refresh();
|
||||
});
|
||||
|
||||
context.subscriptions.push(healthManager, statusBarItem);
|
||||
|
||||
// Inline Python value resolution (hover, ghost text, CodeLens)
|
||||
let pythonResolver: QmdPythonValueResolver | undefined;
|
||||
let pythonHoverProvider: QmdPythonHoverProvider | undefined;
|
||||
@@ -194,6 +211,9 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
await vscode.commands.executeCommand('workbench.action.openSettings', '@ext:mlsysbook.mlsysbook-workbench');
|
||||
}),
|
||||
vscode.commands.registerCommand('mlsysbook.renameLabelReferences', () => void renameLabelAcrossWorkspace()),
|
||||
vscode.commands.registerCommand('mlsysbook.showHealthDetails', () => {
|
||||
void vscode.commands.executeCommand('mlsysbook.precommit.focus');
|
||||
}),
|
||||
|
||||
// Section ID management (uses binder CLI)
|
||||
vscode.commands.registerCommand('mlsysbook.addSectionIds', () => {
|
||||
@@ -234,8 +254,20 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
registerContextMenuCommands(context);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.onDidChangeActiveTextEditor(editor => navigatorProvider.refreshFromEditor(editor)),
|
||||
vscode.workspace.onDidSaveTextDocument(document => navigatorProvider.refreshFromDocument(document)),
|
||||
vscode.window.onDidChangeActiveTextEditor(editor => {
|
||||
navigatorProvider.refreshFromEditor(editor);
|
||||
// Run health checks when switching to a QMD file
|
||||
if (editor?.document.uri.fsPath.endsWith('.qmd')) {
|
||||
healthManager.runFastChecks(editor.document);
|
||||
}
|
||||
}),
|
||||
vscode.workspace.onDidSaveTextDocument(document => {
|
||||
navigatorProvider.refreshFromDocument(document);
|
||||
// Run health checks on save for QMD files
|
||||
if (document.uri.fsPath.endsWith('.qmd')) {
|
||||
healthManager.runFastChecks(document);
|
||||
}
|
||||
}),
|
||||
vscode.workspace.onDidChangeConfiguration(event => {
|
||||
if (event.affectsConfiguration('mlsysbook.chapterOrderSource')) {
|
||||
buildProvider.refresh();
|
||||
@@ -255,6 +287,48 @@ export function activate(context: vscode.ExtensionContext): void {
|
||||
void navigatorTreeView.reveal(sectionItem, { focus: false, select: false, expand: true });
|
||||
}),
|
||||
);
|
||||
|
||||
// Run health checks on the currently active file at startup
|
||||
if (vscode.window.activeTextEditor?.document.uri.fsPath.endsWith('.qmd')) {
|
||||
healthManager.runFastChecks(vscode.window.activeTextEditor.document);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status bar helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function updateStatusBar(item: vscode.StatusBarItem, manager: HealthManager): void {
|
||||
item.text = formatStatusBarText(manager.status, manager.getSummaryText());
|
||||
item.tooltip = manager.getTooltip();
|
||||
|
||||
switch (manager.status) {
|
||||
case 'ok':
|
||||
item.backgroundColor = undefined;
|
||||
item.color = undefined;
|
||||
break;
|
||||
case 'warn':
|
||||
item.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
|
||||
item.color = undefined;
|
||||
break;
|
||||
case 'error':
|
||||
item.backgroundColor = new vscode.ThemeColor('statusBarItem.errorBackground');
|
||||
item.color = undefined;
|
||||
break;
|
||||
default:
|
||||
item.backgroundColor = undefined;
|
||||
item.color = undefined;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function formatStatusBarText(status: HealthStatus, summary: string): string {
|
||||
switch (status) {
|
||||
case 'ok': return `$(pass) ${summary}`;
|
||||
case 'warn': return `$(warning) ${summary}`;
|
||||
case 'error': return `$(error) ${summary}`;
|
||||
default: return `$(circle-outline) ${summary}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function deactivate(): void {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { PRECOMMIT_CHECK_HOOKS, PRECOMMIT_FIXER_HOOKS, CHECK_ACTIONS } from '../constants';
|
||||
import { ActionTreeItem } from '../models/treeItems';
|
||||
import { HealthManager } from '../validation/healthManager';
|
||||
|
||||
type TreeNode = ActionTreeItem | SeparatorItem;
|
||||
type TreeNode = ActionTreeItem | SeparatorItem | HealthSummaryItem | HealthIssueItem;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tree item helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class SeparatorItem extends vscode.TreeItem {
|
||||
constructor(label: string) {
|
||||
@@ -12,15 +17,132 @@ class SeparatorItem extends vscode.TreeItem {
|
||||
}
|
||||
}
|
||||
|
||||
class HealthSummaryItem extends vscode.TreeItem {
|
||||
constructor(private readonly healthManager: HealthManager) {
|
||||
const status = healthManager.status;
|
||||
const hasIssues = status === 'error' || status === 'warn';
|
||||
super(
|
||||
healthManager.getSummaryText().replace('MLSysBook: ', ''),
|
||||
hasIssues
|
||||
? vscode.TreeItemCollapsibleState.Expanded
|
||||
: vscode.TreeItemCollapsibleState.None,
|
||||
);
|
||||
|
||||
this.contextValue = 'health-summary';
|
||||
this.tooltip = healthManager.getTooltip();
|
||||
|
||||
switch (status) {
|
||||
case 'ok':
|
||||
this.iconPath = new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed'));
|
||||
this.description = '';
|
||||
break;
|
||||
case 'warn':
|
||||
this.iconPath = new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground'));
|
||||
this.description = '';
|
||||
break;
|
||||
case 'error':
|
||||
this.iconPath = new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground'));
|
||||
this.description = '';
|
||||
break;
|
||||
case 'pending':
|
||||
default:
|
||||
this.iconPath = new vscode.ThemeIcon('circle-outline');
|
||||
this.description = 'no files checked';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
getChildren(): HealthIssueItem[] {
|
||||
const allResults = this.healthManager.getAllResults();
|
||||
const items: HealthIssueItem[] = [];
|
||||
|
||||
for (const fh of allResults) {
|
||||
const shortPath = fh.uri.replace(/^file:\/\//, '').split('/').slice(-2).join('/');
|
||||
for (const r of fh.results) {
|
||||
items.push(new HealthIssueItem(r.message, r.severity, shortPath, r.line, fh.uri));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
class HealthIssueItem extends vscode.TreeItem {
|
||||
constructor(
|
||||
message: string,
|
||||
severity: 'error' | 'warning' | 'info',
|
||||
filePath: string,
|
||||
line: number,
|
||||
fileUri: string,
|
||||
) {
|
||||
super(message, vscode.TreeItemCollapsibleState.None);
|
||||
this.contextValue = 'health-issue';
|
||||
this.description = `${filePath}:${line + 1}`;
|
||||
this.tooltip = `${message}\n${filePath} line ${line + 1}`;
|
||||
|
||||
// Click to open file at the issue line
|
||||
const uri = vscode.Uri.parse(fileUri);
|
||||
this.command = {
|
||||
command: 'mlsysbook.openNavigatorLocation',
|
||||
title: 'Go to Issue',
|
||||
arguments: [uri, line],
|
||||
};
|
||||
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
this.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.errorForeground'));
|
||||
break;
|
||||
case 'warning':
|
||||
this.iconPath = new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('list.warningForeground'));
|
||||
break;
|
||||
case 'info':
|
||||
this.iconPath = new vscode.ThemeIcon('info', new vscode.ThemeColor('list.deemphasizedForeground'));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PrecommitTreeProvider implements vscode.TreeDataProvider<TreeNode> {
|
||||
private _onDidChangeTreeData = new vscode.EventEmitter<TreeNode | undefined>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
private _healthManager: HealthManager | undefined;
|
||||
private _healthSummary: HealthSummaryItem | undefined;
|
||||
|
||||
/** Wire up the health manager (called from extension.ts). */
|
||||
setHealthManager(manager: HealthManager): void {
|
||||
this._healthManager = manager;
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this._healthSummary = undefined; // force rebuild
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: TreeNode): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
getChildren(): TreeNode[] {
|
||||
getChildren(element?: TreeNode): TreeNode[] {
|
||||
// If expanding the health summary node, return its children
|
||||
if (element instanceof HealthSummaryItem) {
|
||||
return element.getChildren();
|
||||
}
|
||||
|
||||
// Top-level children
|
||||
const items: TreeNode[] = [];
|
||||
|
||||
// Health summary at the very top
|
||||
if (this._healthManager) {
|
||||
this._healthSummary = new HealthSummaryItem(this._healthManager);
|
||||
items.push(this._healthSummary);
|
||||
items.push(new SeparatorItem(''));
|
||||
}
|
||||
|
||||
const runAll = new ActionTreeItem(
|
||||
'Run ALL Hooks',
|
||||
'mlsysbook.precommitRunAll',
|
||||
@@ -53,7 +175,7 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
new ActionTreeItem(action.label, 'mlsysbook.validateRunAction', [action.command], action.icon)
|
||||
);
|
||||
|
||||
return [
|
||||
items.push(
|
||||
runAll,
|
||||
new SeparatorItem('--- Pre-commit Checks ---'),
|
||||
...checkItems,
|
||||
@@ -63,6 +185,8 @@ export class PrecommitTreeProvider implements vscode.TreeDataProvider<TreeNode>
|
||||
...fixerItems,
|
||||
new SeparatorItem('--- Binder Check (Fast, Focused) ---'),
|
||||
...binderCheckItems,
|
||||
];
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
}
|
||||
|
||||
179
book/vscode-ext/src/validation/healthManager.ts
Normal file
179
book/vscode-ext/src/validation/healthManager.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Central health-status tracker for the MLSysBook extension.
|
||||
*
|
||||
* Maintains per-file check results, aggregates an overall status,
|
||||
* and emits events so the status bar and tree view stay in sync.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { CheckResult, runAllChecks } from './qmdChecks';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type HealthStatus = 'ok' | 'warn' | 'error' | 'pending';
|
||||
|
||||
export interface FileHealth {
|
||||
uri: string;
|
||||
results: CheckResult[];
|
||||
checkedAt: number; // Date.now()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HealthManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class HealthManager implements vscode.Disposable {
|
||||
private readonly _results = new Map<string, FileHealth>();
|
||||
|
||||
private readonly _onDidUpdateHealth = new vscode.EventEmitter<void>();
|
||||
/** Fires whenever the aggregated health status changes. */
|
||||
readonly onDidUpdateHealth = this._onDidUpdateHealth.event;
|
||||
|
||||
private _status: HealthStatus = 'pending';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Public API
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Run all fast in-process checks on a single document. */
|
||||
runFastChecks(document: vscode.TextDocument): CheckResult[] {
|
||||
const text = document.getText();
|
||||
const results = runAllChecks(text);
|
||||
|
||||
this._results.set(document.uri.toString(), {
|
||||
uri: document.uri.toString(),
|
||||
results,
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
|
||||
this._recomputeStatus();
|
||||
this._onDidUpdateHealth.fire();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Remove results for a file (e.g. when it's closed). */
|
||||
clearFile(uri: vscode.Uri): void {
|
||||
if (this._results.delete(uri.toString())) {
|
||||
this._recomputeStatus();
|
||||
this._onDidUpdateHealth.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/** Current aggregated status across all tracked files. */
|
||||
get status(): HealthStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** Total number of issues across all tracked files. */
|
||||
get totalIssues(): number {
|
||||
let count = 0;
|
||||
for (const fh of this._results.values()) {
|
||||
count += fh.results.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Number of errors (not warnings/info). */
|
||||
get errorCount(): number {
|
||||
let count = 0;
|
||||
for (const fh of this._results.values()) {
|
||||
for (const r of fh.results) {
|
||||
if (r.severity === 'error') { count++; }
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Number of warnings. */
|
||||
get warningCount(): number {
|
||||
let count = 0;
|
||||
for (const fh of this._results.values()) {
|
||||
for (const r of fh.results) {
|
||||
if (r.severity === 'warning') { count++; }
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Number of info-level issues. */
|
||||
get infoCount(): number {
|
||||
let count = 0;
|
||||
for (const fh of this._results.values()) {
|
||||
for (const r of fh.results) {
|
||||
if (r.severity === 'info') { count++; }
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Human-readable one-line summary for the status bar. */
|
||||
getSummaryText(): string {
|
||||
if (this._status === 'pending') { return 'MLSysBook'; }
|
||||
if (this._status === 'ok') { return 'MLSysBook: All Clear'; }
|
||||
|
||||
const parts: string[] = [];
|
||||
const errors = this.errorCount;
|
||||
const warnings = this.warningCount;
|
||||
if (errors > 0) { parts.push(`${errors} error${errors !== 1 ? 's' : ''}`); }
|
||||
if (warnings > 0) { parts.push(`${warnings} warning${warnings !== 1 ? 's' : ''}`); }
|
||||
return `MLSysBook: ${parts.join(', ')}`;
|
||||
}
|
||||
|
||||
/** Tooltip with more detail for the status bar hover. */
|
||||
getTooltip(): string {
|
||||
if (this._status === 'pending') { return 'MLSysBook — no files checked yet'; }
|
||||
if (this._status === 'ok') {
|
||||
const fileCount = this._results.size;
|
||||
return `MLSysBook — ${fileCount} file${fileCount !== 1 ? 's' : ''} checked, no issues`;
|
||||
}
|
||||
|
||||
const lines: string[] = [`MLSysBook Health: ${this.totalIssues} issue(s)`];
|
||||
for (const fh of this._results.values()) {
|
||||
if (fh.results.length === 0) { continue; }
|
||||
const shortPath = fh.uri.replace(/^file:\/\//, '').split('/').slice(-2).join('/');
|
||||
lines.push(` ${shortPath}: ${fh.results.length} issue(s)`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** All results grouped by file, for the tree view. */
|
||||
getAllResults(): FileHealth[] {
|
||||
return Array.from(this._results.values()).filter(fh => fh.results.length > 0);
|
||||
}
|
||||
|
||||
/** Results for a specific file. */
|
||||
getFileResults(uri: string): CheckResult[] {
|
||||
return this._results.get(uri)?.results ?? [];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private _recomputeStatus(): void {
|
||||
if (this._results.size === 0) {
|
||||
this._status = 'pending';
|
||||
return;
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
let hasWarning = false;
|
||||
|
||||
for (const fh of this._results.values()) {
|
||||
for (const r of fh.results) {
|
||||
if (r.severity === 'error') { hasError = true; }
|
||||
if (r.severity === 'warning') { hasWarning = true; }
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) { this._status = 'error'; }
|
||||
else if (hasWarning) { this._status = 'warn'; }
|
||||
else { this._status = 'ok'; }
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._onDidUpdateHealth.dispose();
|
||||
}
|
||||
}
|
||||
281
book/vscode-ext/src/validation/qmdChecks.ts
Normal file
281
book/vscode-ext/src/validation/qmdChecks.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* Pure QMD validation functions — no VS Code API dependency.
|
||||
*
|
||||
* Each function accepts the full text of a `.qmd` file and returns
|
||||
* an array of {@link CheckResult} objects describing any issues found.
|
||||
* All checks are designed to run in <100 ms per file.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CheckResult {
|
||||
/** 0-based line number where the issue was found. */
|
||||
line: number;
|
||||
/** Human-readable description of the issue. */
|
||||
message: string;
|
||||
/** Severity level for UI display. */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
/** Machine-readable check identifier (e.g. 'duplicate-label'). */
|
||||
checkId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** All Quarto label prefixes we care about. */
|
||||
const LABEL_PREFIXES = ['fig', 'tbl', 'lst', 'sec', 'eq', 'thm', 'lem', 'cor', 'def', 'exm', 'exr'];
|
||||
|
||||
/** Matches label definitions: {#fig-xxx}, {#tbl-xxx}, {#sec-xxx}, etc. */
|
||||
const LABEL_DEF_RE = new RegExp(
|
||||
`\\{#((?:${LABEL_PREFIXES.join('|')})-[\\w-]+)\\}`,
|
||||
'g',
|
||||
);
|
||||
|
||||
/** Matches #| label: fig-xxx in Python code blocks. */
|
||||
const CODE_LABEL_RE = /^#\|\s*label:\s*((?:fig|tbl|lst)-[\w-]+)/;
|
||||
|
||||
/** Matches @ref cross-references in prose. */
|
||||
const REF_RE = new RegExp(
|
||||
`@((?:${LABEL_PREFIXES.join('|')})-[\\w-]+)`,
|
||||
'g',
|
||||
);
|
||||
|
||||
/** Matches div fence openers: ::: or :::: (with optional class). */
|
||||
const DIV_OPEN_RE = /^(:{3,})\s*\{/;
|
||||
|
||||
/** Matches div fence closers: ::: or :::: on a line by themselves. */
|
||||
const DIV_CLOSE_RE = /^(:{3,})\s*$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Detect duplicate label definitions within a single file.
|
||||
*
|
||||
* Scans for both attribute-style labels (`{#fig-xxx}`) and
|
||||
* code-block labels (`#| label: fig-xxx`).
|
||||
*/
|
||||
export function checkDuplicateLabels(text: string): CheckResult[] {
|
||||
const results: CheckResult[] = [];
|
||||
const seen = new Map<string, number>(); // label → first line (0-based)
|
||||
const lines = text.split('\n');
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Attribute-style labels
|
||||
LABEL_DEF_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = LABEL_DEF_RE.exec(line)) !== null) {
|
||||
const label = m[1];
|
||||
if (seen.has(label)) {
|
||||
results.push({
|
||||
line: i,
|
||||
message: `Duplicate label '#${label}' (first defined on line ${seen.get(label)! + 1})`,
|
||||
severity: 'error',
|
||||
checkId: 'duplicate-label',
|
||||
});
|
||||
} else {
|
||||
seen.set(label, i);
|
||||
}
|
||||
}
|
||||
|
||||
// Code-block labels
|
||||
const cm = CODE_LABEL_RE.exec(line);
|
||||
if (cm) {
|
||||
const label = cm[1];
|
||||
if (seen.has(label)) {
|
||||
results.push({
|
||||
line: i,
|
||||
message: `Duplicate label '${label}' (first defined on line ${seen.get(label)! + 1})`,
|
||||
severity: 'error',
|
||||
checkId: 'duplicate-label',
|
||||
});
|
||||
} else {
|
||||
seen.set(label, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect unclosed or mismatched div fences (`::: { }` / `::::`).
|
||||
*
|
||||
* Tracks a stack of openers and reports unmatched fences.
|
||||
*/
|
||||
export function checkUnclosedDivs(text: string): CheckResult[] {
|
||||
const results: CheckResult[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
// Stack entries: [colon-count, line-number]
|
||||
const stack: Array<[number, number]> = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
const openMatch = DIV_OPEN_RE.exec(line);
|
||||
if (openMatch) {
|
||||
stack.push([openMatch[1].length, i]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeMatch = DIV_CLOSE_RE.exec(line);
|
||||
if (closeMatch) {
|
||||
const closeLen = closeMatch[1].length;
|
||||
if (stack.length === 0) {
|
||||
results.push({
|
||||
line: i,
|
||||
message: `Closing div fence '${''.padStart(closeLen, ':')}' has no matching opener`,
|
||||
severity: 'warning',
|
||||
checkId: 'unclosed-div',
|
||||
});
|
||||
} else {
|
||||
// Pop the most recent opener — Quarto allows ::: to close ::::
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Any remaining openers are unclosed
|
||||
for (const [colons, lineNum] of stack) {
|
||||
results.push({
|
||||
line: lineNum,
|
||||
message: `Div fence '${''.padStart(colons, ':')}' opened here is never closed`,
|
||||
severity: 'warning',
|
||||
checkId: 'unclosed-div',
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect figure code blocks missing `fig-alt` metadata.
|
||||
*
|
||||
* Scans `{python}` blocks that have a `#| label: fig-*` directive
|
||||
* and warns if no `#| fig-alt:` directive is present.
|
||||
*/
|
||||
export function checkMissingAltText(text: string): CheckResult[] {
|
||||
const results: CheckResult[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
let inPythonBlock = false;
|
||||
let blockStartLine = -1;
|
||||
let hasFigLabel = false;
|
||||
let hasAltText = false;
|
||||
let figLabel = '';
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (/^```\{python\}/.test(line)) {
|
||||
inPythonBlock = true;
|
||||
blockStartLine = i;
|
||||
hasFigLabel = false;
|
||||
hasAltText = false;
|
||||
figLabel = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inPythonBlock && /^```\s*$/.test(line)) {
|
||||
// End of block — check
|
||||
if (hasFigLabel && !hasAltText) {
|
||||
results.push({
|
||||
line: blockStartLine,
|
||||
message: `Figure '${figLabel}' is missing '#| fig-alt:' accessibility text`,
|
||||
severity: 'warning',
|
||||
checkId: 'missing-alt-text',
|
||||
});
|
||||
}
|
||||
inPythonBlock = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inPythonBlock) {
|
||||
const labelMatch = /^#\|\s*label:\s*(fig-[\w-]+)/.exec(line);
|
||||
if (labelMatch) {
|
||||
hasFigLabel = true;
|
||||
figLabel = labelMatch[1];
|
||||
}
|
||||
if (/^#\|\s*fig-alt:/.test(line)) {
|
||||
hasAltText = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect cross-references (`@fig-xxx`, `@tbl-xxx`, etc.) that point to
|
||||
* labels not defined anywhere in the same file.
|
||||
*
|
||||
* Note: cross-chapter references are valid in Quarto and will produce
|
||||
* false positives here, so these are reported as `info` severity.
|
||||
*/
|
||||
export function checkBrokenInFileRefs(text: string): CheckResult[] {
|
||||
const results: CheckResult[] = [];
|
||||
const lines = text.split('\n');
|
||||
|
||||
// First pass: collect all defined labels
|
||||
const definedLabels = new Set<string>();
|
||||
|
||||
for (const line of lines) {
|
||||
LABEL_DEF_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = LABEL_DEF_RE.exec(line)) !== null) {
|
||||
definedLabels.add(m[1]);
|
||||
}
|
||||
|
||||
const cm = CODE_LABEL_RE.exec(line);
|
||||
if (cm) {
|
||||
definedLabels.add(cm[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: find references not in the defined set
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip lines inside code blocks (rough heuristic: starts with #|)
|
||||
if (/^\s*#\|/.test(line)) { continue; }
|
||||
// Skip YAML frontmatter-like lines
|
||||
if (/^\s*-\s/.test(line) && i < 20) { continue; }
|
||||
|
||||
REF_RE.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = REF_RE.exec(line)) !== null) {
|
||||
const ref = m[1];
|
||||
if (!definedLabels.has(ref)) {
|
||||
// Only flag sec- refs as info (almost always cross-chapter)
|
||||
const severity = ref.startsWith('sec-') ? 'info' as const : 'info' as const;
|
||||
results.push({
|
||||
line: i,
|
||||
message: `Reference '@${ref}' has no matching label in this file (may be cross-chapter)`,
|
||||
severity,
|
||||
checkId: 'unresolved-ref',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all fast checks on a QMD file's text.
|
||||
*/
|
||||
export function runAllChecks(text: string): CheckResult[] {
|
||||
return [
|
||||
...checkDuplicateLabels(text),
|
||||
...checkUnclosedDivs(text),
|
||||
...checkMissingAltText(text),
|
||||
...checkBrokenInFileRefs(text),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user