import type { CompleteEvent, SlashCompletionOption, SlashCompletions, } from "../../plug-api/types.ts"; import { editor, markdown, space, system, YAML, } from "@silverbulletmd/silverbullet/syscalls"; import type { AttributeCompletion } from "../index/attributes.ts"; import { queryObjects } from "../index/plug_api.ts"; import type { TemplateObject } from "./types.ts"; import { loadPageObject } from "./page.ts"; import { renderTemplate } from "./api.ts"; import { extractFrontmatter, prepareFrontmatterDispatch, } from "@silverbulletmd/silverbullet/lib/frontmatter"; import type { SnippetConfig } from "./types.ts"; import { deepObjectMerge } from "@silverbulletmd/silverbullet/lib/json"; export async function snippetSlashComplete( completeEvent: CompleteEvent, ): Promise { const allTemplates = await queryObjects("template", { // where hooks.snippet.slashCommand exists filter: ["attr", ["attr", ["attr", "hooks"], "snippet"], "slashCommand"], }, 5); return { options: allTemplates.map((template) => { const snippetTemplate = template.hooks!.snippet!; return { label: snippetTemplate.slashCommand, detail: template.description, order: snippetTemplate.order || 0, templatePage: template.ref, pageName: completeEvent.pageName, invoke: "template.insertSnippetTemplate", }; }), }; } export async function insertSnippetTemplate( slashCompletion: SlashCompletionOption, ) { const pageObject = await loadPageObject( slashCompletion.pageName || (await editor.getCurrentPage()), ); const config = await system.getSpaceConfig(); const templateText = await space.readPage(slashCompletion.templatePage); let { renderedFrontmatter, text: replacementText, frontmatter } = await renderTemplate( templateText, pageObject, { page: pageObject, config }, ); const snippetTemplate: SnippetConfig = frontmatter.hooks.snippet; let cursorPos = await editor.getCursor(); if (renderedFrontmatter) { let parsedFrontmatter: Record = {}; try { parsedFrontmatter = await YAML.parse(renderedFrontmatter); } catch (e: any) { console.error( `Invalid rendered for ${slashCompletion.templatePage}:`, e.message, "for frontmatter", renderedFrontmatter, ); await editor.flashNotification( `Invalid frontmatter for ${slashCompletion.templatePage}, won't insert snippet`, "error", ); return; } const pageText = await editor.getText(); const tree = await markdown.parseMarkdown(pageText); const currentFrontmatter = await extractFrontmatter( tree, parsedFrontmatter, ); if (!currentFrontmatter.tags?.length) { delete currentFrontmatter.tags; } const newFrontmatter = deepObjectMerge( currentFrontmatter, parsedFrontmatter, ); const dispatch = await prepareFrontmatterDispatch( tree, newFrontmatter, ); if (cursorPos === 0) { dispatch.selection = { anchor: renderedFrontmatter.length + 9 }; } await editor.dispatch(dispatch); // update cursor position cursorPos = await editor.getCursor(); } if (snippetTemplate.insertAt) { switch (snippetTemplate.insertAt) { case "page-start": await editor.moveCursor(0); break; case "page-end": await editor.moveCursor((await editor.getText()).length); break; case "line-start": { const pageText = await editor.getText(); let startOfLine = cursorPos; while (startOfLine > 0 && pageText[startOfLine - 1] !== "\n") { startOfLine--; } await editor.moveCursor(startOfLine); break; } case "line-end": { const pageText = await editor.getText(); let endOfLine = cursorPos; while (endOfLine < pageText.length && pageText[endOfLine] !== "\n") { endOfLine++; } await editor.moveCursor(endOfLine); break; } default: // Deliberate no-op } } cursorPos = await editor.getCursor(); if (snippetTemplate.match || snippetTemplate.matchRegex) { const pageText = await editor.getText(); // Regex matching mode const matchRegex = new RegExp( (snippetTemplate.match || snippetTemplate.matchRegex)!, ); let startOfLine = cursorPos; while (startOfLine > 0 && pageText[startOfLine - 1] !== "\n") { startOfLine--; } let endOfLine = cursorPos; while (endOfLine < pageText.length && pageText[endOfLine] !== "\n") { endOfLine++; } let currentLine = pageText.slice(startOfLine, endOfLine); const caretParts = replacementText.split("|^|"); const emptyLine = !currentLine; currentLine = currentLine.replace(matchRegex, caretParts[0]); let newSelection = emptyLine ? { anchor: startOfLine + currentLine.length, } : undefined; if (caretParts.length === 2) { // The semantics of a caret in a replacement are: // 1. It's a caret, so we need to move the cursor there // 2. It's a placeholder, so we need to remove it // 3. Any text after the caret should be inserted after the caret const caretPos = currentLine.length; // Now add the text after the caret currentLine += caretParts[1]; newSelection = { anchor: startOfLine + caretPos, }; } await editor.dispatch({ changes: { from: startOfLine, to: endOfLine, insert: currentLine, }, selection: newSelection, }); } else { const carretPos = replacementText.indexOf("|^|"); replacementText = replacementText.replace("|^|", ""); await editor.insertAtCursor(replacementText); if (carretPos !== -1) { await editor.moveCursor(cursorPos + carretPos); } } } export function attributeCompletionsToCMCompletion( completions: AttributeCompletion[], ) { return completions.map( (completion) => ({ label: completion.name, detail: `${completion.attributeType} (${completion.source})`, type: "attribute", }), ); }