Lua `${...}` expression results are rendered using two parallel paths:
- **HTML path** (display path) for visual display in the editor and
- **Markdown path** (copy path) for the *copy button* output.
Every Lua value is classified as one of:
- **Scalar:** number, string, boolean, or `nil`
- **Empty table:** `{}`
- **Array:** a `LuaTable` with only sequential integer keys (no string keys)
- **Array of tables:** an array where every element is a `LuaTable`
- **Record:** a `LuaTable` with any string key (may also have integer keys)
Scalars are rendered as inline Markdown - wiki links, tags, bold, and
other inline syntax are resolved, `nil` renders as `<span data-empty>`.
Empty tables render as `<table data-table-empty>`.
An array of tables renders as a multi-row `<table>`. Column headers
are the union of all element keys. Each cell value recurses through
the same classification.
An array of scalars (or a mix of scalars and tables) renders as `<ul>`
with one `<li>` per element. Each item value recurses.
A record renders as a single-row `<table>` with one column per key.
Cell values recurse.
Every `<td>` carries `data-table-cell-type` and optionally
`data-table-cell-empty`. Every `<li>` carries `data-list-item-type` and
optionally `data-list-item-empty`. The outermost element carries
`data-type`. These attributes allow CSS styling of rendered widgets.
Nesting is **unlimited**. Tables inside cells, lists inside cells,
tables inside list items all render correctly in HTML.
Scalars render as plain text. Lua `nil` renders as `nil`. Empty tables
render as `*(empty table)*`.
An array of tables renders as a pipe-delimited Markdown table. Nested
values in cells are rendered as Lua literals via `toStringAsync()`
(e.g. `{1, 2, 3}` or `{a = 1}`), since Markdown table cells cannot
contain block-level content.
An array of scalars renders as a Markdown list (`- item`). When a list
item is itself multi-line (a sublist) it uses an empty bullet with
two-space indented content on the next line:
```
- 1
- 2
-
- nested 1
- nested 2
```
Nested tables or arrays **beyond the first list nesting** level are
rendered as Lua literals.
A record renders as a single-row Markdown table.
Column headers follow the order returned by `LuaTable.keys`: string keys
in their insertion order, followed by integer keys in ascending order.
For example, `{a = 1, b = 2, 3, d = 4}` produces columns `a
| b | d | 1`. This is consequence of `LuaTable` storing string keys
and the array part separately (for performance).
Arrays mixing plain scalars and record-like tables (e.g. `{1, 2, 3,
{a=3}}`) were previously rendered as a single flat table due to an
internal JS conversion (`toJS`). They now render as a list, with record
elements shown as nested tables - which more accurately reflects their
Lua structure. This is the only divergence from the original
presentation.
To restore the old tabular layout, wrap all elements as records with
a consistent key:
```lua
-- Before (was a flat table, now a list with nested table):
${{1, 2, 3, {a=3}}}
-- After (explicit records, renders as a table):
${{{value=1}, {value=2}, {value=3}, {a=3}}}
```
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
170 lines
5.5 KiB
TypeScript
170 lines
5.5 KiB
TypeScript
import type { EditorState, Range } from "@codemirror/state";
|
|
import { syntaxTree } from "@codemirror/language";
|
|
import { Decoration } from "@codemirror/view";
|
|
import {
|
|
decoratorStateField,
|
|
invisibleDecoration,
|
|
isCursorInRange,
|
|
} from "./util.ts";
|
|
import type { Client } from "../client.ts";
|
|
import { parse as parseLua } from "../space_lua/parse.ts";
|
|
import type { LuaBlock, LuaFunctionCallStatement } from "../space_lua/ast.ts";
|
|
import { evalExpression } from "../space_lua/eval.ts";
|
|
import {
|
|
LuaEnv,
|
|
LuaRuntimeError,
|
|
LuaStackFrame,
|
|
LuaTable,
|
|
luaValueToJS,
|
|
singleResult,
|
|
} from "../space_lua/runtime.ts";
|
|
import { isTaggedFloat } from "../space_lua/numeric.ts";
|
|
import {
|
|
encodeRef,
|
|
getNameFromPath,
|
|
} from "@silverbulletmd/silverbullet/lib/ref";
|
|
import { resolveASTReference } from "../space_lua.ts";
|
|
import { LuaWidget } from "./lua_widget.ts";
|
|
|
|
export function luaDirectivePlugin(client: Client) {
|
|
return decoratorStateField((state: EditorState) => {
|
|
const widgets: Range<Decoration>[] = [];
|
|
|
|
let shouldRender = true;
|
|
|
|
// Don't render Lua directives of federated pages (security)
|
|
if (
|
|
!client.clientSystem.scriptsLoaded
|
|
) {
|
|
return Decoration.none;
|
|
}
|
|
|
|
syntaxTree(state).iterate({
|
|
enter: (node) => {
|
|
// Disable rendering of Lua directives in #meta/template pages
|
|
// Either in frontmatter
|
|
if (node.name === "FrontMatterCode") {
|
|
const text = state.sliceDoc(node.from, node.to);
|
|
try {
|
|
// Very ad-hoc regex to detect if meta/template appears in the tag list
|
|
if (/tags:.*meta\/template/s.exec(text)) {
|
|
shouldRender = false;
|
|
return;
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
// Or with a hash tag
|
|
if (node.name === "Hashtag") {
|
|
const text = state.sliceDoc(node.from, node.to);
|
|
if (text.startsWith("#meta/template")) {
|
|
shouldRender = false;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (node.name !== "LuaDirective") {
|
|
return;
|
|
}
|
|
|
|
if (isCursorInRange(state, [node.from, node.to])) {
|
|
return;
|
|
}
|
|
|
|
const codeText = state.sliceDoc(node.from, node.to);
|
|
const expressionText = codeText.slice(2, -1);
|
|
const currentPageMeta = client.currentPageMeta();
|
|
widgets.push(
|
|
Decoration.widget({
|
|
widget: new LuaWidget(
|
|
client,
|
|
`lua:${expressionText}:${currentPageMeta?.name}`,
|
|
expressionText,
|
|
codeText,
|
|
async (bodyText) => {
|
|
if (bodyText.trim().length === 0) {
|
|
return "**Error:** Empty Lua expression";
|
|
}
|
|
try {
|
|
const parsedLua = parseLua(`_(${bodyText})`) as LuaBlock;
|
|
const expr =
|
|
(parsedLua.statements[0] as LuaFunctionCallStatement).call
|
|
.args[0];
|
|
|
|
const tl = new LuaEnv();
|
|
tl.setLocal(
|
|
"currentPage",
|
|
currentPageMeta || (client.ui.viewState.current
|
|
? {
|
|
name: getNameFromPath(
|
|
client.ui.viewState.current.path,
|
|
),
|
|
}
|
|
: undefined),
|
|
);
|
|
const sf = LuaStackFrame.createWithGlobalEnv(
|
|
client.clientSystem.spaceLuaEnv.env,
|
|
expr.ctx,
|
|
);
|
|
const threadLocalizedEnv = new LuaEnv(
|
|
client.clientSystem.spaceLuaEnv.env,
|
|
);
|
|
threadLocalizedEnv.setLocal("_CTX", tl);
|
|
const rawResult = singleResult(
|
|
await evalExpression(
|
|
expr,
|
|
threadLocalizedEnv,
|
|
sf,
|
|
),
|
|
);
|
|
// keep tagged floats as-is for proper formatting
|
|
if (
|
|
isTaggedFloat(rawResult) || typeof rawResult === "number"
|
|
) {
|
|
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) {
|
|
if (e instanceof LuaRuntimeError) {
|
|
if (e.sf?.astCtx) {
|
|
const source = resolveASTReference(e.sf.astCtx);
|
|
if (source) {
|
|
// We know the origin node of the error, let's reference it
|
|
return `**Lua error:** ${e.message} (Origin: [[${
|
|
encodeRef(source)
|
|
}]])`;
|
|
}
|
|
}
|
|
}
|
|
return `**Lua error:** ${e.message}`;
|
|
}
|
|
},
|
|
true,
|
|
true,
|
|
null,
|
|
),
|
|
}).range(node.to),
|
|
);
|
|
|
|
if (!client.ui.viewState.uiOptions.markdownSyntaxRendering) {
|
|
widgets.push(invisibleDecoration.range(node.from, node.to));
|
|
}
|
|
},
|
|
});
|
|
|
|
if (!shouldRender) {
|
|
return Decoration.set([]);
|
|
}
|
|
|
|
return Decoration.set(widgets, true);
|
|
});
|
|
}
|