@silverbulletmd/silverbullet 2.8.1 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +42 -25
  2. package/client/plugos/hooks/syscall.ts +24 -10
  3. package/client/plugos/syscalls/asset.ts +61 -23
  4. package/client/plugos/syscalls/clientStore.ts +32 -6
  5. package/client/plugos/syscalls/client_code_widget.ts +9 -4
  6. package/client/plugos/syscalls/code_widget.ts +26 -11
  7. package/client/plugos/syscalls/config.ts +146 -30
  8. package/client/plugos/syscalls/datastore.ts +146 -40
  9. package/client/plugos/syscalls/editor.ts +1432 -700
  10. package/client/plugos/syscalls/event.ts +46 -13
  11. package/client/plugos/syscalls/fetch.ts +123 -82
  12. package/client/plugos/syscalls/index.ts +243 -114
  13. package/client/plugos/syscalls/jsonschema.ts +107 -8
  14. package/client/plugos/syscalls/language.ts +38 -12
  15. package/client/plugos/syscalls/markdown.ts +166 -50
  16. package/client/plugos/syscalls/mq.ts +93 -20
  17. package/client/plugos/syscalls/schema_introspection.ts +33 -0
  18. package/client/plugos/syscalls/service_registry.ts +74 -20
  19. package/client/plugos/syscalls/shell.ts +51 -20
  20. package/client/plugos/syscalls/space.ts +169 -113
  21. package/client/plugos/syscalls/sync.ts +50 -19
  22. package/client/plugos/syscalls/system.ts +236 -139
  23. package/client/plugos/system.ts +21 -5
  24. package/client/space_lua/api_documentation.ts +107 -0
  25. package/client/space_lua/ast.ts +13 -0
  26. package/client/space_lua/eval.ts +12 -4
  27. package/client/space_lua/parse.ts +254 -119
  28. package/client/space_lua/pretty_print.ts +465 -0
  29. package/client/space_lua/query_collection.ts +3 -3
  30. package/client/space_lua/render_widget.ts +156 -0
  31. package/client/space_lua/runtime.ts +83 -22
  32. package/client/space_lua/stdlib/crypto.ts +8 -3
  33. package/client/space_lua/stdlib/encoding.ts +27 -9
  34. package/client/space_lua/stdlib/js.ts +137 -22
  35. package/client/space_lua/stdlib/load.ts +26 -17
  36. package/client/space_lua/stdlib/math.ts +323 -118
  37. package/client/space_lua/stdlib/net.ts +57 -9
  38. package/client/space_lua/stdlib/os.ts +111 -44
  39. package/client/space_lua/stdlib/space_lua.ts +397 -13
  40. package/client/space_lua/stdlib/string.ts +282 -104
  41. package/client/space_lua/stdlib/string_pack.ts +58 -19
  42. package/client/space_lua/stdlib/table.ts +195 -40
  43. package/client/space_lua/stdlib.ts +336 -94
  44. package/client/space_lua/syscalls.ts +270 -0
  45. package/dist/plug-compile.js +1 -1
  46. package/package.json +12 -4
  47. package/plug-api/lib/ref.ts +96 -0
  48. package/plug-api/lib/shortcut.ts +2 -1
  49. package/plug-api/syscalls/config.ts +5 -3
  50. package/plug-api/syscalls/editor.ts +23 -2
  51. package/plug-api/syscalls/index.ts +24 -1
  52. package/plug-api/syscalls/jsonschema.ts +9 -0
  53. package/plug-api/syscalls/lua.ts +28 -1
  54. package/plug-api/syscalls/shell.ts +0 -1
  55. package/plug-api/syscalls/system.ts +7 -0
  56. package/plug-api/system_mock.ts +2 -2
  57. package/plug-api/types/config.ts +1 -1
  58. package/plug-api/types/index.ts +52 -1
  59. package/plug-api/types/manifest.ts +22 -4
  60. package/plug-api/ui/cx.ts +4 -0
  61. package/plug-api/ui/index.ts +22 -0
  62. package/plug-api/ui/panel_styles.ts +33 -0
  63. package/plug-api/ui/slugify.ts +44 -0
  64. package/plugs/builtin_plugs.ts +1 -0
  65. package/client/plugos/syscalls/lua.ts +0 -58
