* Fix extended task rendering in widgets
This patch addresses several issues.
1. Extended tasks like `[PLANNED]` or `[TODO]` were not rendering
correctly when generated via widgets. The HTML renderer was producing
plain text without brackets or proper styling, while simple `[x]`
tasks worked fine. The fix produces the same DOM structure as
CodeMirror producing coherent styling everywhere.
2. Widget checkboxes are not interactive now. Since widget tasks have no
backing state handler clicking them only caused a misleading visual
toggle with no effect. The `disabled` attribute prevents this
confusion and the dimmed appearanceit signals to the user that the
checkbox is read-only in this context which is the truth.
To see it in action try the following Markdown snippet in SilverBullet:
```
- [x] #foo [att: i]
- [PLANNED] bar #bar [att: i]
${'- [x] #foo [att: i]\n- [PLANNED] bar #bar [att: i]'}
```
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
* Revert and just prevent widget checkboxes from toggling visually
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
* Support custom task states in widgets and indexer
Custom task states like `[PLANNED]` or `[IN PROGRESS]` now render
correctly in *widgets*, matching the same DOM structure as CodeMirror
uses in the editor. Widget-rendered tasks that reference a real task
(via `page@pos`) are fully interactive — clicking an extended state
cycles it through the configured states and updates the source task.
Tasks without a reference are disabled to prevent misleading visual
feedback.
The task indexer now respects the `done` flag on custom states defined
via `taskState.define`, so queries like `where not t.done` correctly
exclude tasks in states marked as done. Previously only the built-in
simple tasks (`- [x]` or `- [X]`) states were treated as completed. The
`Task: Remove Completed` command already handled custom done states and
continues to work unchanged.
State cycling now preserves the definition order from `taskState.define`
calls instead of sorting alphabetically. A new `order` field allows
explicit control over the cycle sequence. Without it, states cycle in
definition order. With it, states are sorted by the `order` field value.
This gives users full control over the progression, for example:
PLANNED --> IN PROGRESS --> FINISHED
The `taskState.define` API now validates its input against a schema:
`name` (string, required), `done` (boolean, optional), and `order`
(number, optional).
The `expandMarkdown` no longer injects "fake" `page@pos` references into
tasks rendered via `widget.markdownBlock` or `widget.new` which was
causing broken links and unwanted interactivity on static task content.
The `task:stateChange` event is dispatched when toggling tasks from
widgets, ensuring Lua listeners (such as some custom completed timestamp
management) fire correctly regardless of where the toggle originates.
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
---------
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
649 lines
16 KiB
TypeScript
649 lines
16 KiB
TypeScript
import {
|
|
addParentPointers,
|
|
collectNodesOfType,
|
|
findNodeOfType,
|
|
normalizeTableRow,
|
|
type ParseTree,
|
|
removeParentPointers,
|
|
renderToText,
|
|
traverseTree,
|
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
|
import {
|
|
encodePageURI,
|
|
encodeRef,
|
|
parseToRef,
|
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
import { Fragment, renderHtml, type Tag } from "./html_render.ts";
|
|
import * as TagConstants from "../../plugs/index/constants.ts";
|
|
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
|
|
import { justifiedTableRender } from "./justified_tables.ts";
|
|
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
|
|
import { createMediaElement } from "./inline.ts";
|
|
import { parseTransclusion } from "@silverbulletmd/silverbullet/lib/transclusion";
|
|
|
|
export type MarkdownRenderOptions = {
|
|
failOnUnknown?: true;
|
|
smartHardBreak?: true;
|
|
annotationPositions?: true;
|
|
shortWikiLinks?: boolean;
|
|
// When defined, use to inline images as data: urls
|
|
translateUrls?: (url: string, type: "link" | "image") => string;
|
|
expand?: true;
|
|
};
|
|
|
|
function cleanTags(values: (Tag | null)[], cleanWhitespace = false): Tag[] {
|
|
const result: Tag[] = [];
|
|
for (const value of values) {
|
|
if (cleanWhitespace && typeof value === "string" && value.match(/^\s+$/)) {
|
|
continue;
|
|
}
|
|
if (value) {
|
|
result.push(value);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Cleans up a markdown tree. Side effect: adds parent pointers
|
|
*/
|
|
function preprocess(t: ParseTree) {
|
|
addParentPointers(t);
|
|
traverseTree(t, (node) => {
|
|
if (!node.type) {
|
|
// Remove redundant newlines in table
|
|
if (node.text?.startsWith("\n")) {
|
|
const prevNodeIdx = node.parent!.children!.indexOf(node) - 1;
|
|
const prevNodeType = node.parent!.children![prevNodeIdx]?.type;
|
|
if (
|
|
prevNodeType?.includes("Heading") ||
|
|
prevNodeType?.includes("Table")
|
|
) {
|
|
node.text = node.text.slice(1);
|
|
}
|
|
}
|
|
}
|
|
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":
|
|
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 "ATXHeading6":
|
|
return {
|
|
name: "h6",
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "Paragraph":
|
|
return {
|
|
name: "span",
|
|
attrs: {
|
|
class: "p",
|
|
},
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
// Code blocks
|
|
case "FencedCode":
|
|
case "CodeBlock": {
|
|
// Clear out top-level indent blocks
|
|
const lang = findNodeOfType(t, "CodeInfo");
|
|
t.children = t.children!.filter((c) => c.type);
|
|
return {
|
|
name: "pre",
|
|
attrs: {
|
|
"data-lang": lang ? lang.children![0].text : undefined,
|
|
},
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
}
|
|
case "CodeInfo":
|
|
return null;
|
|
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 "Strikethrough":
|
|
return {
|
|
name: "del",
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "InlineCode":
|
|
return {
|
|
name: "code",
|
|
attrs: {
|
|
class: "sb-code",
|
|
},
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "BulletList":
|
|
return {
|
|
name: "ul",
|
|
body: cleanTags(mapRender(t.children!), true),
|
|
};
|
|
case "OrderedList":
|
|
return {
|
|
name: "ol",
|
|
body: cleanTags(mapRender(t.children!), true),
|
|
};
|
|
case "ListItem":
|
|
return {
|
|
name: "li",
|
|
body: cleanTags(mapRender(t.children!), true),
|
|
};
|
|
case "StrongEmphasis":
|
|
return {
|
|
name: "strong",
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "HorizontalRule":
|
|
return {
|
|
name: "hr",
|
|
body: "",
|
|
};
|
|
case "Link": {
|
|
const linkTextChildren = t.children!.slice(1, -4);
|
|
const urlNode = findNodeOfType(t, "URL");
|
|
if (!urlNode) {
|
|
return renderToText(t);
|
|
}
|
|
return {
|
|
name: "a",
|
|
attrs: {
|
|
href: urlNode.children![0].text!,
|
|
target: "_blank",
|
|
},
|
|
body: cleanTags(mapRender(linkTextChildren)),
|
|
};
|
|
}
|
|
case "Autolink": {
|
|
const urlNode = findNodeOfType(t, "URL");
|
|
if (!urlNode) {
|
|
return renderToText(t);
|
|
}
|
|
const url = urlNode.children![0].text!;
|
|
return {
|
|
name: "a",
|
|
attrs: {
|
|
href: url,
|
|
target: "_blank",
|
|
},
|
|
body: url,
|
|
};
|
|
}
|
|
case "Image": {
|
|
const text = renderToText(t);
|
|
|
|
const transclusion = parseTransclusion(text);
|
|
if (!transclusion) {
|
|
return text;
|
|
}
|
|
|
|
try {
|
|
const element = createMediaElement(transclusion);
|
|
if (!element) {
|
|
return text;
|
|
}
|
|
|
|
return {
|
|
name: element.tagName,
|
|
attrs: Array.from(element.attributes).reduce(
|
|
(obj, attr) => {
|
|
obj[attr.name] = attr.value;
|
|
return obj;
|
|
},
|
|
{} as Record<string, string>,
|
|
),
|
|
body: "",
|
|
};
|
|
} catch (e: any) {
|
|
console.error("Error handling image or transclusion", e.message);
|
|
return {
|
|
name: "span",
|
|
body: "Error loading image",
|
|
};
|
|
}
|
|
}
|
|
|
|
// Custom stuff
|
|
case "WikiLink": {
|
|
const link = findNodeOfType(t, "WikiLinkPage")!.children![0].text!;
|
|
let linkText =
|
|
options.shortWikiLinks === true ? link.split("/").pop()! : link;
|
|
const aliasNode = findNodeOfType(t, "WikiLinkAlias");
|
|
if (aliasNode) {
|
|
linkText = aliasNode.children![0].text!;
|
|
}
|
|
|
|
// For invalid refs the link just won't link
|
|
let href: string = `#`;
|
|
|
|
const ref = parseToRef(link);
|
|
if (ref) {
|
|
href = `/${encodePageURI(encodeRef(ref))}`;
|
|
}
|
|
|
|
return {
|
|
name: "a",
|
|
attrs: {
|
|
href,
|
|
class: "wiki-link",
|
|
"data-ref": link,
|
|
},
|
|
body: linkText,
|
|
};
|
|
}
|
|
case "NakedURL": {
|
|
const url = t.children![0].text!;
|
|
return {
|
|
name: "a",
|
|
attrs: {
|
|
href: url,
|
|
target: "_blank",
|
|
},
|
|
body: url,
|
|
};
|
|
}
|
|
case "Hashtag": {
|
|
const tagText: string = t.children![0].text!;
|
|
const link = TagConstants.tagPrefix + extractHashtag(tagText);
|
|
return {
|
|
name: "a",
|
|
attrs: {
|
|
class: "hashtag sb-hashtag",
|
|
"data-tag-name": extractHashtag(tagText),
|
|
href: `/${encodePageURI(link)}`,
|
|
"data-ref": link,
|
|
},
|
|
body: tagText,
|
|
};
|
|
}
|
|
case "Task": {
|
|
let externalTaskRef = "";
|
|
collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => {
|
|
const ref = parseToRef(wikilink.children![0].text!);
|
|
|
|
if (
|
|
!externalTaskRef &&
|
|
ref &&
|
|
(ref.details?.type === "position" ||
|
|
ref.details?.type === "linecolumn")
|
|
) {
|
|
externalTaskRef = wikilink.children![0].text!;
|
|
}
|
|
});
|
|
|
|
return {
|
|
name: "span",
|
|
attrs: externalTaskRef
|
|
? {
|
|
"data-external-task-ref": externalTaskRef,
|
|
}
|
|
: {},
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
}
|
|
case "TaskState": {
|
|
// child[0] = marker, child[1] = state, child[2] = marker
|
|
const stateText = t.children![1].text!;
|
|
if ([" ", "x", "X"].includes(stateText)) {
|
|
return {
|
|
name: "input",
|
|
attrs: {
|
|
type: "checkbox",
|
|
checked: stateText !== " " ? "checked" : undefined,
|
|
"data-state": stateText,
|
|
},
|
|
body: "",
|
|
};
|
|
}
|
|
return {
|
|
name: Fragment,
|
|
body: [
|
|
{ name: "span", attrs: { class: "sb-task-mark" }, body: "[" },
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-task-state", "data-state": stateText },
|
|
body: stateText,
|
|
},
|
|
{ name: "span", attrs: { class: "sb-task-mark" }, body: "]" },
|
|
],
|
|
};
|
|
}
|
|
|
|
// Tables
|
|
case "Table":
|
|
return {
|
|
name: "table",
|
|
body: justifiedTableRender(cleanTags(mapRender(t.children!)), t),
|
|
};
|
|
case "TableHeader":
|
|
return {
|
|
name: "thead",
|
|
body: [
|
|
{
|
|
name: "tr",
|
|
body: cleanTags(mapRender(t.children!), true),
|
|
},
|
|
],
|
|
};
|
|
case "TableCell":
|
|
return {
|
|
name: "td",
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "TableRow": {
|
|
const table = t.parent;
|
|
const header = table?.children?.find((c) => c.type === "TableHeader");
|
|
let columnCount = 0;
|
|
let headerHasLeadingDelim: boolean | undefined;
|
|
if (header) {
|
|
normalizeTableRow(header);
|
|
headerHasLeadingDelim = header.children?.[0]?.type === "TableDelimiter";
|
|
for (const c of header.children ?? []) {
|
|
if (c.type === "TableCell") columnCount++;
|
|
}
|
|
}
|
|
normalizeTableRow(t, columnCount, headerHasLeadingDelim);
|
|
return {
|
|
name: "tr",
|
|
body: cleanTags(mapRender(t.children!), true),
|
|
};
|
|
}
|
|
case "Attribute": {
|
|
const nameNode = findNodeOfType(t, "AttributeName");
|
|
const valueNode = findNodeOfType(t, "AttributeValue");
|
|
const colonNode = findNodeOfType(t, "AttributeColon");
|
|
const attrName = nameNode?.children?.[0].text ?? "";
|
|
const attrValue = valueNode?.children?.[0].text ?? "";
|
|
const attrColon = colonNode?.children?.[0].text ?? ": ";
|
|
return {
|
|
name: "span",
|
|
attrs: {
|
|
class: "sb-attribute",
|
|
...(attrName ? { [`data-${attrName}`]: attrValue } : {}),
|
|
},
|
|
body: [
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-frontmatter sb-meta" },
|
|
body: "[",
|
|
},
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-frontmatter sb-attribute-name" },
|
|
body: attrName,
|
|
},
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-frontmatter sb-meta" },
|
|
body: attrColon,
|
|
},
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-frontmatter sb-attribute-value" },
|
|
body: attrValue,
|
|
},
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-frontmatter sb-meta" },
|
|
body: "]",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
case "Escape": {
|
|
return {
|
|
name: "span",
|
|
attrs: {
|
|
class: "escape",
|
|
},
|
|
body: t.children![0].text!.slice(1),
|
|
};
|
|
}
|
|
case "Entity":
|
|
return t.children![0].text!;
|
|
|
|
case "Superscript":
|
|
return {
|
|
name: "sup",
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "Subscript":
|
|
return {
|
|
name: "sub",
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "FootnoteRef": {
|
|
const label = findNodeOfType(t, "FootnoteRefLabel")!.children![0].text!;
|
|
return {
|
|
name: "sup",
|
|
attrs: {
|
|
class: "sb-rendered-footnote-ref",
|
|
},
|
|
body: label,
|
|
};
|
|
}
|
|
case "FootnoteDefinition": {
|
|
const label = findNodeOfType(t, "FootnoteDefLabel")!.children![0].text!;
|
|
const body = findNodeOfType(t, "FootnoteDefBody");
|
|
return {
|
|
name: "div",
|
|
attrs: {
|
|
class: "sb-rendered-footnote-def",
|
|
id: `fn-${label}`,
|
|
},
|
|
body: [
|
|
{
|
|
name: "span",
|
|
attrs: { class: "sb-rendered-footnote-def-label" },
|
|
body: `${label}. `,
|
|
},
|
|
...(body ? cleanTags(mapRender(body.children!)) : []),
|
|
],
|
|
};
|
|
}
|
|
case "FootnoteDefBody":
|
|
return {
|
|
name: Fragment,
|
|
body: cleanTags(mapRender(t.children!)),
|
|
};
|
|
case "InlineFootnote": {
|
|
const content = findNodeOfType(t, "InlineFootnoteContent")!.children![0]
|
|
.text!;
|
|
return {
|
|
name: "sup",
|
|
attrs: {
|
|
class: "sb-rendered-footnote-ref",
|
|
},
|
|
body: [
|
|
{
|
|
name: "span",
|
|
attrs: {
|
|
class: "sb-rendered-inline-footnote-content",
|
|
title: content,
|
|
},
|
|
body: "*",
|
|
},
|
|
],
|
|
};
|
|
}
|
|
case "LuaDirective":
|
|
return {
|
|
name: "span",
|
|
attrs: {
|
|
class: "sb-lua-directive",
|
|
},
|
|
body: renderToText(t),
|
|
};
|
|
|
|
// Text
|
|
case undefined:
|
|
return t.text!;
|
|
default:
|
|
if (options.failOnUnknown) {
|
|
removeParentPointers(t);
|
|
console.error("Not handling", JSON.stringify(t, null, 2));
|
|
throw new Error(`Unknown markdown node type ${t.type}`);
|
|
} else {
|
|
// Falling back to rendering verbatim
|
|
removeParentPointers(t);
|
|
console.warn("Not handling", JSON.stringify(t, null, 2));
|
|
return renderToText(t);
|
|
}
|
|
}
|
|
|
|
function mapRender(children: ParseTree[]) {
|
|
return children.map((t) => posPreservingRender(t, options));
|
|
}
|
|
}
|
|
|
|
function traverseTag(t: Tag, fn: (t: Tag) => void) {
|
|
fn(t);
|
|
if (typeof t === "string") {
|
|
return;
|
|
}
|
|
if (t.body) {
|
|
for (const child of t.body) {
|
|
traverseTag(child, fn);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function renderMarkdownToHtml(
|
|
t: ParseTree,
|
|
options: MarkdownRenderOptions = {},
|
|
allPages: PageMeta[] = [],
|
|
) {
|
|
preprocess(t);
|
|
const htmlTree = posPreservingRender(t, options);
|
|
if (htmlTree) {
|
|
traverseTag(htmlTree, (t) => {
|
|
if (typeof t === "string") {
|
|
return;
|
|
}
|
|
if (t.name === "img" && options.translateUrls) {
|
|
t.attrs!.src = options.translateUrls!(t.attrs!.src!, "image");
|
|
}
|
|
|
|
if (t.name === "a" && t.attrs!.href) {
|
|
if (options.translateUrls) {
|
|
t.attrs!.href = options.translateUrls!(t.attrs!.href, "link");
|
|
}
|
|
if (t.attrs!["data-ref"]?.length) {
|
|
const ref = parseToRef(t.attrs!["data-ref"]!);
|
|
if (!ref) {
|
|
return;
|
|
}
|
|
|
|
const pageMeta = allPages.find(
|
|
(p) => ref.path === parseToRef(p.ref)?.path,
|
|
);
|
|
if (
|
|
pageMeta &&
|
|
!(
|
|
ref.details?.type === "position" ||
|
|
ref.details?.type === "linecolumn"
|
|
)
|
|
) {
|
|
t.body = [(pageMeta.pageDecoration?.prefix ?? "") + t.body];
|
|
if (pageMeta.pageDecoration?.cssClasses) {
|
|
t.attrs!.class +=
|
|
" sb-decorated-object " +
|
|
pageMeta.pageDecoration.cssClasses
|
|
.join(" ")
|
|
.replaceAll(/[^a-zA-Z0-9-_ ]/g, "");
|
|
}
|
|
}
|
|
}
|
|
if (t.body.length === 0) {
|
|
t.body = [t.attrs!.href];
|
|
}
|
|
}
|
|
});
|
|
}
|
|
return renderHtml(htmlTree);
|
|
}
|