From 2b494f263ee13a62b1e525e0a0e83877b4a97f60 Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Mon, 24 Jul 2023 19:54:31 +0200 Subject: [PATCH] Work on inline attributes --- common/markdown_parser/customtags.ts | 4 +++ common/markdown_parser/parser.test.ts | 13 +++++++ common/markdown_parser/parser.ts | 50 ++++++++++++++++++++++++++ plug-api/lib/attribute.test.ts | 39 ++++++++++++++++++++ plug-api/lib/attribute.ts | 51 +++++++++++++++++++++++++++ plugs/core/item.ts | 17 ++++++--- plugs/core/page.ts | 12 +++++-- plugs/markdown/markdown_render.ts | 12 +++++++ plugs/tasks/task.ts | 9 ++++- web/cm_plugins/table.ts | 1 + web/style.ts | 2 ++ web/styles/colors.scss | 17 ++++----- 12 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 plug-api/lib/attribute.test.ts create mode 100644 plug-api/lib/attribute.ts diff --git a/common/markdown_parser/customtags.ts b/common/markdown_parser/customtags.ts index ef9104e8..f696af89 100644 --- a/common/markdown_parser/customtags.ts +++ b/common/markdown_parser/customtags.ts @@ -18,3 +18,7 @@ export const DirectiveTag = Tag.define(); export const DirectiveStartTag = Tag.define(); export const DirectiveEndTag = Tag.define(); export const DirectiveProgramTag = Tag.define(); + +export const AttributeTag = Tag.define(); +export const AttributeNameTag = Tag.define(); +export const AttributeValueTag = Tag.define(); diff --git a/common/markdown_parser/parser.test.ts b/common/markdown_parser/parser.test.ts index 0d6e3d38..18461cf3 100644 --- a/common/markdown_parser/parser.test.ts +++ b/common/markdown_parser/parser.test.ts @@ -89,3 +89,16 @@ Deno.test("Test directive parser", () => { tree = parse(lang, orderByExample); console.log("Tree", JSON.stringify(tree, null, 2)); }); + +const inlineAttributeSample = ` +Hello there [a link](http://zef.plus) and [age:: 100] +`; + +Deno.test("Test inline attribute syntax", () => { + const lang = buildMarkdown([]); + const tree = parse(lang, inlineAttributeSample); + const nameNode = findNodeOfType(tree, "AttributeName"); + assertEquals(nameNode?.children![0].text, "age"); + const valueNode = findNodeOfType(tree, "AttributeValue"); + assertEquals(valueNode?.children![0].text, "100"); +}); diff --git a/common/markdown_parser/parser.ts b/common/markdown_parser/parser.ts index 51baca4c..aaa98a89 100644 --- a/common/markdown_parser/parser.ts +++ b/common/markdown_parser/parser.ts @@ -142,6 +142,55 @@ export const Highlight: MarkdownConfig = { ], }; +export const attributeRegex = /^\[([^:]+)(::\s*)([^\]]+)\]/; + +export const Attribute: MarkdownConfig = { + defineNodes: [ + { name: "Attribute", style: { "Attribute/...": ct.AttributeTag } }, + { name: "AttributeName", style: ct.AttributeNameTag }, + { name: "AttributeValue", style: ct.AttributeValueTag }, + { name: "AttributeMark", style: t.processingInstruction }, + { name: "AttributeColon", style: t.processingInstruction }, + ], + parseInline: [ + { + name: "Attribute", + parse(cx, next, pos) { + let match: RegExpMatchArray | null; + if ( + next != 91 /* '[' */ || + // and match the whole thing + !(match = attributeRegex.exec(cx.slice(pos, cx.end))) + ) { + return -1; + } + const [fullMatch, attributeName, attributeColon, attributeValue] = + match; + const endPos = pos + fullMatch.length; + + return cx.addElement( + cx.elt("Attribute", pos, endPos, [ + cx.elt("AttributeMark", pos, pos + 1), // [ + cx.elt("AttributeName", pos + 1, pos + 1 + attributeName.length), + cx.elt( + "AttributeColon", + pos + 1 + attributeName.length, + pos + 1 + attributeName.length + attributeColon.length, + ), + cx.elt( + "AttributeValue", + pos + 1 + attributeName.length + attributeColon.length, + endPos - 1, + ), + cx.elt("AttributeMark", endPos - 1, endPos), // [ + ]), + ); + }, + after: "Emphasis", + }, + ], +}; + class CommentParser implements LeafBlockParser { nextLine() { return false; @@ -343,6 +392,7 @@ export default function buildMarkdown(mdExtensions: MDExt[]): Language { extensions: [ WikiLink, CommandLink, + Attribute, FrontMatter, Directive, TaskList, diff --git a/plug-api/lib/attribute.test.ts b/plug-api/lib/attribute.test.ts new file mode 100644 index 00000000..c998a2b1 --- /dev/null +++ b/plug-api/lib/attribute.test.ts @@ -0,0 +1,39 @@ +import { parse } from "../../common/markdown_parser/parse_tree.ts"; +import buildMarkdown from "../../common/markdown_parser/parser.ts"; +import { extractAttributes } from "$sb/lib/attribute.ts"; +import { assertEquals } from "../../test_deps.ts"; +import { renderToText } from "$sb/lib/tree.ts"; + +const inlineAttributeSample = ` +# My document +Top level attributes: [name:: sup] [age:: 42] + +* [ ] Attribute in a task [tag:: foo] +* Regular item [tag:: bar] + +1. Itemized list [tag:: baz] +`; + +const cleanedInlineAttributeSample = ` +# My document +Top level attributes: + +* [ ] Attribute in a task [tag:: foo] +* Regular item [tag:: bar] + +1. Itemized list [tag:: baz] +`; + +Deno.test("Test attribute extraction", () => { + const lang = buildMarkdown([]); + const tree = parse(lang, inlineAttributeSample); + const toplevelAttributes = extractAttributes(tree, false); + assertEquals(Object.keys(toplevelAttributes).length, 2); + assertEquals(toplevelAttributes.name, "sup"); + assertEquals(toplevelAttributes.age, 42); + // Check if the attributes are still there + assertEquals(renderToText(tree), inlineAttributeSample); + // Now once again with cleaning + extractAttributes(tree, true); + assertEquals(renderToText(tree), cleanedInlineAttributeSample); +}); diff --git a/plug-api/lib/attribute.ts b/plug-api/lib/attribute.ts new file mode 100644 index 00000000..b5d6dfe6 --- /dev/null +++ b/plug-api/lib/attribute.ts @@ -0,0 +1,51 @@ +import { + findNodeOfType, + ParseTree, + replaceNodesMatching, +} from "$sb/lib/tree.ts"; + +export type Attribute = { + name: string; + value: string; +}; + +const numberRegex = /^-?\d+(\.\d+)?$/; + +/** + * Extracts attributes from a tree, optionally cleaning them out of the tree. + * @param tree tree to extract attributes from + * @param clean whether or not to clean out the attributes from the tree + * @returns mapping from attribute name to attribute value + */ +export function extractAttributes( + tree: ParseTree, + clean: boolean, +): Record { + const attributes: Record = {}; + replaceNodesMatching(tree, (n) => { + if (n.type === "ListItem") { + // Find top-level only, no nested lists + return n; + } + if (n.type === "Attribute") { + const nameNode = findNodeOfType(n, "AttributeName"); + const valueNode = findNodeOfType(n, "AttributeValue"); + if (nameNode && valueNode) { + let val: any = valueNode.children![0].text!; + if (numberRegex.test(val)) { + val = +val; + } + attributes[nameNode.children![0].text!] = val; + } + // Remove from tree + if (clean) { + return null; + } else { + return n; + } + } + // Go on... + return undefined; + }); + return attributes; +} diff --git a/plugs/core/item.ts b/plugs/core/item.ts index 8352196e..18ff0ded 100644 --- a/plugs/core/item.ts +++ b/plugs/core/item.ts @@ -3,6 +3,7 @@ import type { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts"; import { index } from "$sb/silverbullet-syscall/mod.ts"; import { collectNodesOfType, ParseTree, renderToText } from "$sb/lib/tree.ts"; import { applyQuery, removeQueries } from "$sb/lib/query.ts"; +import { extractAttributes } from "$sb/lib/attribute.ts"; export type Item = { name: string; @@ -11,7 +12,7 @@ export type Item = { // Not stored in DB page?: string; pos?: number; -}; +} & Record; export async function indexItems({ name, tree }: IndexTreeEvent) { const items: { key: string; value: Item }[] = []; @@ -30,6 +31,10 @@ export async function indexItems({ name, tree }: IndexTreeEvent) { return; } + const item: Item = { + name: "", // to be replaced + }; + const textNodes: ParseTree[] = []; let nested: string | undefined; for (const child of n.children!.slice(1)) { @@ -37,13 +42,15 @@ export async function indexItems({ name, tree }: IndexTreeEvent) { nested = renderToText(child); break; } + // Extract attributes and remove from tree + const extractedAttributes = extractAttributes(child, true); + for (const [key, value] of Object.entries(extractedAttributes)) { + item[key] = value; + } textNodes.push(child); } - const itemText = textNodes.map(renderToText).join("").trim(); - const item: Item = { - name: itemText, - }; + item.name = textNodes.map(renderToText).join("").trim(); if (nested) { item.nested = nested; } diff --git a/plugs/core/page.ts b/plugs/core/page.ts index a02be94e..253c3c4b 100644 --- a/plugs/core/page.ts +++ b/plugs/core/page.ts @@ -25,6 +25,7 @@ import { applyQuery } from "$sb/lib/query.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import { invokeFunction } from "$sb/silverbullet-syscall/system.ts"; import { isValidPageName } from "$sb/lib/page.ts"; +import { extractAttributes } from "$sb/lib/attribute.ts"; // Key space: // l:toPage:pos => {name: pageName, inDirective: true} @@ -42,14 +43,21 @@ export async function indexLinks({ name, tree }: IndexTreeEvent) { // [[Style Links]] // console.log("Now indexing links for", name); const pageMeta = await extractFrontmatter(tree); - if (Object.keys(pageMeta).length > 0) { - // console.log("Extracted page meta data", pageMeta); + const toplevelAttributes = extractAttributes(tree, false); + if ( + Object.keys(pageMeta).length > 0 || + Object.keys(toplevelAttributes).length > 0 + ) { + for (const [k, v] of Object.entries(toplevelAttributes)) { + pageMeta[k] = v; + } // Don't index meta data starting with $ for (const key in pageMeta) { if (key.startsWith("$")) { delete pageMeta[key]; } } + console.log("Extracted page meta data", pageMeta); await index.set(name, "meta:", pageMeta); } diff --git a/plugs/markdown/markdown_render.ts b/plugs/markdown/markdown_render.ts index 0a177e9a..171ce415 100644 --- a/plugs/markdown/markdown_render.ts +++ b/plugs/markdown/markdown_render.ts @@ -11,6 +11,7 @@ type MarkdownRenderOptions = { smartHardBreak?: true; annotationPositions?: true; attachmentUrlPrefix?: string; + preserveAttributes?: true; // When defined, use to inline images as data: urls translateUrls?: (url: string) => string; }; @@ -345,6 +346,17 @@ function render( const body = findNodeOfType(t, "DirectiveBody")!; return posPreservingRender(body.children![0], options); } + case "Attribute": + if (options.preserveAttributes) { + return { + name: "span", + attrs: { + class: "attribute", + }, + body: renderToText(t), + }; + } + return null; // Text case undefined: return t.text!; diff --git a/plugs/tasks/task.ts b/plugs/tasks/task.ts index 45d0fcd1..7d0113f5 100644 --- a/plugs/tasks/task.ts +++ b/plugs/tasks/task.ts @@ -23,6 +23,7 @@ import { } from "$sb/lib/tree.ts"; import { applyQuery, removeQueries } from "$sb/lib/query.ts"; import { niceDate } from "$sb/lib/dates.ts"; +import { extractAttributes } from "$sb/lib/attribute.ts"; export type Task = { name: string; @@ -33,7 +34,7 @@ export type Task = { // Not saved in DB, just added when pulled out (from key) pos?: number; page?: string; -}; +} & Record; function getDeadline(deadlineNode: ParseTree): string { return deadlineNode.children![0].text!.replace(/📅\s*/, ""); @@ -67,6 +68,12 @@ export async function indexTasks({ name, tree }: IndexTreeEvent) { } }); + // Extract attributes and remove from tree + const extractedAttributes = extractAttributes(n, true); + for (const [key, value] of Object.entries(extractedAttributes)) { + task[key] = value; + } + task.name = n.children!.slice(1).map(renderToText).join("").trim(); const taskIndex = n.parent!.children!.indexOf(n); diff --git a/web/cm_plugins/table.ts b/web/cm_plugins/table.ts index 0dbe8739..b1798bb9 100644 --- a/web/cm_plugins/table.ts +++ b/web/cm_plugins/table.ts @@ -43,6 +43,7 @@ class TableViewWidget extends WidgetType { } return url; }, + preserveAttributes: true, }); return dom; } diff --git a/web/style.ts b/web/style.ts index 725d3de9..cdaf3d6c 100644 --- a/web/style.ts +++ b/web/style.ts @@ -18,6 +18,8 @@ export default function highlightStyles(mdExtension: MDExt[]) { { tag: ct.WikiLinkPageTag, class: "sb-wiki-link-page" }, { tag: ct.CommandLinkTag, class: "sb-command-link" }, { tag: ct.CommandLinkNameTag, class: "sb-command-link-name" }, + { tag: ct.AttributeTag, class: "sb-frontmatter" }, + { tag: ct.AttributeNameTag, class: "sb-atom" }, { tag: ct.TaskTag, class: "sb-task" }, { tag: ct.TaskMarkerTag, class: "sb-task-marker" }, { tag: ct.CodeInfoTag, class: "sb-code-info" }, diff --git a/web/styles/colors.scss b/web/styles/colors.scss index ddda050f..d36359a2 100644 --- a/web/styles/colors.scss +++ b/web/styles/colors.scss @@ -50,7 +50,7 @@ font-family: var(--editor-font); } -.sb-notifications > div { +.sb-notifications>div { border: var(--notifications-border-color) 1px solid; } @@ -185,6 +185,7 @@ background-color: var(--editor-command-button-background-color); border: 1px solid var(--editor-command-button-border-color); } + .sb-command-button:hover { background-color: var(--editor-command-button-hover-background-color); } @@ -193,11 +194,13 @@ &.sb-meta { color: var(--editor-command-button-meta-color); } + &.sb-command-link-name { background-color: var(--editor-command-button-color); background-color: var(--editor-command-button-background-color); border: 1px solid var(--editor-command-button-border-color); } + &.sb-command-link-name:hover { background-color: var(--editor-command-button-hover-background-color); } @@ -209,7 +212,7 @@ } /* Then undo other meta */ - .sb-line-li .sb-meta ~ .sb-meta { + .sb-line-li .sb-meta~.sb-meta { color: var(--editor-meta-color); } @@ -326,7 +329,7 @@ .sb-directive-start-outside, .sb-directive-end-outside { - & > span.sb-directive-placeholder { + &>span.sb-directive-placeholder { color: var(--editor-directive-info-color); } } @@ -366,7 +369,7 @@ } a.sb-wiki-link-page-missing, - .sb-wiki-link-page-missing > .sb-wiki-link-page { + .sb-wiki-link-page-missing>.sb-wiki-link-page { color: var(--editor-wiki-link-page-missing-color); background-color: var(--editor-wiki-link-page-background-color); } @@ -380,8 +383,6 @@ } .sb-line-comment { - background-color: var( - --editor-code-comment-color - ); // rgba(255, 255, 0, 0.5); + background-color: var(--editor-code-comment-color); // rgba(255, 255, 0, 0.5); } -} +} \ No newline at end of file