diff --git a/common/markdown_ext.ts b/common/markdown_ext.ts index 13f569b..54fc8fa 100644 --- a/common/markdown_ext.ts +++ b/common/markdown_ext.ts @@ -48,11 +48,11 @@ export function mdExtensionStyleTags({ nodeType, tag }: MDExt): { } export function loadMarkdownExtensions(system: System): MDExt[] { - let mdExtensions: MDExt[] = []; - for (let plug of system.loadedPlugs.values()) { - let manifest = plug.manifest as Manifest; + const mdExtensions: MDExt[] = []; + for (const plug of system.loadedPlugs.values()) { + const manifest = plug.manifest as Manifest; if (manifest.syntax) { - for (let [nodeType, def] of Object.entries(manifest.syntax)) { + for (const [nodeType, def] of Object.entries(manifest.syntax)) { mdExtensions.push({ nodeType, tag: Tag.define(), diff --git a/common/parse_tree.ts b/common/parse_tree.ts index 7708a1b..3cf783b 100644 --- a/common/parse_tree.ts +++ b/common/parse_tree.ts @@ -1,7 +1,5 @@ -import { ParseTree } from "$sb/lib/tree.ts"; - -import type { SyntaxNode } from "./deps.ts"; -import type { Language } from "./deps.ts"; +import type { ParseTree } from "$sb/lib/tree.ts"; +import type { Language, SyntaxNode } from "./deps.ts"; export function lezerToParseTree( text: string, diff --git a/plug-api/app_event.ts b/plug-api/app_event.ts index e3b5e5d..8e8655f 100644 --- a/plug-api/app_event.ts +++ b/plug-api/app_event.ts @@ -1,5 +1,5 @@ -import type { ParseTree } from "./lib/tree.ts"; -import { ParsedQuery } from "./lib/query.ts"; +import type { ParseTree } from "$sb/lib/tree.ts"; +import { ParsedQuery } from "$sb/lib/query.ts"; export type AppEvent = | "page:click" diff --git a/plug-api/silverbullet-syscall/editor.ts b/plug-api/silverbullet-syscall/editor.ts index 76396ba..a5fcb81 100644 --- a/plug-api/silverbullet-syscall/editor.ts +++ b/plug-api/silverbullet-syscall/editor.ts @@ -87,8 +87,8 @@ export function replaceRange( return syscall("editor.replaceRange", from, to, text); } -export function moveCursor(pos: number): Promise { - return syscall("editor.moveCursor", pos); +export function moveCursor(pos: number, center = false): Promise { + return syscall("editor.moveCursor", pos, center); } export function insertAtCursor(text: string): Promise { diff --git a/plug-api/silverbullet-syscall/markdown.ts b/plug-api/silverbullet-syscall/markdown.ts index 50642ff..e766dcd 100644 --- a/plug-api/silverbullet-syscall/markdown.ts +++ b/plug-api/silverbullet-syscall/markdown.ts @@ -1,4 +1,4 @@ -import { syscall } from "./syscall.ts"; +import { syscall } from "$sb/silverbullet-syscall/syscall.ts"; import type { ParseTree } from "$sb/lib/tree.ts"; diff --git a/plugs/directive/command.ts b/plugs/directive/command.ts index c9bcd8a..4c94f72 100644 --- a/plugs/directive/command.ts +++ b/plugs/directive/command.ts @@ -1,13 +1,11 @@ -import { editor, space } from "$sb/silverbullet-syscall/mod.ts"; -import { invokeFunction } from "$sb/silverbullet-syscall/system.ts"; - +import { editor, space, system } from "$sb/silverbullet-syscall/mod.ts"; import { renderDirectives } from "./directives.ts"; export async function updateDirectivesOnPageCommand() { const currentPage = await editor.getCurrentPage(); await editor.save(); if ( - await invokeFunction( + await system.invokeFunction( "server", "updateDirectivesOnPage", currentPage, diff --git a/plugs/directive/eval_directive.ts b/plugs/directive/eval_directive.ts index 4405250..2aa6755 100644 --- a/plugs/directive/eval_directive.ts +++ b/plugs/directive/eval_directive.ts @@ -1,6 +1,6 @@ // This is some shocking stuff. My profession would kill me for this. -import { YAML } from "../../common/deps.ts"; +import * as YAML from "yaml"; import { jsonToMDTable, renderTemplate } from "./util.ts"; // Enables plugName.functionName(arg1, arg2) syntax in JS expressions diff --git a/plugs/markdown/assets/handler.js b/plugs/markdown/assets/handler.js new file mode 100644 index 0000000..3114713 --- /dev/null +++ b/plugs/markdown/assets/handler.js @@ -0,0 +1,9 @@ +document.getElementById("root").addEventListener("click", (e) => { + // console.log("Got click", e.target) + const dataSet = e.target.dataset; + if(dataSet["onclick"]) { + sendEvent("preview:click", dataSet["onclick"]); + } else if(dataSet["pos"]) { + sendEvent("preview:click", JSON.stringify(["pos", dataSet["pos"]])); + } +}) \ No newline at end of file diff --git a/plugs/markdown/styles.css b/plugs/markdown/assets/styles.css similarity index 78% rename from plugs/markdown/styles.css rename to plugs/markdown/assets/styles.css index 43b84c6..4f7fdb4 100644 --- a/plugs/markdown/styles.css +++ b/plugs/markdown/assets/styles.css @@ -8,11 +8,24 @@ body { padding-right: 20px; } +table.front-matter { + border: 1px solid #555; + font-size: 75%; +} + +table.front-matter .key { + font-weight: bold; +} + table { width: 100%; border-spacing: 0; } +ul li p { + margin: 0; +} + thead tr { background-color: #333; color: #eee; @@ -48,3 +61,7 @@ hr:after { content: "···"; letter-spacing: 1em; } + +span.highlight { + background-color: yellow; +} diff --git a/plugs/markdown/html_render.test.ts b/plugs/markdown/html_render.test.ts new file mode 100644 index 0000000..379e4f7 --- /dev/null +++ b/plugs/markdown/html_render.test.ts @@ -0,0 +1,29 @@ +import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts"; +import { renderHtml } from "./html_render.ts"; + +Deno.test("HTML Render", () => { + assertEquals( + renderHtml({ + name: "b", + body: "hello", + }), + `hello`, + ); + assertEquals( + renderHtml({ + name: "a", + attrs: { + href: "https://example.com", + }, + body: "hello", + }), + `hello`, + ); + assertEquals( + renderHtml({ + name: "span", + body: "<>", + }), + `<>`, + ); +}); diff --git a/plugs/markdown/html_render.ts b/plugs/markdown/html_render.ts new file mode 100644 index 0000000..14d09e5 --- /dev/null +++ b/plugs/markdown/html_render.ts @@ -0,0 +1,41 @@ +export const Fragment = "FRAGMENT"; + +export type Tag = { + name: string; + attrs?: Record; + body: Tag[] | string; +} | string; + +function htmlEscape(s: string): string { + return s.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export function renderHtml(t: Tag | null): string { + if (!t) { + return ""; + } + if (typeof t === "string") { + return htmlEscape(t); + } + const attrs = t.attrs + ? " " + Object.entries(t.attrs) + .filter(([, value]) => value !== undefined) + .map(([k, v]) => `${k}="${htmlEscape(v!)}"`).join( + " ", + ) + : ""; + const body = typeof t.body === "string" + ? htmlEscape(t.body) + : t.body.map(renderHtml).join(""); + if (t.name === Fragment) { + return body; + } + if (t.body) { + return `<${t.name}${attrs}>${body}`; + } else { + return `<${t.name}${attrs}/>`; + } +} diff --git a/plugs/markdown/markdown.plug.yaml b/plugs/markdown/markdown.plug.yaml index 0d579f7..b37b0d5 100644 --- a/plugs/markdown/markdown.plug.yaml +++ b/plugs/markdown/markdown.plug.yaml @@ -2,7 +2,7 @@ name: markdown imports: - https://get.silverbullet.md/global.plug.json assets: - - "*.css" + - "assets/*" functions: toggle: path: "./markdown.ts:togglePreview" @@ -18,3 +18,8 @@ functions: - editor:updated - editor:pageLoaded - editor:pageReloaded + previewClickHandler: + path: "./preview.ts:previewClickHandler" + env: client + events: + - preview:click diff --git a/plugs/markdown/markdown_render.test.ts b/plugs/markdown/markdown_render.test.ts new file mode 100644 index 0000000..6fdc0eb --- /dev/null +++ b/plugs/markdown/markdown_render.test.ts @@ -0,0 +1,46 @@ +import buildMarkdown from "../../common/parser.ts"; +import { parse } from "../../common/parse_tree.ts"; +import { renderHtml } from "./html_render.ts"; +import { System } from "../../plugos/system.ts"; + +import corePlug from "../../dist_bundle/_plug/core.plug.json" assert { + type: "json", +}; +import tasksPlug from "../../dist_bundle/_plug/tasks.plug.json" assert { + type: "json", +}; +import { createSandbox } from "../../plugos/environments/deno_sandbox.ts"; +import { loadMarkdownExtensions } from "../../common/markdown_ext.ts"; +import { renderMarkdownToHtml } from "./markdown_render.ts"; +import { assertEquals } from "https://deno.land/std@0.152.0/testing/asserts.ts"; + +Deno.test("Markdown render", async () => { + const system = new System("server"); + await system.load(corePlug, createSandbox); + await system.load(tasksPlug, createSandbox); + const lang = buildMarkdown(loadMarkdownExtensions(system)); + const testFile = Deno.readTextFileSync( + new URL("test/example.md", import.meta.url).pathname, + ); + const tree = parse(lang, testFile); + renderMarkdownToHtml(tree, { + failOnUnknown: true, + renderFrontMatter: true, + }); + // console.log("HTML", html); +}); + +Deno.test("Smart hard break test", () => { + const example = `**Hello** +*world!*`; + const lang = buildMarkdown([]); + const tree = parse(lang, example); + const html = renderMarkdownToHtml(tree, { + failOnUnknown: true, + smartHardBreak: true, + }); + assertEquals( + html, + `

Hello
world!

`, + ); +}); diff --git a/plugs/markdown/markdown_render.ts b/plugs/markdown/markdown_render.ts new file mode 100644 index 0000000..a707268 --- /dev/null +++ b/plugs/markdown/markdown_render.ts @@ -0,0 +1,350 @@ +import { + findNodeOfType, + ParseTree, + renderToText, + traverseTree, +} from "$sb/lib/tree.ts"; +import * as YAML from "yaml"; +import { Fragment, renderHtml, Tag } from "./html_render.ts"; + +type MarkdownRenderOptions = { + failOnUnknown?: true; + smartHardBreak?: true; + annotationPositions?: true; + renderFrontMatter?: true; +}; + +function cleanTags(values: (Tag | null)[]): Tag[] { + const result: Tag[] = []; + for (const value of values) { + if (value) { + result.push(value); + } + } + return result; +} + +function preprocess(t: ParseTree, options: MarkdownRenderOptions = {}) { + traverseTree(t, (node) => { + if (node.type === "Paragraph" && options.smartHardBreak) { + for (const child of node.children!) { + // If at the paragraph level there's a newline, let's turn it into a hard break + if (!child.type && child.text === "\n") { + child.type = "HardBreak"; + } + } + } + return false; + }); +} + +function posPreservingRender( + t: ParseTree, + options: MarkdownRenderOptions = {}, +): Tag | null { + const tag = render(t, options); + if (!options.annotationPositions) { + return tag; + } + if (!tag) { + return null; + } + if (typeof tag === "string") { + return tag; + } + if (t.from) { + if (!tag.attrs) { + tag.attrs = {}; + } + tag.attrs["data-pos"] = "" + t.from; + } + return tag; +} + +function render( + t: ParseTree, + options: MarkdownRenderOptions = {}, +): Tag | null { + if (t.type?.endsWith("Mark") || t.type?.endsWith("Delimiter")) { + return null; + } + switch (t.type) { + case "Document": + return { + name: Fragment, + body: cleanTags(mapRender(t.children!)), + }; + case "FrontMatter": + if (options.renderFrontMatter) { + const yamlCode = renderToText(t.children![1]); + const parsedYaml = YAML.parse(yamlCode) as Record; + const rows: Tag[] = []; + for (const [k, v] of Object.entries(parsedYaml)) { + rows.push({ + name: "tr", + body: [ + { name: "td", attrs: { class: "key" }, body: k }, + { + name: "td", + attrs: { class: "value" }, + body: YAML.stringify(v), + }, + ], + }); + } + return { + name: "table", + attrs: { + class: "front-matter", + }, + body: rows, + }; + } else { + return null; + } + case "CommentBlock": + // Remove, for now + return null; + case "ATXHeading1": + return { + name: "h1", + body: cleanTags(mapRender(t.children!)), + }; + case "ATXHeading2": + return { + name: "h2", + body: cleanTags(mapRender(t.children!)), + }; + case "ATXHeading3": + return { + name: "h3", + body: cleanTags(mapRender(t.children!)), + }; + case "ATXHeading4": + return { + name: "h4", + body: cleanTags(mapRender(t.children!)), + }; + case "ATXHeading5": + return { + name: "h5", + body: cleanTags(mapRender(t.children!)), + }; + case "Paragraph": + return { + name: "p", + body: cleanTags(mapRender(t.children!)), + }; + // Code blocks + case "FencedCode": + case "CodeBlock": { + return { + name: "pre", + body: cleanTags(mapRender(t.children!)), + }; + } + case "CodeText": + return t.children![0].text!; + case "Blockquote": + return { + name: "blockquote", + body: cleanTags(mapRender(t.children!)), + }; + case "HardBreak": + return { + name: "br", + body: "", + }; + // Basic styling + case "Emphasis": + return { + name: "em", + body: cleanTags(mapRender(t.children!)), + }; + case "Highlight": + return { + name: "span", + attrs: { + class: "highlight", + }, + body: cleanTags(mapRender(t.children!)), + }; + case "InlineCode": + return { + name: "tt", + body: cleanTags(mapRender(t.children!)), + }; + case "BulletList": + return { + name: "ul", + body: cleanTags(mapRender(t.children!)), + }; + case "OrderedList": + return { + name: "ol", + body: cleanTags(mapRender(t.children!)), + }; + case "ListItem": + return { + name: "li", + body: cleanTags(mapRender(t.children!)), + }; + case "StrongEmphasis": + return { + name: "strong", + body: cleanTags(mapRender(t.children!)), + }; + case "HorizontalRule": + return { + name: "hr", + body: "", + }; + case "Link": { + const linkText = t.children![1].text!; + const url = findNodeOfType(t, "URL")!.children![0].text!; + return { + name: "a", + attrs: { + href: url, + }, + body: linkText, + }; + } + case "Image": { + const altText = t.children![1].text!; + let url = findNodeOfType(t, "URL")!.children![0].text!; + if (url.indexOf("://") === -1) { + url = `fs/${url}`; + } + return { + name: "img", + attrs: { + src: url, + alt: altText, + }, + body: "", + }; + } + + // Custom stuff + case "WikiLink": { + // console.log("WikiLink", JSON.stringify(t, null, 2)); + const ref = findNodeOfType(t, "WikiLinkPage")!.children![0].text!; + return { + name: "a", + attrs: { + href: `/${ref}`, + }, + body: ref, + }; + } + case "NakedURL": { + const url = t.children![0].text!; + return { + name: "a", + attrs: { + href: url, + }, + body: url, + }; + } + case "Hashtag": + return { + name: "strong", + body: t.children![0].text!, + }; + + case "Task": + return { + name: "span", + body: cleanTags(mapRender(t.children!)), + }; + case "TaskMarker": + return { + name: "input", + attrs: { + type: "checkbox", + checked: t.children![0].text !== "[ ]" ? "checked" : undefined, + "data-onclick": JSON.stringify(["task", t.to]), + }, + body: "", + }; + case "NamedAnchor": + return { + name: "a", + attrs: { + name: t.children![0].text?.substring(1), + }, + body: "", + }; + case "CommandLink": { + const commandText = t.children![0].text!.substring( + 2, + t.children![0].text!.length - 2, + ); + + return { + name: "button", + attrs: { + "data-onclick": JSON.stringify(["command", commandText]), + }, + body: commandText, + }; + } + + case "DeadlineDate": + return renderToText(t); + + // Tables + case "Table": + return { + name: "table", + body: cleanTags(mapRender(t.children!)), + }; + case "TableHeader": + return { + name: "thead", + body: [ + { + name: "tr", + body: cleanTags(mapRender(t.children!)), + }, + ], + }; + case "TableCell": + return { + name: "td", + body: cleanTags(mapRender(t.children!)), + }; + case "TableRow": + return { + name: "tr", + body: cleanTags(mapRender(t.children!)), + }; + // Text + case undefined: + return t.text!; + default: + if (options.failOnUnknown) { + console.error("Not handling", JSON.stringify(t, null, 2)); + throw new Error(`Unknown markdown node type ${t.type}`); + } else { + // Falling back to rendering verbatim + console.warn("Not handling", JSON.stringify(t, null, 2)); + return renderToText(t); + } + } + + function mapRender(children: ParseTree[]) { + return children.map((t) => posPreservingRender(t, options)); + } +} + +export function renderMarkdownToHtml( + t: ParseTree, + options: MarkdownRenderOptions = {}, +) { + preprocess(t, options); + const htmlTree = posPreservingRender(t, options); + return renderHtml(htmlTree); +} diff --git a/plugs/markdown/preview.ts b/plugs/markdown/preview.ts index b6d1cd2..97b6ea2 100644 --- a/plugs/markdown/preview.ts +++ b/plugs/markdown/preview.ts @@ -1,28 +1,40 @@ -import MarkdownIt from "https://esm.sh/markdown-it@13.0.1"; -import taskLists from "https://esm.sh/markdown-it-task-lists@2.1.1"; - -import { clientStore, editor } from "$sb/silverbullet-syscall/mod.ts"; +import { clientStore, editor, system } from "$sb/silverbullet-syscall/mod.ts"; import { asset } from "$sb/plugos-syscall/mod.ts"; -import { cleanMarkdown } from "./util.ts"; - -const md = new MarkdownIt({ - linkify: true, - html: false, - typographer: true, -}).use(taskLists); +import { parseMarkdown } from "../../plug-api/silverbullet-syscall/markdown.ts"; +import { renderMarkdownToHtml } from "./markdown_render.ts"; export async function updateMarkdownPreview() { if (!(await clientStore.get("enableMarkdownPreview"))) { return; } const text = await editor.getText(); - const cleanMd = await cleanMarkdown(text); - const css = await asset.readAsset("styles.css"); + const mdTree = await parseMarkdown(text); + // const cleanMd = await cleanMarkdown(text); + const css = await asset.readAsset("assets/styles.css"); + const js = await asset.readAsset("assets/handler.js"); + const html = renderMarkdownToHtml(mdTree, { + smartHardBreak: true, + annotationPositions: true, + renderFrontMatter: true, + }); await editor.showPanel( "rhs", 2, - `${ - md.render(cleanMd) - }`, + `
${html}
`, + js, ); } + +export async function previewClickHandler(e: any) { + const [eventName, arg] = JSON.parse(e); + // console.log("Got click", eventName, arg); + switch (eventName) { + case "pos": + // console.log("Moving cursor to", +arg); + await editor.moveCursor(+arg, true); + break; + case "command": + await system.invokeCommand(arg); + break; + } +} diff --git a/plugs/markdown/test/example.md b/plugs/markdown/test/example.md new file mode 100644 index 0000000..05e03d1 --- /dev/null +++ b/plugs/markdown/test/example.md @@ -0,0 +1,82 @@ +--- +name: Sup +--- +# Hello world +This is **bold** and _italic_, or *italic*. And a **_mix_**. And ==highlight==! + +This is one line +and this another. + +Lists: +* This +* Is a +* list +* And here we go nested + 1. This is a numbered + 2. Two +* And different + * Bla + * More bla + +And: + +1. Numbered +2. Two + +## Second heading + +And some + +``` +Code +bla +bla + +bla +``` + +And like this: + + More code + Bla + +And a blockquote: + +> Sup yo +> Empty line +> Second part + + + +And more custom stuff +[[Page link]] + +{[Command button]} + +* [ ] #next Task +* [x] #next Task 2 +* [ ] Task with dealine 📅 2022-05-06 fef + + + +https://community.mattermost.com + +$anchor + +[A link](https://silverbullet.md) + +## Tables + +|type |actor_login|created_at |payload_ref | +|---------|--------|--------------------|----------------------| +|PushEvent|avb|2022-10-27T08:27:48Z|refs/heads/master | +|PushEvent|avb|2022-10-27T04:31:27Z|refs/heads/jitterSched| + + +Here is something + +--- + +A new thing. + +![alt text](https://image.jpg) \ No newline at end of file diff --git a/plugs/tasks/task.ts b/plugs/tasks/task.ts index a0668e0..3add668 100644 --- a/plugs/tasks/task.ts +++ b/plugs/tasks/task.ts @@ -88,6 +88,14 @@ export function taskToggle(event: ClickEvent) { return taskToggleAtPos(event.pos); } +export function previewTaskToggle(eventString: string) { + const [eventName, pos] = JSON.parse(eventString); + if (eventName === "task") { + console.log("Gotta toggle a task at", pos); + return taskToggleAtPos(+pos); + } +} + async function toggleTaskMarker(node: ParseTree, moveToPos: number) { let changeTo = "[x]"; if (node.children![0].text === "[x]" || node.children![0].text === "[X]") { @@ -139,6 +147,7 @@ export async function taskToggleAtPos(pos: number) { addParentPointers(mdTree); const node = nodeAtPos(mdTree, pos); + // console.log("Got this node", node?.type); if (node && node.type === "TaskMarker") { await toggleTaskMarker(node, pos); } diff --git a/plugs/tasks/tasks.plug.yaml b/plugs/tasks/tasks.plug.yaml index f093715..64daeaa 100644 --- a/plugs/tasks/tasks.plug.yaml +++ b/plugs/tasks/tasks.plug.yaml @@ -47,3 +47,8 @@ functions: key: Alt-+ contexts: - DeadlineDate + previewTaskToggle: + env: client + path: ./task.ts:previewTaskToggle + events: + - preview:click \ No newline at end of file diff --git a/web/components/panel.tsx b/web/components/panel.tsx index 1f65690..a6fbd97 100644 --- a/web/components/panel.tsx +++ b/web/components/panel.tsx @@ -83,8 +83,10 @@ export function Panel({ editor.dispatchAppEvent(data.name, ...data.args); } }; + console.log("Registering event handler"); globalThis.addEventListener("message", messageListener); return () => { + console.log("Unregistering event handler"); globalThis.removeEventListener("message", messageListener); }; }, []); diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index 39927a2..5b65c89 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -1,5 +1,5 @@ import { Editor } from "../editor.tsx"; -import { Transaction } from "../deps.ts"; +import { EditorView, Transaction } from "../deps.ts"; import { SysCallMapping } from "../../plugos/system.ts"; import { FilterOption } from "../../common/types.ts"; @@ -113,12 +113,24 @@ export function editorSyscalls(editor: Editor): SysCallMapping { }, }); }, - "editor.moveCursor": (_ctx, pos: number) => { + "editor.moveCursor": (_ctx, pos: number, center = false) => { editor.editorView!.dispatch({ selection: { anchor: pos, }, }); + if (center) { + editor.editorView!.dispatch({ + effects: [ + EditorView.scrollIntoView( + pos, + { + y: "center", + }, + ), + ], + }); + } }, "editor.setSelection": (_ctx, from: number, to: number) => { const editorView = editor.editorView!;