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 eff36158..1e9ddb5c 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 = 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 36496f86..ae828b99 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..cb712b04
--- /dev/null
+++ b/client/space_lua/render_lua_html.ts
@@ -0,0 +1,332 @@
+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 (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)));
+}