diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index 794e89be..3a1d2ec1 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -63,6 +63,10 @@ config: defaultLinkStyle: type: string nullable: true + plugs: + type: array + items: + type: string functions: setEditorMode: path: "./editor.ts:setEditorMode" diff --git a/plugs/plug-manager/plugmanager.test.ts b/plugs/plug-manager/plugmanager.test.ts new file mode 100644 index 00000000..b0e61ca8 --- /dev/null +++ b/plugs/plug-manager/plugmanager.test.ts @@ -0,0 +1,110 @@ +import { assertEquals } from "@std/assert"; +import { insertIntoPlugPage } from "./plugmanager.ts"; + +/** Convenience function simulating repeatedly calling `editor.replaceRange` */ +function replaceRanges( + pageText: string, + ranges: Array<{ from: number; to: number; text: string }>, +): string { + let result = pageText; + for (const { from, to, text } of ranges) { + result = result.substring(0, from) + text + result.substring(to); + } + return result; +} + +const exampleURI = "test:my.plug.js"; +const exampleBlock = `\`\`\`space-config +plugs: +- ${exampleURI} +\`\`\``; + +Deno.test("Updating PLUGS page", () => { + // Empty page + let before = ""; + let after = replaceRanges(before, insertIntoPlugPage(exampleURI, before)); + assertEquals(after, exampleBlock); + + // Page with some content and newline + before = "Lorem ipsum dolor sit amet.\n"; + let expected = `${before}${exampleBlock}`; + after = replaceRanges(before, insertIntoPlugPage(exampleURI, before)); + assertEquals(after, expected); + + // Page without a newline at the end + before = "Lorem ipsum dolor sit amet."; + expected = `${before}\n${exampleBlock}`; + after = replaceRanges(before, insertIntoPlugPage(exampleURI, before)); + assertEquals(after, expected); + + // Old PLUGS page + before = `Old prelude + +\`\`\`yaml +- test:old.plug.js +- test:another.plug.js +\`\`\` +Some stuff below`; + expected = `Old prelude + +\`\`\`space-config +plugs: +- test:old.plug.js +- test:another.plug.js +- test:my.plug.js +\`\`\` +Some stuff below`; + after = replaceRanges(before, insertIntoPlugPage("test:my.plug.js", before)); + assertEquals(after, expected); + + // Page with an existing space config block + before = `Page content +\`\`\`space-config +foo: + bar: baz +\`\`\` +Some stuff below`; + expected = `Page content +\`\`\`space-config +foo: + bar: baz +plugs: +- test:my.plug.js +\`\`\` +Some stuff below`; + after = replaceRanges(before, insertIntoPlugPage("test:my.plug.js", before)); + assertEquals(after, expected); + + // Append at the end of an existing plugs list + before = `Page content +\`\`\`space-config +plugs: +- test:old.plug.js +# some comment +\`\`\` +`; + expected = `Page content +\`\`\`space-config +plugs: +- test:old.plug.js +# some comment +- test:my.plug.js +\`\`\` +`; + after = replaceRanges(before, insertIntoPlugPage("test:my.plug.js", before)); + assertEquals(after, expected); + + // Why would you do this to yourself? + before = `I love square brackets +\`\`\`space-config +plugs: [ "test:old.plug.js" ] +\`\`\` +`; + expected = `I love square brackets +\`\`\`space-config +plugs: [ "test:old.plug.js" , "test:my.plug.js" ] +\`\`\` +`; + after = replaceRanges(before, insertIntoPlugPage("test:my.plug.js", before)); + assertEquals(after, expected); +}); diff --git a/plugs/plug-manager/plugmanager.ts b/plugs/plug-manager/plugmanager.ts index 071b1eeb..17459b65 100644 --- a/plugs/plug-manager/plugmanager.ts +++ b/plugs/plug-manager/plugmanager.ts @@ -6,38 +6,75 @@ import { } from "@silverbulletmd/silverbullet/syscalls"; import { readYamlPage } from "@silverbulletmd/silverbullet/lib/yaml_page"; import { builtinPlugNames } from "../builtin_plugs.ts"; +import { findNodeMatching } from "@silverbulletmd/silverbullet/lib/tree"; +import { parseMarkdown } from "$common/markdown_parser/parser.ts"; +import { addParentPointers } from "@silverbulletmd/silverbullet/lib/tree"; +import { findNodeOfType } from "@silverbulletmd/silverbullet/lib/tree"; +import { assert } from "@std/assert/assert"; +import { builtinLanguages } from "$common/languages.ts"; +const plugsPage = "PLUGS"; const plugsPrelude = - "This file lists all plugs that SilverBullet will load. Run the {[Plugs: Update]} command to update and reload this list of plugs.\n\n"; + "#meta\n\nThis file lists all plugs added with the {[Plugs: Add]} command. Run the {[Plugs: Update]} command to update all Plugs defined anywhere using Space Config.\n\n"; export async function updatePlugsCommand() { await editor.save(); await editor.flashNotification("Updating plugs..."); try { - let plugList: string[] = []; - try { - const plugListRead: any[] = await readYamlPage("PLUGS"); - if (!Array.isArray(plugListRead)) { - await editor.flashNotification( - "PLUGS YAML does not contain a plug list, not loading anything", + const plugList: string[] = []; + const configPlugs: any[] = await system.getSpaceConfig("plugs", []); + if (!Array.isArray(configPlugs)) { + throw new Error("Expected 'plugs' in Space Config to be an array"); + } + const stringPlugs = configPlugs.filter((plug) => typeof plug === "string"); + if (stringPlugs.length !== configPlugs.length) { + throw new Error( + `${ + configPlugs.length - stringPlugs.length + } plugs in Space Config aren't set as strings`, + ); + } + plugList.push(...stringPlugs); + if (await space.fileExists("PLUGS.md")) { + // This is not primary mode of managing plugs anymore, only here for backwards compatibility. + try { + const pagePlugs: any[] = await readYamlPage("PLUGS"); + if (Array.isArray(pagePlugs)) { + // It's possible that the user is using it for something else, but if it has yaml with an array, assume it's plugs + const pageStringPlugs = pagePlugs.filter((plug) => + typeof plug === "string" + ); + if (pageStringPlugs.length !== pagePlugs.length) { + throw new Error( + `${ + pagePlugs.length - pageStringPlugs.length + } plugs from PLUG page were not in a yaml list format`, + ); + } + plugList.push(...pageStringPlugs); + if (pageStringPlugs.length > 0) { + editor.flashNotification( + `${pageStringPlugs.length} plugs in PLUGS page can be moved to Space Config for better editor support`, + ); + } + } + } catch (e: any) { + editor.flashNotification( + `Error processing PLUGS page: ${e.message}`, "error", ); return; } - plugList = plugListRead.filter((plug) => typeof plug === "string"); - if (plugList.length !== plugListRead.length) { - throw new Error( - `Some of the plugs were not in a yaml list format, they were ignored`, - ); - } - } catch (e: any) { - if (e.message.includes("Not found")) { - console.warn("No PLUGS page found, not loading anything"); - return; - } - throw new Error(`Error processing PLUGS: ${e.message}`); } - console.log("Plug YAML", plugList); + + // De-duplicate URIs, this is safe because by definition they point to the same plug + plugList.forEach((uri, index) => { + if (plugList.indexOf(uri) !== index) { + plugList.splice(index, 1); + } + }); + + console.log("Found Plug URIs:", plugList); const allCustomPlugNames: string[] = []; for (const plugUri of plugList) { const [protocol, ...rest] = plugUri.split(":"); @@ -65,6 +102,18 @@ export async function updatePlugsCommand() { plugName = plugNameMatch[1]; } + // Validate the extracted name + if (builtinPlugNames.includes(plugName)) { + throw new Error( + `Plug name '${plugName}' is conflicting with a built-in plug`, + ); + } + if (allCustomPlugNames.includes(plugName)) { + throw new Error( + `Plug name '${plugName}' defined by more than one URI`, + ); + } + const manifests = await events.dispatchEvent( `get-plug:${protocol}`, rest.join(":"), @@ -99,38 +148,142 @@ export async function updatePlugsCommand() { } } -export async function addPlugCommand() { - let name = await editor.prompt("Plug URI:"); - if (!name) { +export async function addPlugCommand(_cmdDef: any, uriSuggestion: string = "") { + let uri = await editor.prompt("Plug URI:", uriSuggestion); + if (!uri) { return; } // Support people copy & pasting the YAML version - if (name.startsWith("-")) { - name = name.replace(/^\-\s*/, ""); + if (uri.startsWith("-")) { + uri = uri.replace(/^\-\s*/, ""); } - let plugList: string[] = []; - try { - plugList = await readYamlPage("PLUGS"); - } catch (e: any) { - console.error("ERROR", e); + + let plugPageContent = plugsPrelude; + if (await space.fileExists(plugsPage + ".md")) { + plugPageContent = await space.readPage(plugsPage); + } else { + space.writePage(plugsPage, plugPageContent); } - if (plugList.includes(name)) { - await editor.flashNotification("Plug already installed", "error"); - return; + await editor.navigate({ page: plugsPage }); + // Here we are on the PLUGS page, if it didn't exist before it's filled with prelude + const changeList = insertIntoPlugPage(uri, plugPageContent); + for (const { from, to, text } of changeList) { + editor.replaceRange(from, to, text); } - plugList.push(name); - // await writeYamlPage("PLUGS", plugList, plugsPrelude); - await space.writePage( - "PLUGS", - plugsPrelude + "```yaml\n" + plugList.map((p) => `- ${p}`).join("\n") + - "\n```", - ); - await editor.navigate({ page: "PLUGS" }); - await updatePlugsCommand(); await editor.flashNotification("Plug added!"); system.reloadPlugs(); } +/** Add the plug to the end of the plugs list in Space Config inside the PLUGS page content + * Returns an array for `editor.replaceRange` syscalls. + * + * Rewrites the `yaml` block to `space-config` if present + * Appends the `space-config` block if needed. + * Appends the `plugs` key on root level if needed. + * + * It's exported only to allow testing. + * There are a bunch of asserts to please the type checker that will fail with a malformed page. + */ +export function insertIntoPlugPage( + uri: string, + pageContent: string, +): Array<{ from: number; to: number; text: string }> { + const edits: Array<{ from: number; to: number; text: string }> = []; + + const tree = parseMarkdown(pageContent); + addParentPointers(tree); + + const yamlInfo = findNodeMatching(tree, (n) => { + return n.type === "CodeInfo" && + n.children !== undefined && + n.children.length === 1 && + n.children[0].text === "yaml"; + }); + const configInfo = findNodeMatching(tree, (n) => { + return n.type === "CodeInfo" && + n.children !== undefined && + n.children.length === 1 && + n.children[0].text === "space-config"; + }); + + if (yamlInfo) { + // replace YAML with Space Config, add plugs: line at the start, and the new URI at the end + assert(yamlInfo.from && yamlInfo.to); + edits.push({ from: yamlInfo.from, to: yamlInfo.to, text: "space-config" }); + + assert(yamlInfo.parent); + const yamlText = findNodeOfType(yamlInfo.parent, "CodeText"); + assert(yamlText && yamlText.from && yamlText.to); + edits.push({ from: yamlText.from, to: yamlText.from, text: "plugs:\n" }); + edits.push({ from: yamlText.to, to: yamlText.to, text: `\n- ${uri}` }); + } else if (configInfo) { + // Append the required parts into the Space Config block, using lezer's (upstream) parser + assert(configInfo.parent); + const configText = findNodeOfType(configInfo.parent, "CodeText"); + assert(configText && configText.from && configText.to); + assert(configText.children?.length === 1 && configText.children[0].text); + + const config = configText.children[0].text; + const configTree = builtinLanguages["yaml"].parser.parse(config); + configTree.iterate({ + enter: (n) => { + if ( + n.name === "Document" && + config.substring(n.from, n.to).startsWith("plugs:") + ) { + assert(configText.from); + if ( + n.node.lastChild && + config.substring(n.node.lastChild.from, n.node.lastChild.to) === "]" + ) { + // This is a list with square brackets + edits.push({ + from: configText.from + n.node.lastChild.from, + to: configText.from + n.node.lastChild.from, + text: `, "${uri}" `, + }); + } else { + edits.push({ + from: configText.from + n.to, + to: configText.from + n.to, + text: `\n- ${uri}`, + }); + } + return false; // Found the right node, no need to traverse any more + } else { + return true; + } + }, + }); + if (edits.length === 0) { + // No plugs in this block + edits.push({ + from: configText.to, + to: configText.to, + text: `\nplugs:\n- ${uri}`, + }); + } + } else { + // Just add the whole block if there's nothing + const configBlock = `\`\`\`space-config +plugs: +- ${uri} +\`\`\``; + edits.push({ + from: pageContent.length, + to: pageContent.length, + // Start on an empty line + text: (pageContent.endsWith("\n") || pageContent === "") + ? configBlock + : ("\n" + configBlock), + }); + } + + // Sort edits from end to start, so they don't affect each other's positions + edits.sort((a, b) => b.from - a.from); + return edits; +} + export async function getPlugHTTPS(url: string): Promise { const fullUrl = `https:${url}`; console.log("Now fetching plug code from", fullUrl);