diff --git a/client/codemirror/lint.ts b/client/codemirror/lint.ts index b994fdb1..fa2698f1 100644 --- a/client/codemirror/lint.ts +++ b/client/codemirror/lint.ts @@ -1,14 +1,21 @@ import { type Diagnostic, linter } from "@codemirror/lint"; +import { StateEffect } from "@codemirror/state"; import type { Client } from "../client.ts"; import { parse } from "../markdown_parser/parse_tree.ts"; import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts"; -import type { LintEvent } from "@silverbulletmd/silverbullet/type/client"; +import type { + LintDiagnostic, + LintEvent, +} from "@silverbulletmd/silverbullet/type/client"; + +/** Signals the linter to re-run even when the document hasn't changed. */ +export const refreshLintEffect = StateEffect.define(); export function plugLinter(client: Client) { return linter(async (view): Promise => { const text = view.state.sliceDoc(); const tree = parse(extendedMarkdownLanguage, text); - const results = ( + const results: LintDiagnostic[] = ( await client.dispatchAppEvent("editor:lint", { name: client.currentName(), pageMeta: client.currentPageMeta(), @@ -16,6 +23,30 @@ export function plugLinter(client: Client) { text, } as LintEvent) ).flat(); - return results; + return results.map(toDiagnostic); + }, { + needsRefresh: (update) => + update.transactions.some((tr) => + tr.effects.some((e) => e.is(refreshLintEffect)) + ), }); } + +function toDiagnostic(d: LintDiagnostic): Diagnostic { + const out: Diagnostic = { + from: d.from, + to: d.to, + severity: d.severity, + message: d.message, + }; + if (d.markClass) out.markClass = d.markClass; + if (d.messageHtml) { + const html = d.messageHtml; + out.renderMessage = () => { + const wrap = document.createElement("div"); + wrap.innerHTML = html; + return wrap; + }; + } + return out; +} diff --git a/client/plugos/syscalls/editor.ts b/client/plugos/syscalls/editor.ts index a206b9e7..fe9b24ef 100644 --- a/client/plugos/syscalls/editor.ts +++ b/client/plugos/syscalls/editor.ts @@ -71,6 +71,8 @@ import type { UploadFile, } from "@silverbulletmd/silverbullet/type/client"; import { openSearchPanel } from "@codemirror/search"; +import { forceLinting } from "@codemirror/lint"; +import { refreshLintEffect } from "../../codemirror/lint.ts"; import { isValidPath, parseToRef, @@ -249,6 +251,15 @@ export function editorSyscalls(client: Client): SysCallMapping { "editor.reloadPage": async () => { await client.reloadEditor(); }, + "editor.forceLint": () => { + // `forceLinting` alone is a no-op on unchanged content after the + // linter's internal "scheduled" flag has been cleared; the effect + // re-arms it via `needsRefresh`. + client.editorView.dispatch({ + effects: refreshLintEffect.of(null), + }); + forceLinting(client.editorView); + }, "editor.reloadUI": () => { location.reload(); }, diff --git a/plug-api/syscalls/editor.ts b/plug-api/syscalls/editor.ts index 3d009d47..46648afa 100644 --- a/plug-api/syscalls/editor.ts +++ b/plug-api/syscalls/editor.ts @@ -153,6 +153,11 @@ export function reloadPage(): Promise { return syscall("editor.reloadPage"); } +/** Useful when a plug toggles a piece of state that lint subscribers consult. */ +export function forceLint(): Promise { + return syscall("editor.forceLint"); +} + /** * Force reloads the browser UI */ diff --git a/plug-api/types/client.ts b/plug-api/types/client.ts index d15bd3a9..85ca5648 100644 --- a/plug-api/types/client.ts +++ b/plug-api/types/client.ts @@ -54,6 +54,18 @@ export type LintDiagnostic = { to: number; severity: "error" | "warning" | "info" | "hint"; message: string; + /** + * Optional HTML rendered into the hover tooltip for this diagnostic. + * When present, takes precedence over `message` for tooltip display. + * `message` is still used as a plain-text fallback (e.g. in the lint + * panel) and as the diagnostic's accessible label. + */ + messageHtml?: string; + /** + * Optional CSS class added to the inline range mark for this diagnostic, + * in addition to CodeMirror's default `.cm-lintRange-` class. + */ + markClass?: string; }; export type UploadFile = {