diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 57809624..3815fd3f 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -62,7 +62,7 @@ jobs:
- name: Build binaries for multiple platforms
run: |
make build-for-docker
-
+
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4.4.0
diff --git a/client/client.ts b/client/client.ts
index dd209690..66eb4fa2 100644
--- a/client/client.ts
+++ b/client/client.ts
@@ -988,7 +988,10 @@ export class Client {
} catch (e: any) {
// If there is no document editor we will open the file raw
if (e.message.includes("Couldn't find")) {
- this.openUrl(`${fsEndpoint}/${path}`, !previousPath);
+ this.openUrl(
+ `${document.baseURI.replace(/\/*$/, "") + fsEndpoint}/${path}`,
+ !previousPath,
+ );
}
throw e;
diff --git a/client/codemirror/frontmatter.ts b/client/codemirror/frontmatter.ts
index fb098184..5c0d04ca 100644
--- a/client/codemirror/frontmatter.ts
+++ b/client/codemirror/frontmatter.ts
@@ -9,10 +9,10 @@ import {
} from "./util.ts";
import type { Client } from "../client.ts";
import {
+ frontmatterMailtoRegex,
frontmatterQuotesRegex,
frontmatterUrlRegex,
frontmatterWikiLinkRegex,
- frontmatterMailtoRegex,
} from "../markdown_parser/constants.ts";
import { processWikiLink, type WikiLinkMatch } from "./wiki_link_processor.ts";
diff --git a/client/codemirror/lua_directive.ts b/client/codemirror/lua_directive.ts
index 2a677d28..5c5320d8 100644
--- a/client/codemirror/lua_directive.ts
+++ b/client/codemirror/lua_directive.ts
@@ -14,6 +14,7 @@ import {
LuaEnv,
LuaRuntimeError,
LuaStackFrame,
+ LuaTable,
luaValueToJS,
singleResult,
} from "../space_lua/runtime.ts";
@@ -123,6 +124,12 @@ export function luaDirectivePlugin(client: Client) {
) {
return rawResult;
}
+ if (rawResult instanceof LuaTable) {
+ if (rawResult.rawGet("_isWidget")) {
+ return luaValueToJS(rawResult, sf);
+ }
+ return rawResult;
+ }
// everything else needs luaValueToJS for widget support
return luaValueToJS(rawResult, sf);
} catch (e: any) {
diff --git a/client/codemirror/lua_widget.ts b/client/codemirror/lua_widget.ts
index c92d3758..cdeb8f6f 100644
--- a/client/codemirror/lua_widget.ts
+++ b/client/codemirror/lua_widget.ts
@@ -13,15 +13,13 @@ import {
moveCursorIntoText,
} from "./widget_util.ts";
import { expandMarkdown } from "../markdown_renderer/inline.ts";
-import { luaFormatNumber, LuaTable } from "../space_lua/runtime.ts";
-import { isTaggedFloat } from "../space_lua/numeric.ts";
import {
isBlockMarkdown,
- jsonToMDTable,
- refCellTransformer,
+ renderExpressionResult,
} from "../markdown_renderer/result_render.ts";
import { activeWidgets } from "./code_widget.ts";
import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
+import { renderResultToHtml } from "../space_lua/render_lua_html.ts";
export type LuaWidgetCallback = (
bodyText: string,
@@ -95,6 +93,26 @@ export class LuaWidget extends WidgetType {
return wrapperSpan;
}
+ // Build an inline Markdown renderer bound to the current client context
+ private buildInlineRenderer(): (text: string) => string {
+ return (text: string): string => {
+ const mdTree = parse(extendedMarkdownLanguage, text);
+ return renderMarkdownToHtml(mdTree, {
+ shortWikiLinks: this.client.config.get("shortWikiLinks", false),
+ translateUrls: (url) => {
+ if (isLocalURL(url)) {
+ url = resolveMarkdownLink(
+ this.client.currentName(),
+ decodeURI(url),
+ );
+ }
+ return url;
+ },
+ preserveAttributes: true,
+ }, this.client.ui.viewState.allPages);
+ };
+ }
+
async renderContent(
div: HTMLElement,
) {
@@ -121,38 +139,53 @@ export class LuaWidget extends WidgetType {
let block = false;
let copyContent: string | undefined ;
- // Normalization
+ // Normalization (non-widget results go through dual-path rendering)
if (typeof widgetContent === "string" || !widgetContent._isWidget) {
- // Apply heuristic to render the object as a markdown table
+ // HTML path for display (with data attributes, nested structures)
+ const inlineRenderer = this.buildInlineRenderer();
+ const { html: displayHtml, dataType } = await renderResultToHtml(
+ widgetContent,
+ inlineRenderer,
+ );
+ // Markdown path for copy button (flat tables, `{...}` for nested)
+ const markdownCopy = await renderExpressionResult(widgetContent);
+
widgetContent = {
_isWidget: true,
- markdown: await renderExpressionResult(widgetContent),
+ html: `${displayHtml}`,
+ display: (dataType === "table" || dataType === "list")
+ ? "block"
+ : "inline",
};
+ copyContent = markdownCopy;
}
- if (widgetContent.cssClasses) {
- div.className = widgetContent.cssClasses.join(" ");
+ // After normalization `widgetContent` is always the object form
+ const wc = widgetContent as Exclude;
+
+ if (wc.cssClasses) {
+ div.className = wc.cssClasses.join(" ");
}
- if (widgetContent.html) {
- if (typeof widgetContent.html === "string") {
- html = parseHtmlString(widgetContent.html);
- copyContent = widgetContent.html;
+ if (wc.html) {
+ if (typeof wc.html === "string") {
+ html = parseHtmlString(wc.html);
+ if (!copyContent) copyContent = wc.html;
} else {
- html = widgetContent.html;
- copyContent = widgetContent.html.outerHTML;
+ html = wc.html;
+ if (!copyContent) copyContent = wc.html.outerHTML;
}
- block = widgetContent.display === "block";
+ block = wc.display === "block";
if (block) {
div.className += " sb-lua-directive-block";
} else {
div.className += " sb-lua-directive-inline";
}
}
- if (widgetContent.markdown) {
+ if (wc.markdown) {
let mdTree = parse(
extendedMarkdownLanguage,
- widgetContent.markdown || "",
+ wc.markdown || "",
);
mdTree = await expandMarkdown(
@@ -176,7 +209,7 @@ export class LuaWidget extends WidgetType {
return;
}
- block = widgetContent._isWidget && widgetContent.display === "block" ||
+ block = wc._isWidget && wc.display === "block" ||
isBlockMarkdown(trimmedMarkdown);
if (block) {
div.className += " sb-lua-directive-block";
@@ -211,7 +244,7 @@ export class LuaWidget extends WidgetType {
div,
this.client,
this.inPage ? this.codeText : undefined,
- widgetContent._isWidget && widgetContent.events,
+ wc._isWidget && wc.events,
);
}
@@ -343,42 +376,6 @@ export class LuaWidget extends WidgetType {
}
}
-export function renderExpressionResult(result: any): Promise {
- if (result instanceof LuaTable) {
- result = result.toJS();
- }
- // Must check before object/array checks — tagged floats are plain objects
- if (isTaggedFloat(result)) {
- return Promise.resolve(luaFormatNumber(result.value, "float"));
- }
- if (typeof result === "number") {
- return Promise.resolve(luaFormatNumber(result));
- }
- if (
- Array.isArray(result) && result.length > 0 && typeof result[0] === "object"
- ) {
- // If result is an array of objects, render as a Markdown table
- try {
- return jsonToMDTable(result, refCellTransformer);
- } catch (e: any) {
- console.error(
- `Error rendering expression directive: ${e.message} for value ${
- JSON.stringify(result)
- }`,
- );
- return Promise.resolve(JSON.stringify(result));
- }
- } else if (typeof result === "object" && result.constructor === Object) {
- // If result is a plain object, render as a Markdown table
- return jsonToMDTable([result], refCellTransformer);
- } else if (Array.isArray(result)) {
- // Not-object array, let's render it as a Markdown list
- return Promise.resolve(result.map((item) => `- ${item}`).join("\n"));
- } else {
- return Promise.resolve(`${result}`);
- }
-}
-
export function parseHtmlString(html: string): HTMLElement {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
diff --git a/client/markdown_renderer/result_render.ts b/client/markdown_renderer/result_render.ts
index d830ca3a..f23e38f7 100644
--- a/client/markdown_renderer/result_render.ts
+++ b/client/markdown_renderer/result_render.ts
@@ -13,6 +13,10 @@ export function defaultTransformer(v: any, _k: string): Promise {
if (typeof v === "string") {
return Promise.resolve(escapeRegularPipes(v.replaceAll("\n", " ")));
}
+ if (v instanceof LuaTable) {
+ // Render nested `LuaTables` as literal `{...}` for copy path
+ return v.toStringAsync().then((s) => escapeRegularPipes(s));
+ }
if (v && typeof v === "object") {
return Promise.resolve(luaToString(v));
}
@@ -110,12 +114,18 @@ export async function jsonToMDTable(
return lines.join("\n");
}
-export function renderExpressionResult(result: any): Promise {
+// Render an expression result as Markdown (for copy path)
+// LuaTables are rendered natively, JS objects/arrays use `jsonToMDTable`
+export function renderExpressionResult(
+ result: any,
+ cellTransformer: (v: any, k: string) => Promise = refCellTransformer,
+): Promise {
if (result === undefined || result === null) {
return Promise.resolve("nil");
}
+ // LuaTable: render natively without `.toJS`
if (result instanceof LuaTable) {
- result = result.toJS();
+ return renderLuaTableToMarkdown(result, cellTransformer);
}
// Must check before object/array checks — tagged floats are plain objects
if (isTaggedFloat(result)) {
@@ -129,7 +139,7 @@ export function renderExpressionResult(result: any): Promise {
) {
// If result is an array of objects, render as a Markdown table
try {
- return jsonToMDTable(result);
+ return jsonToMDTable(result, cellTransformer);
} catch (e: any) {
console.error(
`Error rendering expression directive: ${e.message} for value ${
@@ -139,16 +149,164 @@ export function renderExpressionResult(result: any): Promise {
return Promise.resolve(JSON.stringify(result));
}
} else if (typeof result === "object" && result.constructor === Object) {
- // If result is a plain object, render as a Markdown table
- return jsonToMDTable([result]);
+ if (Object.keys(result).length === 0) {
+ return Promise.resolve("*(empty table)*");
+ }
+ return jsonToMDTable([result], cellTransformer);
} else if (Array.isArray(result)) {
- // Not-object array, let's render it as a Markdown list
- return Promise.resolve(result.map((item) => `- ${item}`).join("\n"));
+ if (result.length === 0) {
+ return Promise.resolve("*(empty table)*");
+ }
+ return renderListItems(result, cellTransformer, false);
} else {
return Promise.resolve(`${result}`);
}
}
+// Render a `LuaTable` to Markdown (table for records, list for arrays)
+async function renderLuaTableToMarkdown(
+ tbl: LuaTable,
+ cellTransformer: (v: any, k: string) => Promise,
+ nested = false,
+): Promise {
+ const keys = tbl.keys();
+ if (keys.length === 0) return "*(empty table)*";
+
+ const arrayLen = tbl.length;
+ const stringKeys: string[] = [];
+ for (const k of keys) {
+ if (typeof k === "string") stringKeys.push(k);
+ }
+
+ const hasArrayPart = arrayLen > 0;
+ const hasStrKeys = stringKeys.length > 0;
+
+ // Pure array
+ if (hasArrayPart && !hasStrKeys) {
+ const elements: any[] = [];
+ for (let i = 1; i <= arrayLen; i++) elements.push(tbl.rawGet(i));
+ // If all elements are `LuaTables` then to Markdown table
+ if (elements.every((el) => el instanceof LuaTable)) {
+ return renderLuaTableArrayToMarkdown(
+ elements as LuaTable[],
+ cellTransformer,
+ );
+ }
+ // Flat list
+ return renderListItems(elements, cellTransformer, nested);
+ }
+
+ // Record or mixed - use `keys` order directly (preserve order)
+ const allHeaders: string[] = keys.map(String);
+
+ const lines: string[] = [
+ `|${allHeaders.join("|")}|`,
+ `|${allHeaders.map(() => "--").join("|")}|`,
+ ];
+ const cells: string[] = [];
+ for (const k of keys) {
+ const v = tbl.rawGet(k);
+ cells.push(await cellTransformer(v, String(k)));
+ }
+ lines.push(`|${cells.join("|")}|`);
+ return lines.join("\n");
+}
+
+// Render a list of items as indented Markdown list
+async function renderListItems(
+ items: any[],
+ cellTransformer: (v: any, k: string) => Promise,
+ nested: boolean,
+): Promise {
+ const rendered: string[] = [];
+ for (const item of items) {
+ const md = await renderItemToMarkdown(item, cellTransformer, nested);
+ if (md.includes("\n")) {
+ const lines = md.split("\n");
+ rendered.push(
+ "-\n" +
+ lines.map((l) => ` ${l}`).join("\n"),
+ );
+ } else {
+ rendered.push(`- ${md}`);
+ }
+ }
+ return rendered.join("\n");
+}
+
+// Render a single item for use inside a list
+function renderItemToMarkdown(
+ item: any,
+ cellTransformer: (v: any, k: string) => Promise,
+ nested: boolean,
+): Promise {
+ if (item === undefined || item === null) return Promise.resolve("");
+ if (item instanceof LuaTable) {
+ if (item.empty()) return Promise.resolve("*(empty table)*");
+ if (nested) {
+ return item.toStringAsync();
+ }
+ return renderLuaTableToMarkdown(item, cellTransformer, true);
+ }
+ if (Array.isArray(item)) {
+ if (item.length === 0) return Promise.resolve("*(empty table)*");
+ if (isPlainObjectHelper(item)) {
+ return jsonToMDTable(item, cellTransformer);
+ }
+ if (nested) {
+ return Promise.resolve(JSON.stringify(item));
+ }
+ return renderListItems(item, cellTransformer, true);
+ }
+ if (isPlainObjectHelper(item)) {
+ if (Object.keys(item).length === 0) {
+ return Promise.resolve("*(empty table)*");
+ }
+ if (nested) {
+ return Promise.resolve(JSON.stringify(item));
+ }
+ return jsonToMDTable([item], cellTransformer);
+ }
+ if (isTaggedFloat(item)) {
+ return Promise.resolve(luaFormatNumber(item.value, "float"));
+ }
+ if (typeof item === "number") return Promise.resolve(luaFormatNumber(item));
+ return Promise.resolve(`${item}`);
+}
+
+function isPlainObjectHelper(v: any): v is Record {
+ return typeof v === "object" && v !== null && !Array.isArray(v) &&
+ v.constructor === Object;
+}
+
+// Array of record-like `LuaTables` to multi-row Markdown table
+async function renderLuaTableArrayToMarkdown(
+ tables: LuaTable[],
+ cellTransformer: (v: any, k: string) => Promise,
+): Promise {
+ const headerSet = new Set();
+ for (const tbl of tables) {
+ for (const k of tbl.keys()) headerSet.add(String(k));
+ }
+ if (headerSet.size === 0) return "*(empty table)*";
+ const headers = [...headerSet];
+
+ const lines: string[] = [
+ `|${headers.join("|")}|`,
+ `|${headers.map(() => "--").join("|")}|`,
+ ];
+ for (const tbl of tables) {
+ const cells: string[] = [];
+ for (const h of headers) {
+ const key = /^\d+$/.test(h) ? Number(h) : h;
+ const v = tbl.rawGet(key);
+ cells.push(await cellTransformer(v, h));
+ }
+ lines.push(`|${cells.join("|")}|`);
+ }
+ return lines.join("\n");
+}
+
/**
* Applies some heuristics to figure out if a string should be rendered as a markdown block or inline markdown
* @param s markdown string
diff --git a/client/space_lua/render_lua_html.ts b/client/space_lua/render_lua_html.ts
new file mode 100644
index 00000000..ad05b07b
--- /dev/null
+++ b/client/space_lua/render_lua_html.ts
@@ -0,0 +1,335 @@
+import { luaFormatNumber, LuaTable } from "../space_lua/runtime.ts";
+import { isTaggedFloat } from "../space_lua/numeric.ts";
+import { isSqlNull } from "../space_lua/query_collection.ts";
+
+function escapeHtml(s: string): string {
+ return s
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+// Render inline Markdown to HTML (caller provides the implementation)
+export type InlineRenderer = (text: string) => string;
+
+function defaultInlineRenderer(text: string): string {
+ return escapeHtml(text);
+}
+
+function luaTypeName(
+ v: any,
+): "number" | "string" | "boolean" | "table" | "array" | undefined {
+ if (v === undefined || v === null || isSqlNull(v)) return undefined;
+ if (typeof v === "number" || isTaggedFloat(v)) return "number";
+ if (typeof v === "string") return "string";
+ if (typeof v === "boolean") return "boolean";
+ if (v instanceof LuaTable) {
+ return hasStringKeys(v) ? "table" : "array";
+ }
+ if (Array.isArray(v)) return "array";
+ if (isPlainObject(v)) return "table";
+ return "string";
+}
+
+function isEmpty(v: any): boolean {
+ return v === undefined || v === null || isSqlNull(v);
+}
+
+function isPlainObject(v: any): v is Record {
+ return typeof v === "object" && v !== null && !Array.isArray(v) &&
+ v.constructor === Object;
+}
+
+function hasStringKeys(tbl: LuaTable): boolean {
+ for (const k of tbl.keys()) {
+ if (typeof k === "string") return true;
+ }
+ return false;
+}
+
+function formatScalar(v: any): string {
+ if (isEmpty(v)) return "";
+ if (isTaggedFloat(v)) return luaFormatNumber(v.value, "float");
+ if (typeof v === "number") return luaFormatNumber(v);
+ if (typeof v === "boolean") return v ? "true" : "false";
+ return `${v}`;
+}
+
+/**
+ * Render any Lua/JS value to an HTML string with data attributes.
+ * Also sets data-type and data-empty on the outermost element when
+ * used for widget display.
+ */
+export async function renderResultToHtml(
+ result: any,
+ renderInline: InlineRenderer = defaultInlineRenderer,
+): Promise<{
+ html: string;
+ dataType: string;
+}> {
+ if (isEmpty(result)) {
+ return {
+ html: ``,
+ dataType: "nil",
+ };
+ }
+ if (typeof result === "string") {
+ return {
+ html: renderInline(result),
+ dataType: "string",
+ };
+ }
+ if (isTaggedFloat(result)) {
+ return {
+ html: escapeHtml(luaFormatNumber(result.value, "float")),
+ dataType: "number",
+ };
+ }
+ if (typeof result === "number") {
+ return {
+ html: escapeHtml(luaFormatNumber(result)),
+ dataType: "number",
+ };
+ }
+ if (typeof result === "boolean") {
+ return {
+ html: result ? "true" : "false",
+ dataType: "boolean",
+ };
+ }
+ if (result instanceof LuaTable) {
+ if (result.empty()) {
+ return {
+ html: ``,
+ dataType: "table",
+ };
+ }
+ const html = await renderLuaTableToHtml(result, renderInline);
+ const type = hasStringKeys(result) ? "table" : "list";
+ return { html, dataType: type };
+ }
+ if (Array.isArray(result)) {
+ if (result.length === 0) {
+ return {
+ html: ``,
+ dataType: "table",
+ };
+ }
+ if (result.every(isPlainObject)) {
+ return {
+ html: await renderJsObjectArrayToHtml(result, renderInline),
+ dataType: "table",
+ };
+ }
+ return {
+ html: await renderJsArrayToHtml(result, renderInline),
+ dataType: "list",
+ };
+ }
+ if (isPlainObject(result)) {
+ if (Object.keys(result).length === 0) {
+ return {
+ html: ``,
+ dataType: "table",
+ };
+ }
+ return {
+ html: await renderJsObjectArrayToHtml([result], renderInline),
+ dataType: "table",
+ };
+ }
+ return {
+ html: renderInline(`${result}`),
+ dataType: "string",
+ };
+}
+
+async function renderLuaTableToHtml(
+ tbl: LuaTable,
+ renderInline: InlineRenderer,
+): Promise {
+ const keys = tbl.keys();
+ if (keys.length === 0) return ``;
+
+ const arrayLen = tbl.length;
+ const stringKeys: string[] = [];
+ for (const k of keys) {
+ if (typeof k === "string") stringKeys.push(k);
+ }
+
+ const hasArrayPart = arrayLen > 0;
+ const hasStrKeys = stringKeys.length > 0;
+
+ // Pure array (render as `` or multi-row ``)
+ if (hasArrayPart && !hasStrKeys) {
+ const elements: any[] = [];
+ for (let i = 1; i <= arrayLen; i++) elements.push(tbl.rawGet(i));
+ // If all elements are `LuaTables` then render as multi-row table
+ if (elements.every((el) => el instanceof LuaTable)) {
+ return renderLuaTableArrayToHtml(elements as LuaTable[], renderInline);
+ }
+ return renderArrayToHtml(elements, renderInline);
+ }
+
+ // Has string keys (record or mixed) - use `keys` order directly
+ const lines: string[] = [];
+ lines.push("");
+ lines.push("");
+ for (const k of keys) lines.push(`| ${escapeHtml(String(k))} | `);
+ lines.push("
");
+ lines.push("");
+ for (const k of keys) {
+ const v = tbl.rawGet(k);
+ lines.push(await renderTdHtml(v, renderInline));
+ }
+ lines.push("
");
+ lines.push("
");
+ return lines.join("");
+}
+
+// Array of record-like LuaTables to multi-row ``
+async function renderLuaTableArrayToHtml(
+ tables: LuaTable[],
+ renderInline: InlineRenderer,
+): Promise {
+ const headerSet = new Set();
+ for (const tbl of tables) {
+ for (const k of tbl.keys()) headerSet.add(String(k));
+ }
+ if (headerSet.size === 0) return ``;
+ const headers = [...headerSet];
+
+ const lines: string[] = [];
+ lines.push("");
+ lines.push("");
+ for (const h of headers) lines.push(`| ${escapeHtml(h)} | `);
+ lines.push("
");
+ lines.push("");
+ for (const tbl of tables) {
+ lines.push("");
+ for (const h of headers) {
+ const key = /^\d+$/.test(h) ? Number(h) : h;
+ const v = tbl.rawGet(key);
+ lines.push(await renderTdHtml(v, renderInline));
+ }
+ lines.push("
");
+ }
+ lines.push("");
+ lines.push("
");
+ return lines.join("");
+}
+
+async function renderJsObjectArrayToHtml(
+ jsonArray: Record[],
+ renderInline: InlineRenderer,
+): Promise {
+ const headerSet = new Set();
+ for (const entry of jsonArray) {
+ for (const k of Object.keys(entry)) headerSet.add(k);
+ }
+ if (headerSet.size === 0) return ``;
+ const headers = [...headerSet];
+
+ const lines: string[] = [];
+ lines.push("");
+ lines.push("");
+ for (const h of headers) lines.push(`| ${escapeHtml(h)} | `);
+ lines.push("
");
+ lines.push("");
+ for (const row of jsonArray) {
+ lines.push("");
+ for (const h of headers) {
+ lines.push(await renderTdHtml(row[h], renderInline));
+ }
+ lines.push("
");
+ }
+ lines.push("");
+ lines.push("
");
+ return lines.join("");
+}
+
+async function renderArrayToHtml(
+ items: any[],
+ renderInline: InlineRenderer,
+): Promise {
+ const lines: string[] = [];
+ lines.push("");
+ for (const item of items) {
+ lines.push(await renderLiHtml(item, renderInline));
+ }
+ lines.push("
");
+ return lines.join("");
+}
+
+function renderJsArrayToHtml(
+ items: any[],
+ renderInline: InlineRenderer,
+): Promise {
+ return renderArrayToHtml(items, renderInline);
+}
+
+async function renderTdHtml(
+ v: any,
+ renderInline: InlineRenderer,
+): Promise {
+ const type = luaTypeName(v);
+ const attrs: string[] = [];
+ if (type) {
+ attrs.push(`data-table-cell-type="${type}"`);
+ }
+ if (isEmpty(v)) {
+ attrs.push("data-table-cell-empty");
+ }
+ const attrStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
+ const content = await renderCellContent(v, renderInline);
+ return `${content} | `;
+}
+
+async function renderLiHtml(
+ v: any,
+ renderInline: InlineRenderer,
+): Promise {
+ const type = luaTypeName(v);
+ const attrs: string[] = [];
+ if (type) {
+ attrs.push(`data-list-item-type="${type}"`);
+ }
+ if (isEmpty(v)) {
+ attrs.push("data-list-item-empty");
+ }
+ const attrStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
+ const content = await renderCellContent(v, renderInline);
+ return `- ${content}
`;
+}
+
+// Render a value content (recurse for nested tables/arrays)
+function renderCellContent(
+ v: any,
+ renderInline: InlineRenderer,
+): Promise {
+ if (isEmpty(v)) return Promise.resolve("");
+ if (isTaggedFloat(v)) {
+ return Promise.resolve(renderInline(luaFormatNumber(v.value, "float")));
+ }
+ if (v instanceof LuaTable) {
+ if (v.empty()) return Promise.resolve(``);
+ return renderLuaTableToHtml(v, renderInline);
+ }
+ if (Array.isArray(v)) {
+ if (v.length === 0) {
+ return Promise.resolve(``);
+ }
+ if (v.every(isPlainObject)) {
+ return renderJsObjectArrayToHtml(v, renderInline);
+ }
+ return renderArrayToHtml(v, renderInline);
+ }
+ if (isPlainObject(v)) {
+ if (Object.keys(v).length === 0) {
+ return Promise.resolve(``);
+ }
+ return renderJsObjectArrayToHtml([v], renderInline);
+ }
+ return Promise.resolve(renderInline(formatScalar(v)));
+}
diff --git a/libraries/Library/Std/Widgets/Widgets.md b/libraries/Library/Std/Widgets/Widgets.md
index ef5dac4f..01e81916 100644
--- a/libraries/Library/Std/Widgets/Widgets.md
+++ b/libraries/Library/Std/Widgets/Widgets.md
@@ -80,6 +80,27 @@ end
```
## Table of contents
+```space-style
+.sb-toc-summary {
+ cursor: pointer;
+ font-weight: bold;
+ user-select: none;
+ padding: 15px 10px;
+ margin: -10px;
+ background-color: var(--editor-widget-background-color);
+}
+.sb-toc-content {
+ padding-top: 0.5rem;
+}
+.sb-toc-item {
+ padding: 0.1rem 0;
+}
+.sb-toc-link {
+ cursor: pointer;
+ text-decoration: none;
+}
+```
+
```space-lua
-- priority: 10
widgets = widgets or {}
@@ -102,9 +123,14 @@ config.set("std.widgets.toc", {
function widgets.toc(options)
options = options or config.get("std.widgets.toc")
options.minHeaders = options.minHeaders or 3
+ options.minLevel = options.minLevel or 1
+ options.header = options.header or "Table of Contents"
+ local defaultOpen = (options.defaultOpen ~= false) or nil
+
local text = editor.getText()
local pageName = editor.getCurrentPage()
local parsedMarkdown = markdown.parseMarkdown(text)
+
-- Collect all headers
local headers = {}
for topLevelChild in parsedMarkdown.children do
@@ -129,27 +155,57 @@ function widgets.toc(options)
end
end
end
+
if options.minHeaders and options.minHeaders > #headers then
return widget.new{}
end
+
+ -- Filter headers to display
+ local headersToDisplay = {}
+ for _, header in ipairs(headers) do
+ if not (options.maxHeader and header.level > options.maxHeader or
+ header.level < options.minLevel) then
+ table.insert(headersToDisplay, header)
+ end
+ end
+
-- Find min level
local minLevel = 6
- for _, header in ipairs(headers) do
- if header.level < minLevel then
- minLevel = header.level
- end
+ for _, header in ipairs(headersToDisplay) do
+ minLevel = math.min(minLevel, header.level)
end
- -- Build up markdown
- local md = (options.header or "# Table of Contents") .. "\n"
- for _, header in ipairs(headers) do
- if not(options.maxHeader and header.level > options.maxHeader or
- options.minLevel and header.level < options.minLevel) then
- md = md .. string.rep(" ", (header.level - minLevel) * 2) ..
- "* [[" .. pageName .. "@" .. header.pos .. "|" .. header.name .. "]]\n"
- end
+
+ -- Build TOC entries as DOM elements
+ local tocItems = {}
+ for _, header in ipairs(headersToDisplay) do
+ local indent = (header.level - minLevel) * 1.5
+ table.insert(tocItems, dom.div {
+ class = "sb-toc-item",
+ style = "margin-left: " .. indent .. "rem;",
+ dom.a {
+ onclick = function()
+ editor.navigate({ page = pageName, pos = header.pos })
+ end,
+ class = "sb-toc-link",
+ "- " .. header.name
+ }
+ })
end
+
+ -- Wrap in a element for native show/hide toggle
return widget.new {
- markdown = md
+ html = dom.details {
+ open = defaultOpen,
+ dom.summary {
+ class = "sb-toc-summary",
+ options.header
+ },
+ dom.div {
+ class = "sb-toc-content",
+ table.unpack(tocItems)
+ }
+ },
+ display = "block"
}
end
```
diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml
index faa9a520..c5c82594 100644
--- a/plugs/editor/editor.plug.yaml
+++ b/plugs/editor/editor.plug.yaml
@@ -379,12 +379,6 @@ functions:
mac: "Cmd-f"
requireEditor: page
- # Outline helper functions
- determineItemBounds:
- path: ./outline.ts:determineItemBounds
- ensureNewLine:
- path: ./outline.ts:ensureNewLine
-
# Undo/redo
undoCommand:
path: ./editor.ts:undoCommand
diff --git a/plugs/editor/outline.test.ts b/plugs/editor/outline.test.ts
new file mode 100644
index 00000000..08e816db
--- /dev/null
+++ b/plugs/editor/outline.test.ts
@@ -0,0 +1,778 @@
+import { describe, expect, test } from "vitest";
+import {
+ type CursorContext,
+ detectContext,
+ indent,
+ moveDown,
+ moveUp,
+ outdent,
+ type OutlineResult,
+} from "./outline_ops.ts";
+import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
+
+const CURSOR = "|^|";
+
+function applyOp(
+ op: (text: string, cursor: number) => OutlineResult,
+ input: string,
+): string {
+ const pos = input.indexOf(CURSOR);
+ const clean = input.slice(0, pos) + input.slice(pos + CURSOR.length);
+ const result = op(clean, pos);
+ if (result === null || result === "blocked") {
+ return input; // no-op returns unchanged
+ }
+ return result.text.slice(0, result.cursor) + CURSOR +
+ result.text.slice(result.cursor);
+}
+
+function detect(marked: string): CursorContext | null {
+ const pos = marked.indexOf(CURSOR);
+ const text = marked.slice(0, pos) + marked.slice(pos + CURSOR.length);
+ const tree = parseMarkdown(text);
+ return detectContext(tree, pos);
+}
+
+// Bullet Lists: Move Up/Down
+describe("Bullet list move up/down", () => {
+ test("swap two flat bullets", () => {
+ expect(applyOp(
+ moveUp,
+ `- first
+- sec|^|ond
+`,
+ )).toEqual(`- sec|^|ond
+- first
+`,);
+ expect(applyOp(
+ moveDown,
+ `- fir|^|st
+- second
+`,
+ )).toEqual(`- second
+- fir|^|st
+`,);
+ });
+
+ test("boundary bullet is no-op", () => {
+ const upInput = `- fir|^|st
+- second
+`;
+ expect(applyOp(moveUp, upInput)).toEqual(upInput);
+ const downInput = `- first
+- sec|^|ond
+`;
+ expect(applyOp(moveDown, downInput)).toEqual(downInput);
+ });
+
+ test("item with children moves as unit", () => {
+ expect(applyOp(
+ moveUp,
+ `- first
+- sec|^|ond
+ - child
+`,
+ )).toEqual(`- sec|^|ond
+ - child
+- first
+`,);
+ expect(applyOp(
+ moveDown,
+ `- fir|^|st
+ - child
+- second
+`,
+ )).toEqual(`- second
+- fir|^|st
+ - child
+`,);
+ });
+
+ test("nested child: boundary no-op and swap", () => {
+ const upBoundary = `- parent
+ - child |^|one
+ - child two
+`;
+ expect(applyOp(moveUp, upBoundary)).toEqual(upBoundary);
+ const downBoundary = `- parent
+ - child one
+ - child |^|two
+`;
+ expect(applyOp(moveDown, downBoundary)).toEqual(downBoundary);
+
+ expect(applyOp(
+ moveUp,
+ `- parent
+ - child one
+ - child |^|two
+`,
+ )).toEqual(`- parent
+ - child |^|two
+ - child one
+`,);
+ expect(applyOp(
+ moveDown,
+ `- parent
+ - child |^|one
+ - child two
+`,
+ )).toEqual(`- parent
+ - child two
+ - child |^|one
+`,);
+ });
+
+ test("three items, swap adjacent", () => {
+ expect(applyOp(
+ moveUp,
+ `- first
+- second
+- th|^|ird
+`,
+ )).toEqual(`- first
+- th|^|ird
+- second
+`,);
+ expect(applyOp(
+ moveDown,
+ `- fir|^|st
+- second
+- third
+`,
+ )).toEqual(`- second
+- fir|^|st
+- third
+`,);
+ });
+});
+
+// Bullet Lists: Indent/Outdent
+describe("Bullet list indent/outdent", () => {
+ test("indent then outdent flat bullet", () => {
+ const flat = `- first
+- sec|^|ond
+`;
+ const nested = `- first
+ - sec|^|ond
+`;
+ expect(applyOp(indent, flat)).toEqual(nested);
+ expect(applyOp(outdent, nested)).toEqual(flat);
+ });
+
+ test("boundary no-ops", () => {
+ const input = `- fir|^|st
+- second
+`;
+ expect(applyOp(indent, input)).toEqual(input);
+ expect(applyOp(outdent, input)).toEqual(input);
+ });
+
+ test("with children", () => {
+ const flat = `- first
+- sec|^|ond
+ - child
+`;
+ const nested = `- first
+ - sec|^|ond
+ - child
+`;
+ expect(applyOp(indent, flat)).toEqual(nested);
+ expect(applyOp(outdent, nested)).toEqual(flat);
+ });
+
+ test("already nested item indents further", () => {
+ expect(applyOp(
+ indent,
+ `- first
+ - sec|^|ond
+`,
+ )).toEqual(`- first
+ - sec|^|ond
+`,);
+ });
+
+ test("deeply nested item outdents one level", () => {
+ expect(applyOp(
+ outdent,
+ `- first
+ - second
+ - th|^|ird
+`,
+ )).toEqual(`- first
+ - second
+ - th|^|ird
+`,);
+ });
+});
+
+// Headers: Move Up/Down
+describe("Heading move up/down", () => {
+ test("swap two h2 sections", () => {
+ expect(applyOp(
+ moveUp,
+ `## First
+Content one
+## Sec|^|ond
+Content two
+`,
+ )).toEqual(`## Sec|^|ond
+Content two
+## First
+Content one
+`,);
+ expect(applyOp(
+ moveDown,
+ `## Fir|^|st
+Content one
+## Second
+Content two
+`,
+ )).toEqual(`## Second
+Content two
+## Fir|^|st
+Content one
+`,);
+ });
+
+ test("boundary heading is no-op", () => {
+ const upInput = `## Fir|^|st
+Content
+## Second
+`;
+ expect(applyOp(moveUp, upInput)).toEqual(upInput);
+ const downInput = `## First
+## Sec|^|ond
+`;
+ expect(applyOp(moveDown, downInput)).toEqual(downInput);
+ });
+
+ test("h2 with sub-headings moves entire section", () => {
+ expect(applyOp(
+ moveUp,
+ `## First
+### Sub one
+## Sec|^|ond
+### Sub two
+`,
+ )).toEqual(`## Sec|^|ond
+### Sub two
+## First
+### Sub one
+`,);
+ expect(applyOp(
+ moveDown,
+ `## Fir|^|st
+### Sub one
+## Second
+### Sub two
+`,
+ )).toEqual(`## Second
+### Sub two
+## Fir|^|st
+### Sub one
+`,);
+ });
+
+ test("h3 swaps only within parent h2 scope", () => {
+ expect(applyOp(
+ moveUp,
+ `## Parent
+### Sub one
+### Sub |^|two
+`,
+ )).toEqual(`## Parent
+### Sub |^|two
+### Sub one
+`,);
+ });
+
+ test("headings with no body text", () => {
+ expect(applyOp(
+ moveUp,
+ `## First
+## Sec|^|ond
+`,
+ )).toEqual(`## Sec|^|ond
+## First
+`,);
+ });
+
+ test("no trailing newline preserves content", () => {
+ expect(applyOp(
+ moveUp,
+ `## First
+Body
+## Sec|^|ond
+End`,
+ )).toEqual(`## Sec|^|ond
+End
+## First
+Body`,);
+ expect(applyOp(
+ moveDown,
+ `## Fir|^|st
+Body
+## Second
+End`,
+ )).toEqual(`## Second
+End
+## Fir|^|st
+Body`,);
+ });
+
+ test("empty body section swap preserves structure", () => {
+ expect(applyOp(
+ moveDown,
+ `## |^|A
+## B
+Body B
+`,
+ )).toEqual(`## B
+Body B
+## |^|A
+`,);
+ });
+
+ test("no trailing newline, first has no body", () => {
+ expect(applyOp(
+ moveDown,
+ `## |^|A
+## B
+Body`,
+ )).toEqual(`## B
+Body
+## |^|A`,);
+ });
+
+ test("roundtrip: move down then up returns to original", () => {
+ const original = `## First
+Body 1a
+Body 1b
+## Second
+Body 2a
+`;
+ const after = moveDown(original, 4);
+ if (after === null || after === "blocked") {
+ throw new Error(`moveDown returned ${after}`);
+ }
+ const back = moveUp(after.text, after.cursor);
+ if (back === null || back === "blocked") {
+ throw new Error(`moveUp returned ${back}`);
+ }
+ expect(back.text).toEqual(original);
+ });
+});
+
+// Headers: Indent/Outdent
+describe("Heading indent/outdent", () => {
+ test("basic", () => {
+ const h2 = `## He|^|ading
+`;
+ const h3 = `### He|^|ading
+`;
+ expect(applyOp(indent, h2)).toEqual(h3);
+ expect(applyOp(outdent, h3)).toEqual(h2);
+ });
+
+ test("level limit no-ops", () => {
+ const h6 = `###### He|^|ading
+`;
+ expect(applyOp(indent, h6)).toEqual(h6);
+ const h1 = `# He|^|ading
+`;
+ expect(applyOp(outdent, h1)).toEqual(h1);
+ });
+
+ test("with sub-headings", () => {
+ const shallow = `## Ma|^|in
+### Sub
+`;
+ const deep = `### Ma|^|in
+#### Sub
+`;
+ expect(applyOp(indent, shallow)).toEqual(deep);
+ expect(applyOp(outdent, deep)).toEqual(shallow);
+ });
+});
+
+// Paragraphs: Move Up/Down
+describe("Paragraph operations", () => {
+ test("swap two consecutive paragraphs", () => {
+ expect(applyOp(
+ moveUp,
+ `First para.
+
+Second |^|para.
+`,
+ )).toEqual(`Second |^|para.
+
+First para.
+`,);
+ });
+
+ test("boundary no-ops", () => {
+ const upInput = `First |^|para.
+
+Second para.
+`;
+ expect(applyOp(moveUp, upInput)).toEqual(upInput);
+ const downInput = `First para.
+
+Second |^|para.
+`;
+ expect(applyOp(moveDown, downInput)).toEqual(downInput);
+ });
+
+ test("moves past an adjacent list block", () => {
+ expect(applyOp(
+ moveDown,
+ `Some |^|text.
+
+- item one
+- item two
+`,
+ )).toEqual(`- item one
+- item two
+
+Some |^|text.
+`,);
+ });
+});
+
+// Ordered Lists: all operations
+describe("Ordered list operations", () => {
+ test("move up", () => {
+ expect(applyOp(
+ moveUp,
+ `1. first
+2. sec|^|ond
+`,
+ )).toEqual(`1. sec|^|ond
+2. first
+`,);
+ });
+
+ test("move down", () => {
+ expect(applyOp(
+ moveDown,
+ `1. fir|^|st
+2. second
+`,
+ )).toEqual(`1. second
+2. fir|^|st
+`,);
+ });
+
+ test("indent", () => {
+ expect(applyOp(
+ indent,
+ `1. first
+2. sec|^|ond
+`,
+ )).toEqual(`1. first
+ 2. sec|^|ond
+`,);
+ });
+
+ test("outdent", () => {
+ expect(applyOp(
+ outdent,
+ `1. first
+ 2. sec|^|ond
+`,
+ )).toEqual(`1. first
+2. sec|^|ond
+`,);
+ });
+});
+
+// Edge Cases
+describe("Edge cases", () => {
+ test("empty and single-item no-ops", () => {
+ const empty = `|^|`;
+ expect(applyOp(moveUp, empty)).toEqual(empty);
+ expect(applyOp(moveDown, empty)).toEqual(empty);
+ expect(applyOp(indent, empty)).toEqual(empty);
+ expect(applyOp(outdent, empty)).toEqual(empty);
+
+ const single = `- on|^|ly
+`;
+ expect(applyOp(moveUp, single)).toEqual(single);
+ expect(applyOp(moveDown, single)).toEqual(single);
+ });
+
+ describe("nested boundary no-ops", () => {
+ test("move down last item with sub-items", () => {
+ const input = "- a\n- b|^|\n - sub1\n - sub2";
+ expect(applyOp(moveDown, input)).toEqual(input);
+ });
+
+ test("move down last nested item", () => {
+ const input = "- a\n- b\n - sub1\n - sub2|^|";
+ expect(applyOp(moveDown, input)).toEqual(input);
+ });
+
+ test("move up first item with sub-items", () => {
+ const input = "- a|^|\n - sub\n- b";
+ expect(applyOp(moveUp, input)).toEqual(input);
+ });
+
+ test("cursor at end of last nested item blocks move down", () => {
+ const input = "- a\n- b\n - sub1\n - sub2|^|\n- c";
+ expect(applyOp(moveDown, input)).toEqual(input);
+ });
+ });
+
+ test("cursor on leading whitespace of nested item is no-op", () => {
+ const input = " - a\n - b\n - sub1\n |^| - sub2\n - c";
+ expect(applyOp(moveDown, input)).toEqual(input);
+ });
+
+ test("cursor at end of nested item moves within nested list", () => {
+ expect(applyOp(moveDown, "- a\n- b\n - sub1|^|\n - sub2\n- c")).toEqual("- a\n- b\n - sub2\n - sub1|^|\n- c",);
+ });
+});
+
+// Context Detection
+describe("Cursor positions in bullet list", () => {
+ test("on list marker", () => {
+ const ctx = detect("|^|- one\n- two\n");
+ expect(ctx?.type).toEqual("listItem");
+ expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("BulletList");
+ });
+
+ test("on space after marker", () => {
+ expect(detect("-|^| one\n- two\n")?.type).toEqual("listItem");
+ });
+
+ test("on newline between items", () => {
+ expect(detect("- one|^|\n- two\n")?.type).toEqual("listItem");
+ });
+
+ test("at last char of item text", () => {
+ const ctx = detect("- on|^|e\n- two\n");
+ expect(ctx?.type).toEqual("listItem");
+ if (ctx?.type === "listItem") {
+ expect(ctx.itemIndex).toEqual(0);
+ }
+ });
+
+ test("past end of document", () => {
+ expect(detect("- one\n- two\n|^|")).toEqual(null);
+ });
+});
+
+describe("Cursor positions on heading", () => {
+ test("on heading marker", () => {
+ const ctx = detect("|^|## Heading\nBody\n");
+ expect(ctx?.type).toEqual("heading");
+ if (ctx?.type === "heading") {
+ expect(ctx.level).toEqual(2);
+ }
+ });
+
+ test("on newline after heading", () => {
+ expect(detect("## Heading|^|\nBody\n")?.type).toEqual("heading");
+ });
+});
+
+describe("Context detection edge cases", () => {
+ test("code block returns null", () => {
+ expect(detect("```\n|^|some code\n```\n")).toEqual(null);
+ });
+
+ test("frontmatter returns null", () => {
+ expect(detect("---\n|^|title: Test\n---\nContent\n")).toEqual(null);
+ });
+
+ test("between paragraphs resolves to preceding paragraph", () => {
+ expect(detect("Para one.|^|\n\nPara two.\n")?.type).toEqual("paragraph");
+ expect(detect("Para one.\n|^|\nPara two.\n")).toEqual(null);
+ });
+
+ test("paragraph inside list item detects list item", () => {
+ expect(detect("- ite|^|m text\n")?.type).toEqual("listItem");
+ });
+
+ test("heading section boundaries are correct", () => {
+ const ctx = detect("## |^|H2a\nBody\n### H3\nSub\n## H2b\n");
+ expect(ctx?.type).toEqual("heading");
+ if (ctx?.type === "heading") {
+ expect(ctx.level).toEqual(2);
+ const types = ctx.doc.children!.slice(ctx.sectionStart, ctx.sectionEnd)
+ .filter((c) => c.type).map((c) => c.type);
+ expect(types).toEqual([
+ "ATXHeading2",
+ "Paragraph",
+ "ATXHeading3",
+ "Paragraph",
+ ]);
+ }
+ });
+
+ test("deeply nested list item detects innermost list", () => {
+ const ctx = detect("- parent\n - child\n - gra|^|ndchild\n");
+ expect(ctx?.type).toEqual("listItem");
+ if (ctx?.type === "listItem") {
+ expect(ctx.list.type).toEqual("BulletList");
+ expect(ctx.item.from).toEqual(23);
+ }
+ });
+
+ test("paragraph after heading detects paragraph", () => {
+ expect(detect("## Heading\nBody|^| text here.\n")?.type).toEqual("paragraph",);
+ });
+
+ test("single heading with no following content", () => {
+ const ctx = detect("## On|^|ly heading\n");
+ expect(ctx?.type).toEqual("heading");
+ if (ctx?.type === "heading") {
+ expect(ctx.sectionStart).toEqual(0);
+ expect(ctx.sectionEnd).toEqual(ctx.doc.children!.length);
+ }
+ });
+});
+
+describe("List marker variants", () => {
+ test("ordered list", () => {
+ const ctx = detect("|^|1. first\n2. second\n");
+ expect(ctx?.type).toEqual("listItem");
+ expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("OrderedList");
+ });
+
+ test("* bullet", () => {
+ const ctx = detect("|^|* item one\n* item two\n");
+ expect(ctx?.type).toEqual("listItem");
+ expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("BulletList");
+ });
+
+ test("blockquote list", () => {
+ expect(detect("> - |^|item one\n> - item two\n")?.type).toEqual("listItem");
+ });
+});
+
+// Table Rows: Move Up/Down
+describe("Table row move up/down", () => {
+ test("swap two data rows", () => {
+ expect(applyOp(
+ moveUp,
+ `| A | B |
+| --- | --- |
+| 1 | 2 |
+| 3|^| | 4 |
+`,
+ )).toEqual(`| A | B |
+| --- | --- |
+| 3|^| | 4 |
+| 1 | 2 |
+`,);
+ expect(applyOp(
+ moveDown,
+ `| A | B |
+| --- | --- |
+| 1|^| | 2 |
+| 3 | 4 |
+`,
+ )).toEqual(`| A | B |
+| --- | --- |
+| 3 | 4 |
+| 1|^| | 2 |
+`,);
+ });
+
+ test("first data row can't move up", () => {
+ const input = `| A | B |
+| --- | --- |
+| 1|^| | 2 |
+| 3 | 4 |
+`;
+ expect(applyOp(moveUp, input)).toEqual(input);
+ });
+
+ test("last data row can't move down", () => {
+ const input = `| A | B |
+| --- | --- |
+| 1 | 2 |
+| 3|^| | 4 |
+`;
+ expect(applyOp(moveDown, input)).toEqual(input);
+ });
+
+ test("header row is not movable", () => {
+ const input = `| A|^| | B |
+| --- | --- |
+| 1 | 2 |
+`;
+ expect(applyOp(moveUp, input)).toEqual(input);
+ expect(applyOp(moveDown, input)).toEqual(input);
+ });
+
+ test("three rows, swap middle", () => {
+ expect(applyOp(
+ moveUp,
+ `| H1 | H2 |
+| --- | --- |
+| a | b |
+| c|^| | d |
+| e | f |
+`,
+ )).toEqual(`| H1 | H2 |
+| --- | --- |
+| c|^| | d |
+| a | b |
+| e | f |
+`,);
+ expect(applyOp(
+ moveDown,
+ `| H1 | H2 |
+| --- | --- |
+| a | b |
+| c|^| | d |
+| e | f |
+`,
+ )).toEqual(`| H1 | H2 |
+| --- | --- |
+| a | b |
+| e | f |
+| c|^| | d |
+`,);
+ });
+
+ test("indent/outdent on table row is no-op", () => {
+ const input = `| A | B |
+| --- | --- |
+| 1|^| | 2 |
+`;
+ expect(applyOp(indent, input)).toEqual(input);
+ expect(applyOp(outdent, input)).toEqual(input);
+ });
+});
+
+// Table Rows: Context Detection
+describe("Table row context detection", () => {
+ test("cursor in data row detects tableRow", () => {
+ const ctx = detect(`| A | B |
+| --- | --- |
+| 1|^| | 2 |
+`);
+ expect(ctx?.type).toEqual("tableRow");
+ });
+
+ test("cursor in header row detects tableRow with isHeader", () => {
+ const ctx = detect(`| A|^| | B |
+| --- | --- |
+| 1 | 2 |
+`);
+ expect(ctx?.type).toEqual("tableRow");
+ if (ctx?.type === "tableRow") {
+ expect(ctx.isHeader).toEqual(true);
+ }
+ });
+
+ test("cursor on delimiter row returns null", () => {
+ expect(detect(`| A | B |
+| --|^|- | --- |
+| 1 | 2 |
+`)).toEqual(null,);
+ });
+});
diff --git a/plugs/editor/outline.ts b/plugs/editor/outline.ts
index 23811f5c..4a5e8df1 100644
--- a/plugs/editor/outline.ts
+++ b/plugs/editor/outline.ts
@@ -1,288 +1,50 @@
import { editor } from "@silverbulletmd/silverbullet/syscalls";
+import {
+ indent as indentOp,
+ moveDown as moveDownOp,
+ moveUp as moveUpOp,
+ outdent as outdentOp,
+} from "./outline_ops.ts";
-export async function moveItemUp() {
+async function applyOutlineOp(
+ op: (
+ text: string,
+ cursor: number,
+ ) => { text: string; cursor: number } | "blocked" | null,
+ fallback?: () => void,
+) {
const cursorPos = await editor.getCursor();
const text = await editor.getText();
-
- try {
- let currentItemBounds: ReturnType | undefined;
- try {
- currentItemBounds = determineItemBounds(text, cursorPos);
- } catch {
- // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list,
- // so we fall back to `moveLineUp()`
- void editor.moveLineUp();
- return;
- }
- let previousItemBounds: ReturnType | undefined;
-
- try {
- previousItemBounds = determineItemBounds(
- text,
- currentItemBounds.from - 1,
- currentItemBounds.indentLevel,
- );
- if (currentItemBounds.from === previousItemBounds.from) {
- throw new Error("Already at the top");
- }
- } catch {
- // Ok, top of the list, let's find the previous item at any other indent level and adapt
- previousItemBounds = determineItemBounds(
- text,
- currentItemBounds.from - 1,
- );
- }
-
- let newPreviousText = text.slice(
- previousItemBounds.from,
- previousItemBounds.to,
- );
- // If the current item is embedded inside the previous item, we need to strip it out
- if (
- currentItemBounds.from >= previousItemBounds.from &&
- currentItemBounds.to <= previousItemBounds.to
- ) {
- newPreviousText =
- text.slice(previousItemBounds.from, currentItemBounds.from) +
- text.slice(currentItemBounds.to, previousItemBounds.to);
- }
-
- const newText =
- ensureNewLine(text.slice(currentItemBounds.from, currentItemBounds.to)) +
- newPreviousText;
- const newCursorPos = (cursorPos - currentItemBounds.from) +
- previousItemBounds.from;
-
- // console.log("New replacement text", newText);
-
- await editor.dispatch({
- changes: [
- {
- from: Math.min(previousItemBounds.from, currentItemBounds.from),
- to: Math.max(currentItemBounds.to, previousItemBounds.to),
- insert: newText,
- },
- ],
- selection: {
- anchor: newCursorPos,
- },
- });
- } catch (e: any) {
- await editor.flashNotification(e.message, "error");
+ const result = op(text, cursorPos);
+ if (result === "blocked") {
+ await editor.flashNotification("Cannot move item further", "error");
+ return;
}
+ if (result === null) {
+ if (fallback) {
+ fallback();
+ }
+ return;
+ }
+
+ await editor.setText(result.text, true);
+ await editor.moveCursor(result.cursor);
+}
+
+export async function moveItemUp() {
+ await applyOutlineOp(moveUpOp, () => editor.moveLineUp());
}
export async function moveItemDown() {
- const cursorPos = await editor.getCursor();
- const text = await editor.getText();
-
- try {
- let currentItemBounds: ReturnType | undefined;
- try {
- currentItemBounds = determineItemBounds(text, cursorPos);
- } catch {
- // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list,
- // so we fall back to `moveLineDown()`
- void editor.moveLineDown();
- return;
- }
- let nextItemBounds: ReturnType | undefined;
- try {
- nextItemBounds = determineItemBounds(
- text,
- currentItemBounds.to + 1,
- currentItemBounds.indentLevel,
- );
-
- if (currentItemBounds.from === nextItemBounds.from) {
- throw new Error("Already at the bottom");
- }
- } catch {
- nextItemBounds = determineItemBounds(
- text,
- currentItemBounds.to + 1,
- undefined,
- false,
- );
- }
-
- if (currentItemBounds.to === nextItemBounds.to) {
- throw new Error("Already at the bottom");
- }
-
- const nextItemText = ensureNewLine(
- text.slice(nextItemBounds.from, nextItemBounds.to),
- );
- // console.log("Next item text", nextItemText);
- const newText = nextItemText +
- text.slice(currentItemBounds.from, currentItemBounds.to);
- const newCursorPos = (cursorPos - currentItemBounds.from) +
- currentItemBounds.from + nextItemText.length;
- await editor.dispatch({
- changes: [
- {
- from: Math.min(nextItemBounds.from, currentItemBounds.from),
- to: Math.max(nextItemBounds.to, currentItemBounds.to),
- insert: newText,
- },
- ],
- selection: {
- anchor: newCursorPos,
- },
- });
- } catch (e: any) {
- await editor.flashNotification(e.message, "error");
- }
+ await applyOutlineOp(moveDownOp, () => editor.moveLineDown());
}
export async function indentItem() {
- const cursorPos = await editor.getCursor();
- const text = await editor.getText();
-
- try {
- const currentItemBounds = determineItemBounds(text, cursorPos);
- const itemText = text.slice(currentItemBounds.from, currentItemBounds.to);
- const newText = itemText.split("\n").map((line) =>
- line ? ` ${line}` : line
- ).join("\n");
- const preText = text.slice(currentItemBounds.from, cursorPos);
- const newCursorPos = cursorPos + preText.split("\n").length * 2;
- await editor.dispatch({
- changes: [
- {
- from: currentItemBounds.from,
- to: currentItemBounds.to,
- insert: newText,
- },
- ],
- selection: {
- anchor: newCursorPos,
- },
- });
- } catch (e: any) {
- await editor.flashNotification(e.message, "error");
- }
+ await applyOutlineOp(indentOp);
}
export async function outdentItem() {
- const cursorPos = await editor.getCursor();
- const text = await editor.getText();
-
- try {
- const currentItemBounds = determineItemBounds(text, cursorPos);
- const itemText = text.slice(currentItemBounds.from, currentItemBounds.to);
- if (!itemText.startsWith(" ")) {
- throw new Error("Cannot outdent further");
- }
- const newText = itemText.split("\n").map((line) =>
- line.startsWith(" ") ? line.substring(2) : line
- ).join("\n");
- const preText = text.slice(currentItemBounds.from, cursorPos);
- const newCursorPos = cursorPos - preText.split("\n").length * 2;
- await editor.dispatch({
- changes: [
- {
- from: currentItemBounds.from,
- to: currentItemBounds.to,
- insert: newText,
- },
- ],
- selection: {
- anchor: newCursorPos,
- },
- });
- } catch (e: any) {
- await editor.flashNotification(e.message, "error");
- }
-}
-
-export function ensureNewLine(s: string) {
- if (!s.endsWith("\n")) {
- return `${s}\n`;
- } else {
- return s;
- }
-}
-
-export function determineItemBounds(
- text: string,
- pos: number,
- minIndentLevel?: number,
- withChildren = true,
-): { from: number; to: number; indentLevel: number } {
- // Find the start of the item marked with a bullet
- let currentItemStart = pos;
- let indentLevel = 0;
- while (true) {
- while (currentItemStart > 0 && text[currentItemStart - 1] !== "\n") {
- currentItemStart--;
- }
- // Check if the line is a bullet and determine the indent level
- indentLevel = 0;
- while (text[currentItemStart + indentLevel] === " ") {
- indentLevel++;
- }
- if (minIndentLevel !== undefined && indentLevel < minIndentLevel) {
- throw new Error("No item found at minimum indent level");
- }
- if (minIndentLevel !== undefined && indentLevel > minIndentLevel) {
- // Not at the desired indent level yet, let's go up another line
- currentItemStart--;
- if (currentItemStart <= 0) {
- // We've reached the top of the document, no bullet found
- throw new Error("No item found");
- }
- continue;
- }
- if (["-", "*"].includes(text[currentItemStart + indentLevel])) {
- // This is a bullet line, found it, let's break out of this loop
- break;
- } else {
- // Not a bullet line, let's go up another line
- currentItemStart--;
- if (currentItemStart <= 0) {
- // We've reached the top of the document, no bullet found
- throw new Error("No item found");
- }
- }
- }
-
- // Ok, so at this point we have determine the starting point of our item
- // Relevant variables are currentItemStart and indentLevel
- // Now let's find the end point
- let currentItemEnd = currentItemStart + 1;
- while (true) {
- // Let's traverse to the end of the line
- while (currentItemEnd < text.length && text[currentItemEnd - 1] !== "\n") {
- currentItemEnd++;
- }
- if (!withChildren) {
- // We're not interested in the children, so let's stop here
- break;
- }
- // Check the indent level of the next line
- let nextIndentLevel = 0;
- while (text[currentItemEnd + nextIndentLevel] === " ") {
- nextIndentLevel++;
- }
- if (nextIndentLevel <= indentLevel) {
- // This is a line indentend less than the current item, found it, let's break out of this loop
- break;
- } else {
- // Not a bullet line, let's go up another line
- currentItemEnd++;
- if (currentItemEnd >= text.length) {
- // End of the document, mark this as the end of the item
- currentItemEnd = text.length - 1;
- break;
- }
- }
- }
- return {
- from: currentItemStart,
- to: currentItemEnd,
- indentLevel,
- };
+ await applyOutlineOp(outdentOp);
}
export async function foldCommand() {
diff --git a/plugs/editor/outline_ops.ts b/plugs/editor/outline_ops.ts
new file mode 100644
index 00000000..1e838ca6
--- /dev/null
+++ b/plugs/editor/outline_ops.ts
@@ -0,0 +1,680 @@
+import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
+import {
+ addParentPointers,
+ findParentMatching,
+ nodeAtPos,
+ type ParseTree,
+ renderToText,
+} from "@silverbulletmd/silverbullet/lib/tree";
+
+export type OutlineResult =
+ | { text: string; cursor: number }
+ | "blocked"
+ | null;
+
+export type ListContext = {
+ type: "listItem";
+ item: ParseTree;
+ itemIndex: number; // index in parent.children (includes separator text nodes)
+ list: ParseTree; // BulletList or OrderedList
+};
+
+export type HeadingContext = {
+ type: "heading";
+ level: number;
+ // Section: range of Document.children indices [start, end) for this heading section
+ sectionStart: number; // index in Document.children
+ sectionEnd: number; // exclusive index in Document.children
+ doc: ParseTree;
+};
+
+export type ParagraphContext = {
+ type: "paragraph";
+ blockIndex: number; // index in Document.children
+ doc: ParseTree;
+};
+
+export type TableRowContext = {
+ type: "tableRow";
+ row: ParseTree; // TableRow or TableHeader node
+ rowIndex: number; // index in Table.children
+ isHeader: boolean; // true if cursor is on the TableHeader
+ table: ParseTree;
+};
+
+export type CursorContext =
+ | ListContext
+ | HeadingContext
+ | ParagraphContext
+ | TableRowContext;
+
+/**
+ * Classifies the cursor position as a listItem, heading, tableRow, or
+ * paragraph context for outline operations. At node boundaries, resolves to
+ * the preceding typed node (drilling into nested children). Returns null for
+ * positions where no outline operation applies (code blocks, frontmatter,
+ * past end of document).
+ */
+export function detectContext(
+ tree: ParseTree,
+ cursor: number,
+): CursorContext | null {
+ addParentPointers(tree);
+ const initial = nodeAtPos(tree, cursor);
+ if (!initial) {
+ return null;
+ }
+
+ // When cursor is on a separator between typed children (nodeAtPos returns
+ // a container like Document, BulletList, or ListItem), check if it's right
+ // at the end of a preceding typed child — if so, drill into that child.
+ // Repeat recursively so we reach the deepest matching node (e.g. a nested
+ // ListItem rather than its parent). This handles cursor-at-end-of-line.
+ let node: ParseTree = initial;
+ while (node.children) {
+ let found: ParseTree | null = null;
+ for (let i = node.children.length - 1; i >= 0; i--) {
+ const child = node.children[i];
+ if (child.type && child.to === cursor) {
+ found = child;
+ break;
+ }
+ }
+ if (!found) {
+ break;
+ }
+ node = found;
+ }
+
+ // Check if we're in a list item
+ // If nodeAtPos landed on a BulletList/OrderedList itself (i.e. on separator
+ // whitespace between items), don't walk up — cursor isn't on any item.
+ const listItem = node.type === "ListItem"
+ ? node
+ : (node.type === "BulletList" || node.type === "OrderedList")
+ ? null
+ : findParentMatching(node, (n) => n.type === "ListItem");
+ if (listItem) {
+ const list = listItem.parent;
+ if (
+ list &&
+ (list.type === "BulletList" || list.type === "OrderedList")
+ ) {
+ const itemIndex = list.children!.indexOf(listItem);
+ return { type: "listItem", item: listItem, itemIndex, list };
+ }
+ }
+
+ // Check if we're in a heading
+ const heading = node.type?.startsWith("ATXHeading")
+ ? node
+ : findParentMatching(
+ node,
+ (n) => n.type?.startsWith("ATXHeading") ?? false,
+ );
+ if (heading?.type) {
+ const level = parseInt(heading.type.replace("ATXHeading", ""), 10);
+ const doc = heading.parent!;
+ if (doc.type === "Document" && doc.children) {
+ const headingIndex = doc.children.indexOf(heading);
+ const sectionStart = headingIndex;
+ let sectionEnd = doc.children.length;
+ for (let i = headingIndex + 1; i < doc.children.length; i++) {
+ const child = doc.children[i];
+ if (child.type?.startsWith("ATXHeading")) {
+ const childLevel = parseInt(
+ child.type.replace("ATXHeading", ""), 10
+ );
+ if (childLevel <= level) {
+ sectionEnd = i;
+ break;
+ }
+ }
+ }
+ return { type: "heading", level, sectionStart, sectionEnd, doc };
+ }
+ }
+
+ // Check if we're in a table row or header
+ const tableRow = node.type === "TableRow" || node.type === "TableHeader"
+ ? node
+ : findParentMatching(
+ node,
+ (n) => n.type === "TableRow" || n.type === "TableHeader",
+ );
+ if (tableRow && tableRow.parent?.type === "Table") {
+ const table = tableRow.parent;
+ const rowIndex = table.children!.indexOf(tableRow);
+ const isHeader = tableRow.type === "TableHeader";
+ return { type: "tableRow", row: tableRow, rowIndex, isHeader, table };
+ }
+
+ // Check if we're in a paragraph at the Document level
+ const para = node.type === "Paragraph"
+ ? node
+ : findParentMatching(node, (n) => n.type === "Paragraph");
+ if (para && para.parent?.type === "Document") {
+ const doc = para.parent;
+ const blockIndex = doc.children!.indexOf(para);
+ return { type: "paragraph", blockIndex, doc };
+ }
+
+ // No context we can do something with
+ return null;
+}
+
+/**
+ * Returns indices of ListItem children in list.children, skipping separator text nodes.
+ */
+function listItemIndices(list: ParseTree): number[] {
+ const indices: number[] = [];
+ if (!list.children) {
+ return indices;
+ }
+ for (let i = 0; i < list.children.length; i++) {
+ if (list.children[i].type === "ListItem") {
+ indices.push(i);
+ }
+ }
+ return indices;
+}
+
+/**
+ * Swaps two non-overlapping text regions, preserving the separator between
+ * them and adjusting the cursor position based on move direction.
+ */
+function swapRegions(
+ text: string,
+ cursor: number,
+ currFrom: number,
+ direction: "up" | "down",
+ firstFrom: number,
+ firstTo: number,
+ secondFrom: number,
+ secondTo: number,
+): { text: string; cursor: number } {
+ const firstContent = text.slice(firstFrom, firstTo);
+ const separator = text.slice(firstTo, secondFrom);
+ const secondContent = text.slice(secondFrom, secondTo);
+ const newText = text.slice(0, firstFrom) + secondContent + separator +
+ firstContent + text.slice(secondTo);
+ const offsetInCurr = cursor - currFrom;
+ const newCursor = direction === "up"
+ ? firstFrom + offsetInCurr
+ : firstFrom + secondContent.length + separator.length + offsetInCurr;
+ return { text: newText, cursor: newCursor };
+}
+
+/**
+ * Renumbers all ordered list markers (1., 2., ...) sequentially within the
+ * given range.
+ */
+function renumberOrderedList(
+ text: string,
+ listFrom: number,
+ listTo: number,
+): string {
+ const listText = text.slice(listFrom, listTo);
+ let num = 1;
+ const renumbered = listText.replace(
+ /^(\s*)(\d+)\./gm,
+ (_match, indent) => {
+ return `${indent}${num++}.`;
+ },
+ );
+ return text.slice(0, listFrom) + renumbered + text.slice(listTo);
+}
+
+/**
+ * Moves the outline element at cursor up or down, dispatching to the
+ * appropriate handler.
+ */
+function move(
+ text: string,
+ cursor: number,
+ direction: "up" | "down",
+): OutlineResult {
+ const tree = parseMarkdown(text);
+ const ctx = detectContext(tree, cursor);
+ if (!ctx) {
+ return null;
+ }
+ switch (ctx.type) {
+ case "listItem":
+ return moveListItem(text, cursor, ctx, direction) ?? "blocked";
+ case "heading":
+ return moveHeading(text, cursor, ctx, direction) ?? "blocked";
+ case "paragraph":
+ return moveParagraph(text, cursor, ctx, direction) ?? "blocked";
+ case "tableRow":
+ return moveTableRow(text, cursor, ctx, direction) ?? "blocked";
+ }
+}
+
+export const moveUp = (text: string, cursor: number) =>
+ move(text, cursor, "up");
+export const moveDown = (text: string, cursor: number) =>
+ move(text, cursor, "down");
+
+/**
+ * Indents or outdents the outline element at cursor, dispatching to the
+ * appropriate handler.
+ */
+function adjustLevel(
+ text: string,
+ cursor: number,
+ delta: 1 | -1,
+): OutlineResult {
+ const tree = parseMarkdown(text);
+ const ctx = detectContext(tree, cursor);
+ if (!ctx) {
+ return null;
+ }
+ switch (ctx.type) {
+ case "listItem":
+ return (delta === 1
+ ? indentListItem(text, cursor, ctx)
+ : outdentListItem(text, cursor, ctx)) ?? "blocked";
+ case "heading":
+ return adjustHeadingLevel(text, cursor, ctx, delta) ?? "blocked";
+ case "paragraph":
+ case "tableRow":
+ return "blocked";
+ }
+}
+
+export const indent = (text: string, cursor: number) =>
+ adjustLevel(text, cursor, 1);
+export const outdent = (text: string, cursor: number) =>
+ adjustLevel(text, cursor, -1);
+
+/**
+ * Swaps a list item with its adjacent sibling, renumbering if in an ordered
+ * list.
+ */
+function moveListItem(
+ text: string,
+ cursor: number,
+ ctx: ListContext,
+ direction: "up" | "down",
+): OutlineResult {
+ const { item, list } = ctx;
+ const indices = listItemIndices(list);
+ const itemPos = indices.indexOf(ctx.itemIndex);
+
+ if (direction === "up" && itemPos <= 0) {
+ return null;
+ }
+ if (direction === "down" && itemPos >= indices.length - 1) {
+ return null;
+ }
+
+ const otherPos = direction === "up" ? itemPos - 1 : itemPos + 1;
+ const otherIndex = indices[otherPos];
+ const otherItem = list.children![otherIndex];
+
+ const first = direction === "up" ? otherItem : item;
+ const second = direction === "up" ? item : otherItem;
+
+ const result = swapRegions(
+ text,
+ cursor,
+ item.from!,
+ direction,
+ first.from!,
+ first.to!,
+ second.from!,
+ second.to!,
+ );
+
+ if (list.type === "OrderedList") {
+ return {
+ text: renumberOrderedList(result.text, list.from!, list.to!),
+ cursor: result.cursor,
+ };
+ }
+
+ return result;
+}
+
+/**
+ * Returns the start of the line containing position `pos`.
+ */
+function lineStartPos(text: string, pos: number): number {
+ let start = pos;
+ while (start > 0 && text[start - 1] !== "\n") {
+ start--;
+ }
+ return start;
+}
+
+/**
+ * Returns the indent width for a list (2 for bullet, marker length + 1 for
+ * ordered).
+ */
+function listIndentWidth(list: ParseTree): number {
+ if (list.type === "BulletList") {
+ return 2;
+ }
+ const firstItem = list.children?.find((c) => c.type === "ListItem");
+ if (firstItem) {
+ const mark = firstItem.children?.find((c) => c.type === "ListMark");
+ if (mark) {
+ return renderToText(mark).length + 1;
+ }
+ }
+ return 3;
+}
+
+/**
+ * Adds one level of indentation to a list item and its children.
+ */
+function indentListItem(
+ text: string,
+ cursor: number,
+ ctx: ListContext,
+): OutlineResult {
+ const { item, list } = ctx;
+ const indices = listItemIndices(list);
+ const itemPos = indices.indexOf(ctx.itemIndex);
+
+ // Can indent if: has preceding sibling, or list is already nested
+ const isNested = list.parent?.type === "ListItem";
+ if (itemPos <= 0 && !isNested) {
+ return null;
+ }
+
+ const indentWidth = listIndentWidth(list);
+ const indentStr = " ".repeat(indentWidth);
+
+ // Work with full lines including leading whitespace
+ const lineFrom = lineStartPos(text, item.from!);
+ const lineTo = item.to!;
+ const itemLines = text.slice(lineFrom, lineTo);
+
+ const indented = itemLines.split("\n").map((line) =>
+ line ? indentStr + line : line
+ ).join("\n");
+
+ const newText = text.slice(0, lineFrom) + indented + text.slice(lineTo);
+
+ const preText = text.slice(lineFrom, cursor);
+ const linesBeforeCursor = preText.split("\n").length;
+ const newCursor = cursor + linesBeforeCursor * indentWidth;
+
+ return { text: newText, cursor: newCursor };
+}
+
+/**
+ * Removes one level of indentation from a list item and its children.
+ */
+function outdentListItem(
+ text: string,
+ cursor: number,
+ ctx: ListContext,
+): OutlineResult {
+ const { item, list } = ctx;
+
+ const lineFrom = lineStartPos(text, item.from!);
+ const lineTo = item.to!;
+ const itemLines = text.slice(lineFrom, lineTo);
+
+ if (!itemLines.startsWith(" ")) {
+ return null;
+ }
+
+ const indentWidth = listIndentWidth(list);
+
+ const outdented = itemLines.split("\n").map((line) => {
+ if (line.startsWith(" ".repeat(indentWidth))) {
+ return line.substring(indentWidth);
+ }
+ let removed = 0;
+ while (removed < indentWidth && line[removed] === " ") {
+ removed++;
+ }
+ return line.substring(removed);
+ }).join("\n");
+
+ const newText = text.slice(0, lineFrom) + outdented + text.slice(lineTo);
+
+ const preText = text.slice(lineFrom, cursor);
+ const linesBeforeCursor = preText.split("\n").length;
+ const newCursor = cursor - linesBeforeCursor * indentWidth;
+
+ return { text: newText, cursor: newCursor };
+}
+
+/**
+ * Returns the end position of the last typed child in a range, excluding
+ * trailing separator newlines.
+ */
+function lastTypedChildEnd(
+ children: ParseTree[],
+ start: number,
+ end: number,
+): number {
+ for (let i = end - 1; i >= start; i--) {
+ if (children[i].type) {
+ return children[i].to!;
+ }
+ }
+ return children[start].from!;
+}
+
+/**
+ * Swaps a heading section (including sub-headings and body) with its adjacent
+ * sibling section.
+ */
+function moveHeading(
+ text: string,
+ cursor: number,
+ ctx: HeadingContext,
+ direction: "up" | "down",
+): OutlineResult {
+ const { level, sectionStart, sectionEnd, doc } = ctx;
+ const children = doc.children!;
+
+ // Find adjacent section at the same level
+ const searchFrom = direction === "up" ? sectionStart - 1 : sectionEnd;
+ const searchTo = direction === "up" ? -1 : children.length;
+ const step = direction === "up" ? -1 : 1;
+
+ let adjSectionStart = -1;
+ for (let i = searchFrom; i !== searchTo; i += step) {
+ const child = children[i];
+ if (child.type?.startsWith("ATXHeading")) {
+ const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
+ if (childLevel === level) {
+ adjSectionStart = i;
+ break;
+ }
+ if (childLevel < level) {
+ return null;
+ }
+ }
+ }
+ if (adjSectionStart < 0) {
+ return null;
+ }
+
+ // Find end of adjacent section
+ let adjSectionEnd: number;
+ if (direction === "up") {
+ adjSectionEnd = sectionStart;
+ } else {
+ adjSectionEnd = children.length;
+ for (let i = adjSectionStart + 1; i < children.length; i++) {
+ const child = children[i];
+ if (child.type?.startsWith("ATXHeading")) {
+ const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
+ if (childLevel <= level) {
+ adjSectionEnd = i;
+ break;
+ }
+ }
+ }
+ }
+
+ // Normalize to [first, second] order
+ const [firstStart, firstEnd, secondStart, secondEnd] = direction === "up"
+ ? [adjSectionStart, adjSectionEnd, sectionStart, sectionEnd]
+ : [sectionStart, sectionEnd, adjSectionStart, adjSectionEnd];
+
+ return swapRegions(
+ text,
+ cursor,
+ children[sectionStart].from!,
+ direction,
+ children[firstStart].from!,
+ lastTypedChildEnd(children, firstStart, firstEnd),
+ children[secondStart].from!,
+ lastTypedChildEnd(children, secondStart, secondEnd),
+ );
+}
+
+/**
+ * Adds or removes a '#' from all headings in a section, clamping at h1/h6.
+ */
+function adjustHeadingLevel(
+ text: string,
+ cursor: number,
+ ctx: HeadingContext,
+ delta: 1 | -1,
+): OutlineResult {
+ const { level, sectionStart, sectionEnd, doc } = ctx;
+ if (delta === 1 && level >= 6) {
+ return null;
+ }
+ if (delta === -1 && level <= 1) {
+ return null;
+ }
+
+ const limitLevel = delta === 1 ? 6 : 1;
+ const children = doc.children!;
+ const sectionFrom = children[sectionStart].from!;
+ const sectionTo = children[sectionEnd - 1].to!;
+ const sectionText = text.slice(sectionFrom, sectionTo);
+
+ let cursorAdjust = 0;
+ let newSectionText = "";
+ let pos = 0;
+ for (let i = sectionStart; i < sectionEnd; i++) {
+ const child = children[i];
+ if (child.type?.startsWith("ATXHeading")) {
+ const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
+ if (childLevel === limitLevel) {
+ continue;
+ }
+
+ const childFrom = child.from! - sectionFrom;
+ const childTo = child.to! - sectionFrom;
+
+ newSectionText += sectionText.slice(pos, childFrom);
+ if (delta === 1) {
+ newSectionText += `#${sectionText.slice(childFrom, childTo)}`;
+ } else {
+ newSectionText += sectionText.slice(childFrom + 1, childTo);
+ }
+ pos = childTo;
+
+ if (cursor > child.from!) {
+ cursorAdjust += delta;
+ }
+ }
+ }
+ newSectionText += sectionText.slice(pos);
+
+ const newText = text.slice(0, sectionFrom) + newSectionText +
+ text.slice(sectionTo);
+ return { text: newText, cursor: cursor + cursorAdjust };
+}
+
+/**
+ * Swaps a table data row with the adjacent data row. Header rows are blocked.
+ */
+function moveTableRow(
+ text: string,
+ cursor: number,
+ ctx: TableRowContext,
+ direction: "up" | "down",
+): OutlineResult {
+ if (ctx.isHeader) {
+ return null;
+ }
+
+ const { row, table } = ctx;
+ const children = table.children!;
+
+ // Collect indices of TableRow children (not TableHeader or TableDelimiter)
+ const rowIndices: number[] = [];
+ for (let i = 0; i < children.length; i++) {
+ if (children[i].type === "TableRow") {
+ rowIndices.push(i);
+ }
+ }
+
+ const rowPos = rowIndices.indexOf(ctx.rowIndex);
+ if (direction === "up" && rowPos <= 0) {
+ return null;
+ }
+ if (direction === "down" && rowPos >= rowIndices.length - 1) {
+ return null;
+ }
+
+ const otherPos = direction === "up" ? rowPos - 1 : rowPos + 1;
+ const otherRow = children[rowIndices[otherPos]];
+
+ const first = direction === "up" ? otherRow : row;
+ const second = direction === "up" ? row : otherRow;
+
+ return swapRegions(
+ text,
+ cursor,
+ row.from!,
+ direction,
+ first.from!,
+ first.to!,
+ second.from!,
+ second.to!,
+ );
+}
+
+/**
+ * Swaps a top-level paragraph with the adjacent document-level block.
+ */
+function moveParagraph(
+ text: string,
+ cursor: number,
+ ctx: ParagraphContext,
+ direction: "up" | "down",
+): OutlineResult {
+ const { blockIndex, doc } = ctx;
+ const children = doc.children!;
+ const currNode = children[blockIndex];
+
+ // Find adjacent typed sibling
+ let adjIdx = blockIndex + (direction === "up" ? -1 : 1);
+ const step = direction === "up" ? -1 : 1;
+ while (adjIdx >= 0 && adjIdx < children.length && !children[adjIdx].type) {
+ adjIdx += step;
+ }
+ if (adjIdx < 0 || adjIdx >= children.length) {
+ return null;
+ }
+
+ const adjNode = children[adjIdx];
+ const first = direction === "up" ? adjNode : currNode;
+ const second = direction === "up" ? currNode : adjNode;
+
+ return swapRegions(
+ text,
+ cursor,
+ currNode.from!,
+ direction,
+ first.from!,
+ first.to!,
+ second.from!,
+ second.to!,
+ );
+}
diff --git a/plugs/index/task.test.ts b/plugs/index/task.test.ts
new file mode 100644
index 00000000..5e1e9628
--- /dev/null
+++ b/plugs/index/task.test.ts
@@ -0,0 +1,91 @@
+import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
+import { createMockSystem } from "../../plug-api/system_mock.ts";
+import { expect, test } from "vitest";
+import { addParentPointers, renderToText } from "../../plug-api/lib/tree.ts";
+import { completeStates, removeCompletedTasksFromTree } from "./task.ts";
+
+function removeCompleted(md: string): string {
+ createMockSystem();
+ const tree = parseMarkdown(md);
+ addParentPointers(tree);
+ removeCompletedTasksFromTree(tree, completeStates);
+ return renderToText(tree);
+}
+
+test("removeCompletedTasks: flat list", () => {
+ const input = `- [x] completed
+- [ ] incomplete
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] incomplete\n");
+});
+
+test("removeCompletedTasks: flat list, completed last", () => {
+ const input = `- [ ] incomplete
+- [x] completed
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] incomplete\n");
+});
+
+test("removeCompletedTasks: flat list, all completed", () => {
+ const input = `- [x] one
+- [x] two
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("");
+});
+
+test("removeCompletedTasks: nested list, completed has sibling", () => {
+ const input = `- [ ] parent item
+ - [x] completed nested
+ - [ ] incomplete nested
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] parent item\n - [ ] incomplete nested\n");
+});
+
+test("removeCompletedTasks: nested list, only nested item completed", () => {
+ const input = `- [ ] parent item
+ - [x] only nested item
+- [ ] sibling item
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] parent item\n- [ ] sibling item\n");
+});
+
+test("removeCompletedTasks: nested list, all nested completed", () => {
+ const input = `- [ ] parent item
+ - [x] nested completed one
+ - [x] nested completed two
+- [ ] sibling item
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] parent item\n- [ ] sibling item\n");
+});
+
+test("removeCompletedTasks: mixed flat and nested", () => {
+ const input = `- [x] flat completed
+- [ ] flat incomplete
+ - [x] nested completed
+ - [ ] nested incomplete
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] flat incomplete\n - [ ] nested incomplete\n");
+});
+
+test("removeCompletedTasks: uppercase X treated as completed", () => {
+ const input = `- [X] completed uppercase
+- [ ] incomplete
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual("- [ ] incomplete\n");
+});
+
+test("removeCompletedTasks: no completed tasks, no change", () => {
+ const input = `- [ ] one
+- [ ] two
+`;
+ const result = removeCompleted(input);
+ expect(result).toEqual(input);
+});
diff --git a/plugs/index/task.ts b/plugs/index/task.ts
index ef123dea..5920c6db 100644
--- a/plugs/index/task.ts
+++ b/plugs/index/task.ts
@@ -295,17 +295,13 @@ export async function taskCycleCommand() {
await convertListItemToTask(listItem);
}
-export async function removeCompletedTasksCommand() {
- const tree = await markdown.parseMarkdown(await editor.getText());
- addParentPointers(tree);
-
+// Core logic extracted for testability. Mutates tree in place.
+export function removeCompletedTasksFromTree(
+ tree: ParseTree,
+ allCompletedStates: string[],
+) {
// Taking this ugly approach because the tree is modified in place
// Just finding and removing one task at a time and then repeating until nothing changes
- const allCompletedStates = completeStates.concat(
- Object.values(await config.get("taskStates", {})).filter((ts: any) =>
- ts.done
- ).map((ts: any) => ts.name),
- );
while (true) {
const completedTaskNode = findNodeMatching(tree, (node) => {
return node.type === "Task" &&
@@ -317,16 +313,65 @@ export async function removeCompletedTasksCommand() {
const bulletListNode = listItemNode.parent!;
// Remove the list item
const listItemIdx = bulletListNode.children!.indexOf(listItemNode);
- let removeItems = 1;
- if (bulletListNode.children![listItemIdx + 1]?.text === "\n") {
- removeItems++;
+ // Also remove the adjacent whitespace/newline separator text node.
+ // Prefer the following separator; if none, remove the preceding one.
+ const nextChild = bulletListNode.children![listItemIdx + 1];
+ const prevChild = listItemIdx > 0
+ ? bulletListNode.children![listItemIdx - 1]
+ : undefined;
+ if (nextChild && !nextChild.type && nextChild.text?.startsWith("\n")) {
+ // Remove item and following separator
+ bulletListNode.children!.splice(listItemIdx, 2);
+ } else if (
+ prevChild && !prevChild.type && prevChild.text?.startsWith("\n")
+ ) {
+ // Remove preceding separator and item
+ bulletListNode.children!.splice(listItemIdx - 1, 2);
+ } else {
+ // No separator to remove, just remove the item
+ bulletListNode.children!.splice(listItemIdx, 1);
+ }
+ // If the BulletList now has no ListItem children, remove it and any
+ // adjacent whitespace/newline text node from its parent. This prevents
+ // blank lines left behind when all items in a nested list are completed.
+ if (
+ bulletListNode.parent &&
+ !bulletListNode.children!.some((c) => c.type === "ListItem")
+ ) {
+ const parentChildren = bulletListNode.parent.children!;
+ const blIdx = parentChildren.indexOf(bulletListNode);
+ const blNext = parentChildren[blIdx + 1];
+ const blPrev = blIdx > 0 ? parentChildren[blIdx - 1] : undefined;
+ if (blNext && !blNext.type && blNext.text?.startsWith("\n")) {
+ // Remove BulletList and following separator
+ parentChildren.splice(blIdx, 2);
+ } else if (
+ blPrev && !blPrev.type && blPrev.text?.startsWith("\n")
+ ) {
+ // Remove preceding separator and BulletList
+ parentChildren.splice(blIdx - 1, 2);
+ } else {
+ parentChildren.splice(blIdx, 1);
+ }
}
- bulletListNode.children!.splice(listItemIdx, removeItems);
} else {
// No completed tasks left, we're done
break;
}
}
+}
+
+export async function removeCompletedTasksCommand() {
+ const tree = await markdown.parseMarkdown(await editor.getText());
+ addParentPointers(tree);
+
+ const allCompletedStates = completeStates.concat(
+ Object.values(await config.get("taskStates", {})).filter((ts: any) =>
+ ts.done
+ ).map((ts: any) => ts.name),
+ );
+
+ removeCompletedTasksFromTree(tree, allCompletedStates);
await editor.setText(renderToText(tree));
}