@@ -0,0 +1,465 @@
1
+ import type {
2
+ LuaBlock,
3
+ LuaExpression,
4
+ LuaOrderBy,
5
+ LuaQueryClause,
6
+ LuaStatement,
7
+ LuaTableField,
8
+ } from "./ast.ts";
9
+ import { luaFormatNumber } from "./runtime.ts";
10
+
11
+ export type PrintOptions = {
12
+ indentWidth?: number;
13
+ quote?: "double" | "single";
14
+ trailingComma?: boolean;
15
+ };
16
+
17
+ type ResolvedOptions = {
18
+ indentWidth: number;
19
+ quote: "double" | "single";
20
+ trailingComma: boolean;
21
+ };
22
+
23
+ function resolveOptions(opts?: PrintOptions): ResolvedOptions {
24
+ return {
25
+ indentWidth: opts?.indentWidth ?? 2,
26
+ quote: opts?.quote ?? "double",
27
+ trailingComma: opts?.trailingComma ?? true,
28
+ };
29
+ }
30
+
31
+ const RESERVED = new Set([
32
+ "and",
33
+ "break",
34
+ "do",
35
+ "else",
36
+ "elseif",
37
+ "end",
38
+ "false",
39
+ "for",
40
+ "function",
41
+ "goto",
42
+ "if",
43
+ "in",
44
+ "local",
45
+ "nil",
46
+ "not",
47
+ "or",
48
+ "repeat",
49
+ "return",
50
+ "then",
51
+ "true",
52
+ "until",
53
+ "while",
54
+ ]);
55
+
56
+ function isLuaIdentifier(s: string): boolean {
57
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(s) && !RESERVED.has(s);
58
+ }
59
+
60
+ const BINARY_PREC: Record<string, number> = {
61
+ or: 1,
62
+ and: 2,
63
+ "<": 3,
64
+ ">": 3,
65
+ "<=": 3,
66
+ ">=": 3,
67
+ "~=": 3,
68
+ "==": 3,
69
+ "|": 4,
70
+ "~": 5, // binary bitwise xor
71
+ "&": 6,
72
+ "<<": 7,
73
+ ">>": 7,
74
+ "..": 8,
75
+ "+": 9,
76
+ "-": 9,
77
+ "*": 10,
78
+ "/": 10,
79
+ "//": 10,
80
+ "%": 10,
81
+ "^": 12,
82
+ };
83
+ const UNARY_PREC = 11;
84
+ const RIGHT_ASSOC = new Set(["..", "^"]);
85
+ const ATOM_PREC = 99;
86
+
87
+ function exprPrec(e: LuaExpression): number {
88
+ if (e.type === "Binary") return BINARY_PREC[e.operator] ?? ATOM_PREC;
89
+ if (e.type === "Unary") return UNARY_PREC;
90
+ return ATOM_PREC;
91
+ }
92
+
93
+ class Printer {
94
+ constructor(private opts: ResolvedOptions) {}
95
+
96
+ private indent(depth: number): string {
97
+ return " ".repeat(depth * this.opts.indentWidth);
98
+ }
99
+
100
+ private quoteString(s: string): string {
101
+ const q = this.opts.quote === "double" ? '"' : "'";
102
+ let out = q;
103
+ for (const ch of s) {
104
+ switch (ch) {
105
+ case "\\":
106
+ out += "\\\\";
107
+ break;
108
+ case "\n":
109
+ out += "\\n";
110
+ break;
111
+ case "\r":
112
+ out += "\\r";
113
+ break;
114
+ case "\t":
115
+ out += "\\t";
116
+ break;
117
+ case "\0":
118
+ out += "\\0";
119
+ break;
120
+ default:
121
+ out += ch === q ? `\\${ch}` : ch;
122
+ }
123
+ }
124
+ return out + q;
125
+ }
126
+
127
+ expression(e: LuaExpression, depth = 0): string {
128
+ switch (e.type) {
129
+ case "Nil":
130
+ return "nil";
131
+ case "Boolean":
132
+ return e.value ? "true" : "false";
133
+ case "Number":
134
+ return luaFormatNumber(e.value, e.numericType);
135
+ case "String":
136
+ return this.quoteString(e.value);
137
+ case "Variable":
138
+ return e.name;
139
+ case "PropertyAccess":
140
+ return `${this.expression(e.object, depth)}.${e.property}`;
141
+ case "TableAccess":
142
+ return `${this.expression(e.object, depth)}[${this.expression(e.key, depth)}]`;
143
+ case "Parenthesized":
144
+ return `(${this.expression(e.expression, depth)})`;
145
+ case "Binary": {
146
+ const p = BINARY_PREC[e.operator] ?? ATOM_PREC;
147
+ const rightAssoc = RIGHT_ASSOC.has(e.operator);
148
+ const left = this.operand(e.left, p, false, rightAssoc, depth);
149
+ const right = this.operand(e.right, p, true, rightAssoc, depth);
150
+ return `${left} ${e.operator} ${right}`;
151
+ }
152
+ case "Unary": {
153
+ const arg = this.unaryOperand(e.argument, depth);
154
+ return /^[a-z]+$/.test(e.operator)
155
+ ? `${e.operator} ${arg}`
156
+ : `${e.operator}${arg}`;
157
+ }
158
+ case "TableConstructor":
159
+ return this.tableConstructor(e.fields, depth);
160
+ case "FunctionDefinition":
161
+ return `function${this.functionRest(
162
+ e.body.parameters,
163
+ e.body.block,
164
+ depth,
165
+ )}`;
166
+ case "FunctionCall":
167
+ return this.functionCall(e, depth);
168
+ case "Query": {
169
+ const lines = e.clauses.map(
170
+ (c) => this.indent(depth + 1) + this.queryClause(c, depth + 1),
171
+ );
172
+ return `query[[\n${lines.join("\n")}\n${this.indent(depth)}]]`;
173
+ }
174
+ case "FilteredCall":
175
+ return `${this.expression(e.call, depth)} filter ${this.expression(
176
+ e.filter,
177
+ depth,
178
+ )}`;
179
+ case "AggregateCall":
180
+ return `${this.expression(e.call, depth)} order by ${this.orderByList(
181
+ e.orderBy,
182
+ depth,
183
+ )}`;
184
+ default:
185
+ throw new Error(
186
+ `pretty_print: unsupported expression type: ${(e as any).type}`,
187
+ );
188
+ }
189
+ }
190
+
191
+ private operand(
192
+ e: LuaExpression,
193
+ parentPrec: number,
194
+ isRight: boolean,
195
+ parentRightAssoc: boolean,
196
+ depth: number,
197
+ ): string {
198
+ const cp = exprPrec(e);
199
+ let parens = false;
200
+ if (cp < parentPrec) {
201
+ parens = true;
202
+ } else if (cp === parentPrec) {
203
+ // left-assoc parent: the right child of equal precedence needs parens.
204
+ // right-assoc parent: the left child of equal precedence needs parens.
205
+ parens = parentRightAssoc ? !isRight : isRight;
206
+ }
207
+ const s = this.expression(e, depth);
208
+ return parens ? `(${s})` : s;
209
+ }
210
+
211
+ private unaryOperand(e: LuaExpression, depth: number): string {
212
+ const s = this.expression(e, depth);
213
+ return exprPrec(e) < UNARY_PREC ? `(${s})` : s;
214
+ }
215
+
216
+ private field(f: LuaTableField, depth: number): string {
217
+ switch (f.type) {
218
+ case "PropField":
219
+ return isLuaIdentifier(f.key)
220
+ ? `${f.key} = ${this.expression(f.value, depth)}`
221
+ : `[${this.quoteString(f.key)}] = ${this.expression(f.value, depth)}`;
222
+ case "DynamicField":
223
+ return `[${this.expression(f.key, depth)}] = ${this.expression(f.value, depth)}`;
224
+ case "ExpressionField":
225
+ return this.expression(f.value, depth);
226
+ }
227
+ }
228
+
229
+ private tableConstructor(fields: LuaTableField[], depth: number): string {
230
+ if (fields.length === 0) return "{}";
231
+ if (fields.length === 1) return `{${this.field(fields[0], depth)}}`;
232
+ const lines = fields.map(
233
+ (f) => this.indent(depth + 1) + this.field(f, depth + 1),
234
+ );
235
+ const tail = this.opts.trailingComma ? "," : "";
236
+ return `{\n${lines.join(",\n")}${tail}\n${this.indent(depth)}}`;
237
+ }
238
+
239
+ // returns the part of a function after the `function` keyword (+ optional
240
+ // name): "(params) end" or "(params)\n <body>\nend"
241
+ private functionRest(
242
+ params: string[],
243
+ body: LuaBlock,
244
+ depth: number,
245
+ ): string {
246
+ const head = `(${params.join(", ")})`;
247
+ const rendered = this.block(body, depth + 1);
248
+ if (!rendered) return `${head} end`;
249
+ return `${head}\n${rendered}\n${this.indent(depth)}end`;
250
+ }
251
+
252
+ private functionCall(
253
+ e: Extract<LuaExpression, { type: "FunctionCall" }>,
254
+ depth: number,
255
+ ): string {
256
+ let base = this.expression(e.prefix, depth);
257
+ if (e.name) base += `:${e.name}`;
258
+ const args = e.args;
259
+ const orderBy = e.orderBy
260
+ ? ` order by ${this.orderByList(e.orderBy, depth)}`
261
+ : "";
262
+ if (
263
+ !e.orderBy &&
264
+ args.length === 1 &&
265
+ (args[0].type === "TableConstructor" || args[0].type === "String")
266
+ ) {
267
+ return `${base} ${this.expression(args[0], depth)}`;
268
+ }
269
+ return `${base}(${args.map((a) => this.expression(a, depth)).join(", ")})${orderBy}`;
270
+ }
271
+
272
+ private fieldList(fields: LuaTableField[], depth: number): string {
273
+ return fields.map((f) => this.field(f, depth)).join(", ");
274
+ }
275
+
276
+ private orderByList(orders: LuaOrderBy[], depth: number): string {
277
+ return orders
278
+ .map((o) => {
279
+ let s = this.expression(o.expression, depth);
280
+ if (o.direction === "desc") s += " desc";
281
+ if (o.nulls) s += ` nulls ${o.nulls}`;
282
+ return s;
283
+ })
284
+ .join(", ");
285
+ }
286
+
287
+ private queryClause(c: LuaQueryClause, depth: number): string {
288
+ switch (c.type) {
289
+ case "From":
290
+ return `from ${this.fieldList(c.fields, depth)}`;
291
+ case "Where":
292
+ return `where ${this.expression(c.expression, depth)}`;
293
+ case "Limit":
294
+ return (
295
+ `limit ${this.expression(c.limit, depth)}` +
296
+ (c.offset ? ` offset ${this.expression(c.offset, depth)}` : "")
297
+ );
298
+ case "Offset":
299
+ return `offset ${this.expression(c.offset, depth)}`;
300
+ case "OrderBy":
301
+ return `order by ${this.orderByList(c.orderBy, depth)}`;
302
+ case "Select":
303
+ return `select ${this.fieldList(c.fields, depth)}`;
304
+ case "GroupBy":
305
+ return `group by ${this.fieldList(c.fields, depth)}`;
306
+ case "Having":
307
+ return `having ${this.expression(c.expression, depth)}`;
308
+ }
309
+ }
310
+
311
+ block(b: LuaBlock, depth: number): string {
312
+ const stmts = b.statements.filter((s) => s.type !== "Semicolon");
313
+ const items: (LuaStatement | NonNullable<LuaBlock["comments"]>[number])[] =
314
+ [...stmts, ...(b.comments ?? [])].sort(
315
+ (a, z) => (a.ctx.from ?? 0) - (z.ctx.from ?? 0),
316
+ );
317
+ const isDef = (s: (typeof items)[number]) =>
318
+ s.type === "Function" || s.type === "LocalFunction";
319
+ let out = "";
320
+ for (let i = 0; i < items.length; i++) {
321
+ if (i > 0) {
322
+ out += "\n";
323
+ // blank line separating function definitions from neighbours
324
+ const previous = items[i - 1];
325
+ const isDocumentationPair =
326
+ previous.type === "Comment" &&
327
+ previous.text.startsWith("---") &&
328
+ isDef(items[i]);
329
+ if (!isDocumentationPair && (isDef(previous) || isDef(items[i]))) {
330
+ out += "\n";
331
+ }
332
+ }
333
+ const item = items[i];
334
+ out +=
335
+ item.type === "Comment"
336
+ ? this.indent(depth) +
337
+ item.text.replaceAll("\n", `\n${this.indent(depth)}`)
338
+ : this.statement(item, depth);
339
+ }
340
+ return out;
341
+ }
342
+
343
+ private statement(s: LuaStatement, depth: number): string {
344
+ const ind = this.indent(depth);
345
+ switch (s.type) {
346
+ case "Local": {
347
+ const names = s.names
348
+ .map(
349
+ (n) =>
350
+ n.name +
351
+ (n.attribute
352
+ ? ` <${n.attribute}>`
353
+ : n.attributes?.length
354
+ ? ` <${n.attributes[0]}>`
355
+ : ""),
356
+ )
357
+ .join(", ");
358
+ const exprs = s.expressions?.length
359
+ ? ` = ${s.expressions.map((e) => this.expression(e, depth)).join(", ")}`
360
+ : "";
361
+ return `${ind}local ${names}${exprs}`;
362
+ }
363
+ case "Assignment":
364
+ return `${ind}${s.variables
365
+ .map((v) => this.expression(v, depth))
366
+ .join(
367
+ ", ",
368
+ )} = ${s.expressions.map((e) => this.expression(e, depth)).join(", ")}`;
369
+ case "Return":
370
+ return s.expressions.length
371
+ ? `${ind}return ${s.expressions
372
+ .map((e) => this.expression(e, depth))
373
+ .join(", ")}`
374
+ : `${ind}return`;
375
+ case "FunctionCallStatement":
376
+ return ind + this.expression(s.call, depth);
377
+ case "Break":
378
+ return `${ind}break`;
379
+ case "Goto":
380
+ return `${ind}goto ${s.name}`;
381
+ case "Label":
382
+ return `${ind}::${s.name}::`;
383
+ case "Block": {
384
+ const body = this.block(s, depth + 1);
385
+ return body ? `${ind}do\n${body}\n${ind}end` : `${ind}do end`;
386
+ }
387
+ case "If": {
388
+ let out = "";
389
+ s.conditions.forEach((c, i) => {
390
+ const kw = i === 0 ? "if" : "elseif";
391
+ const head = i === 0 ? `${ind}${kw} ` : `\n${ind}${kw} `;
392
+ const body = this.block(c.block, depth + 1);
393
+ out +=
394
+ `${head}${this.expression(c.condition, depth)} then` +
395
+ (body ? `\n${body}` : "");
396
+ });
397
+ if (s.elseBlock) {
398
+ const body = this.block(s.elseBlock, depth + 1);
399
+ out += `\n${ind}else${body ? `\n${body}` : ""}`;
400
+ }
401
+ out += `\n${ind}end`;
402
+ return out;
403
+ }
404
+ case "While": {
405
+ const body = this.block(s.block, depth + 1);
406
+ return (
407
+ `${ind}while ${this.expression(s.condition, depth)} do` +
408
+ (body ? `\n${body}` : "") +
409
+ `\n${ind}end`
410
+ );
411
+ }
412
+ case "Repeat": {
413
+ const body = this.block(s.block, depth + 1);
414
+ return (
415
+ `${ind}repeat` +
416
+ (body ? `\n${body}` : "") +
417
+ `\n${ind}until ${this.expression(s.condition, depth)}`
418
+ );
419
+ }
420
+ case "For": {
421
+ const step = s.step ? `, ${this.expression(s.step, depth)}` : "";
422
+ const body = this.block(s.block, depth + 1);
423
+ return `${ind}for ${s.name} = ${this.expression(s.start, depth)}, ${this.expression(
424
+ s.end,
425
+ depth,
426
+ )}${step} do${body ? `\n${body}` : ""}\n${ind}end`;
427
+ }
428
+ case "ForIn": {
429
+ const body = this.block(s.block, depth + 1);
430
+ return `${ind}for ${s.names.join(", ")} in ${s.expressions
431
+ .map((e) => this.expression(e, depth))
432
+ .join(", ")} do${body ? `\n${body}` : ""}\n${ind}end`;
433
+ }
434
+ case "Function": {
435
+ const name =
436
+ s.name.propNames.join(".") +
437
+ (s.name.colonName ? `:${s.name.colonName}` : "");
438
+ return (
439
+ `${ind}function ${name}` +
440
+ this.functionRest(s.body.parameters, s.body.block, depth)
441
+ );
442
+ }
443
+ case "LocalFunction":
444
+ return (
445
+ `${ind}local function ${s.name}` +
446
+ this.functionRest(s.body.parameters, s.body.block, depth)
447
+ );
448
+ default:
449
+ throw new Error(
450
+ `pretty_print: unsupported statement type: ${(s as any).type}`,
451
+ );
452
+ }
453
+ }
454
+ }
455
+
456
+ export function prettyPrintExpression(
457
+ expr: LuaExpression,
458
+ opts?: PrintOptions,
459
+ ): string {
460
+ return new Printer(resolveOptions(opts)).expression(expr, 0);
461
+ }
462
+
463
+ export function prettyPrintBlock(block: LuaBlock, opts?: PrintOptions): string {
464
+ return new Printer(resolveOptions(opts)).block(block, 0);
465
+ }
@@ -915,9 +915,7 @@ export async function applyQuery(
915
915
  for (let i = 0; i < violated.length; i++) {
916
916
  if (violated[i]) {
917
917
  throw new LuaRuntimeError(
918
- `order by #${
919
- i + 1
920
- }: 'using' comparator violates strict weak ordering`,
918
+ `order by #${i + 1}: 'using' comparator violates strict weak ordering`,
921
919
  sf,
922
920
  );
923
921
  }
@@ -1004,6 +1002,8 @@ export async function queryLua<T = any>(
1004
1002
  for await (let { key, value } of kv.query({ prefix })) {
1005
1003
  if (enricher) {
1006
1004
  value = enricher(key, value);
1005
+ // Enrichers may return undefined to filter a row out
1006
+ if (value === undefined) continue;
1007
1007
  }
1008
1008
  results.push(value);
1009
1009
  }
@@ -0,0 +1,156 @@
1
+ import type { ASTCtx } from "./ast.ts";
2
+ import { evalExpression } from "./eval.ts";
3
+ import { parseExpressionString } from "./parse.ts";
4
+ import {
5
+ type ILuaFunction,
6
+ LuaEnv,
7
+ LuaRuntimeError,
8
+ LuaStackFrame,
9
+ LuaTable,
10
+ type LuaValue,
11
+ luaValueToJS,
12
+ singleResult,
13
+ } from "./runtime.ts";
14
+ import { isTaggedFloat } from "./numeric.ts";
15
+ import {
16
+ encodeRef,
17
+ getNameFromPath,
18
+ } from "@silverbulletmd/silverbullet/lib/ref";
19
+ import { resolveASTReference } from "../space_lua.ts";
20
+ import type { Client } from "../client.ts";
21
+ import {
22
+ classifyResult,
23
+ renderResultToCleanMarkdown,
24
+ } from "./render_lua_markdown.ts";
25
+
26
+ /**
27
+ * Run a Space Lua computation and convert its result into something the
28
+ * LuaWidget renderer understands (a widget table, markdown string, or number).
29
+ * Shared by the `${...}` directive and Lua code widgets so the conversion +
30
+ * error formatting live in one place.
31
+ */
32
+ export async function renderLuaWidgetResult(
33
+ client: Client,
34
+ compute: (env: LuaEnv, sf: LuaStackFrame) => Promise<LuaValue> | LuaValue,
35
+ ctx: ASTCtx,
36
+ currentPageMeta?: { name: string } | undefined,
37
+ ): Promise<any> {
38
+ try {
39
+ const tl = new LuaEnv();
40
+ tl.setLocal(
41
+ "currentPage",
42
+ currentPageMeta ||
43
+ (client.ui.viewState.current
44
+ ? { name: getNameFromPath(client.ui.viewState.current.path) }
45
+ : undefined),
46
+ );
47
+ const sf = LuaStackFrame.createWithGlobalEnv(
48
+ client.clientSystem.spaceLuaEnv.env,
49
+ ctx,
50
+ );
51
+ const env = new LuaEnv(client.clientSystem.spaceLuaEnv.env);
52
+ env.setLocal("_CTX", tl);
53
+ const rawResult = singleResult(await compute(env, sf));
54
+ if (isTaggedFloat(rawResult) || typeof rawResult === "number") {
55
+ return rawResult;
56
+ }
57
+ if (rawResult instanceof LuaTable) {
58
+ if (rawResult.rawGet("_isWidget")) {
59
+ return luaValueToJS(rawResult, sf);
60
+ }
61
+ return rawResult;
62
+ }
63
+ return luaValueToJS(rawResult, sf);
64
+ } catch (e: any) {
65
+ if (e instanceof LuaRuntimeError && e.sf?.astCtx) {
66
+ const source = resolveASTReference(e.sf.astCtx);
67
+ if (source) {
68
+ return `**Lua error:** ${e.message} (Origin: [[${encodeRef(source)}]])`;
69
+ }
70
+ }
71
+ return `**Lua error:** ${e.message}`;
72
+ }
73
+ }
74
+
75
+ /** Evaluate a `${...}`-style expression string and render it as a widget. */
76
+ export async function renderLuaExpression(
77
+ client: Client,
78
+ expressionText: string,
79
+ currentPageMeta?: { name: string } | undefined,
80
+ ): Promise<any> {
81
+ if (expressionText.trim().length === 0) {
82
+ return "**Error:** Empty Lua expression";
83
+ }
84
+ const expr = parseExpressionString(expressionText);
85
+ return renderLuaWidgetResult(
86
+ client,
87
+ (env, sf) => evalExpression(expr, env, sf),
88
+ expr.ctx,
89
+ currentPageMeta,
90
+ );
91
+ }
92
+
93
+ /** Call a stored Lua render function (e.g. a code widget) and render it. */
94
+ export async function renderLuaCallback(
95
+ client: Client,
96
+ fn: ILuaFunction,
97
+ args: LuaValue[],
98
+ currentPageMeta?: { name: string } | undefined,
99
+ ): Promise<any> {
100
+ return renderLuaWidgetResult(
101
+ client,
102
+ (_env, sf) => fn.call(sf, ...args),
103
+ {} as ASTCtx,
104
+ currentPageMeta,
105
+ );
106
+ }
107
+
108
+ export type PortableMarkdownResult =
109
+ | { ok: true; markdown: string }
110
+ | { ok: false; reason: string };
111
+
112
+ /**
113
+ * Evaluate a `${...}`-style expression and produce portable GFM markdown — the
114
+ * same clean rendering the Copy button uses — or report why it can't be baked.
115
+ * - nil/empty → ok with empty markdown
116
+ * - Lua error → not ok (the eval error string)
117
+ * - widget object: portable only if it exposes a `markdown` rendering;
118
+ * html-only widgets are not bakeable
119
+ * - scalars / tables / arrays / strings → clean GFM
120
+ */
121
+ export async function expressionToPortableMarkdown(
122
+ client: Client,
123
+ expressionText: string,
124
+ currentPageMeta?: { name: string } | undefined,
125
+ ): Promise<PortableMarkdownResult> {
126
+ const rawResult = await renderLuaExpression(
127
+ client,
128
+ expressionText,
129
+ currentPageMeta,
130
+ );
131
+ if (rawResult === null || rawResult === undefined) {
132
+ return { ok: true, markdown: "" };
133
+ }
134
+ // renderLuaExpression returns eval failures as markdown error strings.
135
+ if (
136
+ typeof rawResult === "string" &&
137
+ (rawResult.startsWith("**Lua error:**") ||
138
+ rawResult.startsWith("**Error:**"))
139
+ ) {
140
+ return { ok: false, reason: rawResult };
141
+ }
142
+ // Widget objects: portable only when they expose a `markdown` rendering.
143
+ if (typeof rawResult === "object" && (rawResult as any)._isWidget) {
144
+ const md = (rawResult as any).markdown;
145
+ if (typeof md === "string") {
146
+ return { ok: true, markdown: md.trim() };
147
+ }
148
+ return { ok: false, reason: "html-only widget (no markdown rendering)" };
149
+ }
150
+ // Scalars, tables, arrays, plain strings → clean GFM markdown.
151
+ const markdown = await renderResultToCleanMarkdown(
152
+ rawResult,
153
+ classifyResult(rawResult),
154
+ );
155
+ return { ok: true, markdown };
156
+ }