From 2aed9e568591cd26e8433246e221d22066ee9256 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Tue, 21 Nov 2023 16:24:20 +0100 Subject: [PATCH] Fixes #539 linter support, now checking YAML formats --- import_map.json | 2 +- plug-api/app_event.ts | 1 + plug-api/types.ts | 7 +++ plugs/index/index.plug.yaml | 5 ++ plugs/index/lint.ts | 83 +++++++++++++++++++++++++++++++++ plugs/index/page.ts | 90 ++++++++++++++++++++++++++++++++++-- web/cm_plugins/admonition.ts | 2 +- web/cm_plugins/lint.ts | 11 +++++ web/editor_state.ts | 65 ++++++++++++++------------ 9 files changed, 230 insertions(+), 36 deletions(-) create mode 100644 plugs/index/lint.ts create mode 100644 web/cm_plugins/lint.ts diff --git a/import_map.json b/import_map.json index f49ebf0c..39cbd709 100644 --- a/import_map.json +++ b/import_map.json @@ -12,7 +12,7 @@ "@codemirror/commands": "https://esm.sh/@codemirror/commands@6.2.4?external=@codemirror/state,@codemirror/view&target=es2022", "@codemirror/view": "https://esm.sh/@codemirror/view@6.9.0?external=@codemirror/state,@lezer/common&target=es2022", "@codemirror/autocomplete": "https://esm.sh/@codemirror/autocomplete@6.7.1?external=@codemirror/state,@codemirror/commands,@lezer/common,@codemirror/view&target=es2022", - "@codemirror/lint": "https://esm.sh/@codemirror/lint@6.2.1?external=@codemirror/state,@lezer/common&target=es2022", + "@codemirror/lint": "https://esm.sh/@codemirror/lint@6.4.1?external=@codemirror/state,@codemirror/view,@lezer/common&target=es2022", "@codemirror/lang-css": "https://esm.sh/@codemirror/lang-css@6.2.0?external=@codemirror/language,@codemirror/autocomplete,@codemirror/state,@lezer/lr,@lezer/html&target=es2022", "@codemirror/lang-html": "https://esm.sh/@codemirror/lang-html@6.4.3?external=@codemirror/language,@codemirror/autocomplete,@codemirror/lang-css,@codemirror/state,@lezer/lr,@lezer/html&target=es2022", "@codemirror/search": "https://esm.sh/@codemirror/search@6.4.0?external=@codemirror/state,@codemirror/view&target=es2022", diff --git a/plug-api/app_event.ts b/plug-api/app_event.ts index f34e181e..47cace92 100644 --- a/plug-api/app_event.ts +++ b/plug-api/app_event.ts @@ -7,6 +7,7 @@ export type AppEvent = | "editor:complete" | "minieditor:complete" | "slash:complete" + | "editor:lint" | "page:load" | "editor:init" | "editor:pageLoaded" // args: pageName, previousPage, isSynced diff --git a/plug-api/types.ts b/plug-api/types.ts index 2772a780..0381fed3 100644 --- a/plug-api/types.ts +++ b/plug-api/types.ts @@ -133,3 +133,10 @@ export type CodeWidgetContent = { markdown?: string; script?: string; }; + +export type LintDiagnostic = { + from: number; + to: number; + severity: "error" | "warning" | "info" | "hint"; + message: string; +}; diff --git a/plugs/index/index.plug.yaml b/plugs/index/index.plug.yaml index ff5f1f32..e5b7a4fc 100644 --- a/plugs/index/index.plug.yaml +++ b/plugs/index/index.plug.yaml @@ -174,3 +174,8 @@ functions: renderMentions: path: "./mentions_ps.ts:renderMentions" + + lintYAML: + path: lint.ts:lintYAML + events: + - editor:lint \ No newline at end of file diff --git a/plugs/index/lint.ts b/plugs/index/lint.ts new file mode 100644 index 00000000..3bd8ec01 --- /dev/null +++ b/plugs/index/lint.ts @@ -0,0 +1,83 @@ +import { editor, markdown, YAML } from "$sb/syscalls.ts"; +import { LintDiagnostic } from "$sb/types.ts"; +import { + findNodeOfType, + renderToText, + traverseTreeAsync, +} from "$sb/lib/tree.ts"; + +export async function lintYAML(): Promise { + const text = await editor.getText(); + const tree = await markdown.parseMarkdown(text); + const diagnostics: LintDiagnostic[] = []; + await traverseTreeAsync(tree, async (node) => { + if (node.type === "FrontMatterCode") { + const lintResult = await lintYaml( + renderToText(node), + node.from!, + ); + if (lintResult) { + diagnostics.push(lintResult); + } + return true; + } + if (node.type === "FencedCode") { + const codeInfo = findNodeOfType(node, "CodeInfo")!; + if (!codeInfo) { + return true; + } + const codeLang = codeInfo.children![0].text!; + // All known YAML formats + if ( + codeLang === "template" || codeLang === "yaml" || + codeLang.startsWith("#") + ) { + const codeText = findNodeOfType(node, "CodeText"); + if (!codeText) { + return true; + } + const yamlCode = renderToText(codeText); + const lintResult = await lintYaml( + yamlCode, + codeText.from!, + ); + if (lintResult) { + diagnostics.push(lintResult); + } + return true; + } + } + return false; + }); + return diagnostics; +} + +const errorRegex = /at line (\d+),? column (\d+)/; + +async function lintYaml( + yamlText: string, + from: number, +): Promise { + try { + await YAML.parse(yamlText); + } catch (e) { + const errorMatch = errorRegex.exec(e.message); + if (errorMatch) { + console.log("YAML error", e.message); + const line = parseInt(errorMatch[1], 10) - 1; + const yamlLines = yamlText.split("\n"); + let pos = from; + for (let i = 0; i < line; i++) { + pos += yamlLines[i].length + 1; + } + const endPos = pos + yamlLines[line]?.length || pos; + + return { + from: pos, + to: endPos, + severity: "error", + message: e.message, + }; + } + } +} diff --git a/plugs/index/page.ts b/plugs/index/page.ts index d28e21b3..c14583ee 100644 --- a/plugs/index/page.ts +++ b/plugs/index/page.ts @@ -1,10 +1,15 @@ import type { IndexTreeEvent } from "$sb/app_event.ts"; -import { space } from "$sb/syscalls.ts"; +import { editor, markdown, space, YAML } from "$sb/syscalls.ts"; -import type { PageMeta } from "$sb/types.ts"; +import type { LintDiagnostic, PageMeta } from "$sb/types.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import { extractAttributes } from "$sb/lib/attribute.ts"; import { indexObjects } from "./api.ts"; +import { + findNodeOfType, + renderToText, + traverseTreeAsync, +} from "$sb/lib/tree.ts"; export async function indexPage({ name, tree }: IndexTreeEvent) { if (name.startsWith("_")) { @@ -22,7 +27,84 @@ export async function indexPage({ name, tree }: IndexTreeEvent) { pageMeta.tags = [...new Set(["page", ...pageMeta.tags || []])]; // console.log("Page object", pageObj); - - // console.log("Extracted page meta data", pageMeta); await indexObjects(name, [pageMeta]); } + +export async function lintFrontmatter(): Promise { + const text = await editor.getText(); + const tree = await markdown.parseMarkdown(text); + const diagnostics: LintDiagnostic[] = []; + await traverseTreeAsync(tree, async (node) => { + if (node.type === "FrontMatterCode") { + const lintResult = await lintYaml( + renderToText(node), + node.from!, + node.to!, + ); + if (lintResult) { + diagnostics.push(lintResult); + } + return true; + } + if (node.type === "FencedCode") { + const codeInfo = findNodeOfType(node, "CodeInfo")!; + if (!codeInfo) { + return true; + } + const codeLang = codeInfo.children![0].text!; + // All known YAML formats + if ( + codeLang === "template" || codeLang === "yaml" || + codeLang.startsWith("#") + ) { + const codeText = findNodeOfType(node, "CodeText"); + if (!codeText) { + return true; + } + const yamlCode = renderToText(codeText); + const lintResult = await lintYaml( + yamlCode, + codeText.from!, + codeText.to!, + ); + if (lintResult) { + diagnostics.push(lintResult); + } + return true; + } + } + return false; + }); + return diagnostics; +} + +const errorRegex = /at line (\d+),? column (\d+)/; + +async function lintYaml( + yamlText: string, + from: number, + to: number, +): Promise { + try { + await YAML.parse(yamlText); + } catch (e) { + const errorMatch = errorRegex.exec(e.message); + if (errorMatch) { + console.log("YAML error", e.message); + // const line = parseInt(errorMatch[1], 10) - 1; + // const yamlLines = yamlText.split("\n"); + // let pos = posOffset; + // for (let i = 0; i < line; i++) { + // pos += yamlLines[i].length + 1; + // } + // const endPos = pos + yamlLines[line].length; + + return { + from, + to, + severity: "error", + message: e.message, + }; + } + } +} diff --git a/web/cm_plugins/admonition.ts b/web/cm_plugins/admonition.ts index 3e206a3e..ee9bc2f7 100644 --- a/web/cm_plugins/admonition.ts +++ b/web/cm_plugins/admonition.ts @@ -27,7 +27,7 @@ class AdmonitionIconWidget extends WidgetType { toDOM(): HTMLElement { const outerDiv = document.createElement("div"); outerDiv.classList.add("sb-admonition-icon"); - outerDiv.addEventListener("click", (e) => { + outerDiv.addEventListener("click", () => { this.editorView.dispatch({ selection: { anchor: this.pos, diff --git a/web/cm_plugins/lint.ts b/web/cm_plugins/lint.ts new file mode 100644 index 00000000..de580d39 --- /dev/null +++ b/web/cm_plugins/lint.ts @@ -0,0 +1,11 @@ +import { Diagnostic, linter } from "@codemirror/lint"; +import type { Client } from "../client.ts"; + +export function plugLinter(client: Client) { + return linter(async (): Promise => { + const results = (await client.dispatchAppEvent("editor:lint", { + name: client.currentPage!, + })).flat(); + return results; + }); +} diff --git a/web/editor_state.ts b/web/editor_state.ts index 9f10de86..4cb482fe 100644 --- a/web/editor_state.ts +++ b/web/editor_state.ts @@ -42,22 +42,23 @@ import { import { TextChange } from "$sb/lib/change.ts"; import { postScriptPlugin } from "./cm_plugins/post_script.ts"; import { languageFor } from "../common/languages.ts"; +import { plugLinter } from "./cm_plugins/lint.ts"; export function createEditorState( - editor: Client, + client: Client, pageName: string, text: string, readOnly: boolean, ): EditorState { const commandKeyBindings: KeyBinding[] = []; - for (const def of editor.system.commandHook.editorCommands.values()) { + for (const def of client.system.commandHook.editorCommands.values()) { if (def.command.key) { commandKeyBindings.push({ key: def.command.key, mac: def.command.mac, run: (): boolean => { if (def.command.contexts) { - const context = editor.getContext(); + const context = client.getContext(); if (!context || !def.command.contexts.includes(context)) { return false; } @@ -66,14 +67,14 @@ export function createEditorState( .then(def.run) .catch((e: any) => { console.error(e); - editor.flashNotification( + client.flashNotification( `Error running command: ${e.message}`, "error", ); }) .then(() => { // Always be focusing the editor after running a command - editor.focus(); + client.focus(); }); return true; }, @@ -82,24 +83,25 @@ export function createEditorState( } let touchCount = 0; - const markdownLanguage = buildMarkdown(editor.system.mdExtensions); + const markdownLanguage = buildMarkdown(client.system.mdExtensions); return EditorState.create({ doc: text, extensions: [ // Not using CM theming right now, but some extensions depend on the "dark" thing EditorView.theme({}, { - dark: editor.ui.viewState.uiOptions.darkMode, + dark: client.ui.viewState.uiOptions.darkMode, }), // Enable vim mode, or not [ - ...editor.ui.viewState.uiOptions.vimMode ? [vim({ status: true })] : [], + ...client.ui.viewState.uiOptions.vimMode ? [vim({ status: true })] : [], ], [ - ...readOnly || editor.ui.viewState.uiOptions.forcedROMode + ...readOnly || client.ui.viewState.uiOptions.forcedROMode ? [readonlyMode()] : [], ], + // The uber markdown mode markdown({ base: markdownLanguage, @@ -119,16 +121,16 @@ export function createEditorState( markdownLanguage.data.of({ closeBrackets: { brackets: ["(", "{", "[", "`"] }, }), - syntaxHighlighting(customMarkdownStyle(editor.system.mdExtensions)), + syntaxHighlighting(customMarkdownStyle(client.system.mdExtensions)), autocompletion({ override: [ - editor.editorComplete.bind(editor), - editor.system.slashCommandHook.slashCommandCompleter.bind( - editor.system.slashCommandHook, + client.editorComplete.bind(client), + client.system.slashCommandHook.slashCommandCompleter.bind( + client.system.slashCommandHook, ), ], }), - inlineImagesPlugin(editor), + inlineImagesPlugin(client), highlightSpecialChars(), history(), drawSelection(), @@ -137,9 +139,12 @@ export function createEditorState( placeholderText: "…", }), indentOnInput(), - ...cleanModePlugins(editor), + ...cleanModePlugins(client), EditorView.lineWrapping, - postScriptPlugin(editor), + plugLinter(client), + // lintGutter(), + // gutters(), + postScriptPlugin(client), lineWrapper([ { selector: "ATXHeading1", class: "sb-line-h1" }, { selector: "ATXHeading2", class: "sb-line-h2" }, @@ -173,8 +178,8 @@ export function createEditorState( key: "Ctrl-k", mac: "Cmd-k", run: (): boolean => { - editor.ui.viewDispatch({ type: "start-navigate" }); - editor.space.updatePageList(); + client.ui.viewDispatch({ type: "start-navigate" }); + client.space.updatePageList(); return true; }, @@ -183,9 +188,9 @@ export function createEditorState( key: "Ctrl-/", mac: "Cmd-/", run: (): boolean => { - editor.ui.viewDispatch({ + client.ui.viewDispatch({ type: "show-palette", - context: editor.getContext(), + context: client.getContext(), }); return true; }, @@ -194,9 +199,9 @@ export function createEditorState( key: "Ctrl-.", mac: "Cmd-.", run: (): boolean => { - editor.ui.viewDispatch({ + client.ui.viewDispatch({ type: "show-palette", - context: editor.getContext(), + context: client.getContext(), }); return true; }, @@ -229,7 +234,7 @@ export function createEditorState( y: touch.clientY, })!, }; - await editor.dispatchAppEvent("page:click", clickEvent); + await client.dispatchAppEvent("page:click", clickEvent); }); } touchCount = 0; @@ -257,7 +262,7 @@ export function createEditorState( if (parentA) { event.stopPropagation(); event.preventDefault(); - await editor.dispatchAppEvent( + await client.dispatchAppEvent( "page:click", potentialClickEvent, ); @@ -270,7 +275,7 @@ export function createEditorState( // this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks // Fixes #357 if (distanceX <= view.defaultCharacterWidth) { - await editor.dispatchAppEvent("page:click", potentialClickEvent); + await client.dispatchAppEvent("page:click", potentialClickEvent); } }); }, @@ -287,16 +292,16 @@ export function createEditorState( newRange: { from: fromB, to: toB }, }) ); - editor.dispatchAppEvent("editor:pageModified", { changes }); - editor.ui.viewDispatch({ type: "page-changed" }); - editor.debouncedUpdateEvent(); - editor.save().catch((e) => console.error("Error saving", e)); + client.dispatchAppEvent("editor:pageModified", { changes }); + client.ui.viewDispatch({ type: "page-changed" }); + client.debouncedUpdateEvent(); + client.save().catch((e) => console.error("Error saving", e)); } } }, ), pasteLinkExtension, - attachmentExtension(editor), + attachmentExtension(client), closeBrackets(), ], });