@sigrun/shared 0.1.1 → 0.2.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 (46) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +1 -1
  3. package/dist/access.d.ts +0 -55
  4. package/dist/access.js +0 -72
  5. package/dist/ambient.d.ts +0 -111
  6. package/dist/ambient.js +0 -23
  7. package/dist/blocks/format.d.ts +0 -28
  8. package/dist/blocks/format.js +0 -199
  9. package/dist/blocks/icons.d.ts +0 -12
  10. package/dist/blocks/icons.js +0 -64
  11. package/dist/blocks/index.d.ts +0 -24
  12. package/dist/blocks/index.js +0 -20
  13. package/dist/blocks/lttb.d.ts +0 -16
  14. package/dist/blocks/lttb.js +0 -67
  15. package/dist/blocks/ops.d.ts +0 -44
  16. package/dist/blocks/ops.js +0 -294
  17. package/dist/blocks/text.d.ts +0 -43
  18. package/dist/blocks/text.js +0 -790
  19. package/dist/blocks/transport.d.ts +0 -77
  20. package/dist/blocks/transport.js +0 -11
  21. package/dist/blocks/types.d.ts +0 -541
  22. package/dist/blocks/types.js +0 -17
  23. package/dist/commands.d.ts +0 -75
  24. package/dist/commands.js +0 -25
  25. package/dist/conversations.d.ts +0 -31
  26. package/dist/conversations.js +0 -94
  27. package/dist/interactions.d.ts +0 -91
  28. package/dist/interactions.js +0 -21
  29. package/dist/jobs.d.ts +0 -109
  30. package/dist/jobs.js +0 -19
  31. package/dist/json.d.ts +0 -20
  32. package/dist/json.js +0 -13
  33. package/dist/live.d.ts +0 -71
  34. package/dist/live.js +0 -82
  35. package/dist/session-meta.d.ts +0 -119
  36. package/dist/session-meta.js +0 -13
  37. package/dist/settings.d.ts +0 -408
  38. package/dist/settings.js +0 -19
  39. package/dist/themes.d.ts +0 -50
  40. package/dist/themes.js +0 -156
  41. package/dist/turns.d.ts +0 -119
  42. package/dist/turns.js +0 -197
  43. package/dist/view-delta.d.ts +0 -54
  44. package/dist/view-delta.js +0 -135
  45. package/dist/views.d.ts +0 -784
  46. package/dist/views.js +0 -38
@@ -1,790 +0,0 @@
1
- /**
2
- * `toText` (UI-blocks spec §14.3): the deterministic plain-text
3
- * projection of a surface — headings, aligned tables, lists with
4
- * checkboxes and numbers, lanes as sections, charts as one summary
5
- * line per series. Used for the model's view of its own `show_blocks`
6
- * call, the default `fallback` text, extension unit tests, logs and
7
- * notifications.
8
- *
9
- * Determinism (spec §14.3): the same input, width and locale give the
10
- * same bytes, so model context stays cacheable. Three rules make that
11
- * hold:
12
- *
13
- * - object keys are visited in SCHEMA order, never insertion order —
14
- * card slots, log lines, tree nodes and table cells are read through
15
- * their named props, and anything rendered as JSON goes through
16
- * `formatValue`'s sorted-key serializer;
17
- * - no clock reads in the render path: `TextOptions.timeZone` defaults
18
- * to UTC (a datetime projection is stable across hosts; pass a zone
19
- * to localize), and only the `relative` format consults `Date.now()`;
20
- * - unknown blocks render their `fallback`, else
21
- * `[unsupported block: <type>]` (§4.4) — never a throw.
22
- */
23
- import { resolvePointer } from "./ops.js";
24
- import { formatValue } from "./format.js";
25
- /** Spec §12.1: `compact` is below 80 cells; `atLeast:regular` hides there. */
26
- const REGULAR_WIDTH = 80;
27
- /** The `Field` type literals (used to keep fields out of block walks). */
28
- const FIELD_TYPES = new Set([
29
- "text",
30
- "longtext",
31
- "number",
32
- "toggle",
33
- "choice",
34
- "date",
35
- "datetime",
36
- "daterange",
37
- "secret",
38
- "file",
39
- "list",
40
- ]);
41
- // ─── Small helpers ────────────────────────────────────────────────────────
42
- /** A JSON object (not an array, not null). */
43
- function isRecord(value) {
44
- return value !== null && typeof value === "object" && !Array.isArray(value);
45
- }
46
- /** A `{$: pointer}` binding. */
47
- function isBinding(value) {
48
- return (typeof value === "object" &&
49
- value !== null &&
50
- typeof value.$ === "string");
51
- }
52
- /** Resolve a literal-or-binding prop; a missing path is `undefined`. */
53
- function resolveBind(value, ctx, item) {
54
- if (value === undefined || value === null)
55
- return undefined;
56
- if (isBinding(value))
57
- return resolvePointer(ctx.data, value.$, item);
58
- return value;
59
- }
60
- /** Text of a JSON value: strings bare, everything else via `formatValue`. */
61
- function textOf(value, ctx) {
62
- if (value === undefined || value === null)
63
- return "";
64
- if (typeof value === "string")
65
- return value;
66
- return formatValue(value, undefined, ctx.locale);
67
- }
68
- /** Resolve a bindable text prop (empty string for missing paths). */
69
- function bindText(value, ctx, item) {
70
- return textOf(resolveBind(value, ctx, item), ctx);
71
- }
72
- /** A value through `formatValue` (empty string for missing paths). */
73
- function fmt(value, format, ctx) {
74
- return value === undefined || value === null
75
- ? ""
76
- : formatValue(value, format, ctx.locale, ctx.timeZone);
77
- }
78
- /** `label: value` with no trailing whitespace when the value is empty. */
79
- function labeled(label, value) {
80
- return value === "" ? `${label}:` : `${label}: ${value}`;
81
- }
82
- /**
83
- * `label: value` for CARD slots: empty values drop the line entirely
84
- * (an unset optional slot is noise in a text projection, unlike a form
85
- * field, where an empty value is information).
86
- */
87
- function cardLabeled(label, value) {
88
- return value === "" ? null : `${label}: ${value}`;
89
- }
90
- /** Greedy word wrap (never splits a word; keeps blank lines). */
91
- function wrap(text, width) {
92
- const out = [];
93
- for (const paragraph of text.split("\n")) {
94
- const words = paragraph.split(/\s+/).filter((word) => word !== "");
95
- if (words.length === 0) {
96
- out.push("");
97
- continue;
98
- }
99
- let line = "";
100
- for (const word of words) {
101
- if (line === "")
102
- line = word;
103
- else if (line.length + 1 + word.length <= width)
104
- line += ` ${word}`;
105
- else {
106
- out.push(line);
107
- line = word;
108
- }
109
- }
110
- out.push(line);
111
- }
112
- return out;
113
- }
114
- /** Join rendered blocks with one blank line between non-empty groups. */
115
- function joinBlocks(groups) {
116
- const out = [];
117
- for (const group of groups) {
118
- if (group.length === 0)
119
- continue;
120
- if (out.length > 0)
121
- out.push("");
122
- out.push(...group);
123
- }
124
- return out;
125
- }
126
- /**
127
- * Join list ROWS: two consecutive single-line rows stay adjacent (a
128
- * tight list reads as a list); a multi-line row — or a row followed by
129
- * one — keeps the blank line so card content doesn't fuse.
130
- */
131
- function joinRows(groups) {
132
- const out = [];
133
- let lastWasSingle = false;
134
- for (const group of groups) {
135
- if (group.length === 0)
136
- continue;
137
- const single = group.length === 1;
138
- if (out.length > 0 && !(single && lastWasSingle))
139
- out.push("");
140
- out.push(...group);
141
- lastWasSingle = single;
142
- }
143
- return out;
144
- }
145
- /** Is this node a `Field` (kept out of the block-only walks)? */
146
- function isField(node) {
147
- return FIELD_TYPES.has(node.type);
148
- }
149
- /** A unit joins its value: `3%` (single character) vs `10 req/s`. */
150
- function withUnit(text, unit) {
151
- if (unit === "")
152
- return text;
153
- return unit.length === 1 ? `${text}${unit}` : `${text} ${unit}`;
154
- }
155
- // ─── Entry point ──────────────────────────────────────────────────────────
156
- /**
157
- * Render `tree` against `data` as plain text (spec §14.3).
158
- * Deterministic per (input, width, locale): the same arguments give the
159
- * same bytes. Unknown blocks render their `fallback`, else
160
- * `[unsupported block: <type>]`.
161
- */
162
- export function toText(tree, data, opts) {
163
- const ctx = {
164
- data,
165
- locale: opts.locale,
166
- timeZone: opts.timeZone ?? "UTC",
167
- width: Math.max(1, Math.floor(opts.width)),
168
- };
169
- const lines = renderBlock(tree.root, ctx);
170
- while (lines.length > 0 && lines[lines.length - 1] === "")
171
- lines.pop();
172
- return lines.join("\n");
173
- }
174
- // ─── Block dispatch ───────────────────────────────────────────────────────
175
- /** Render one block to lines (empty lines list = renders nothing). */
176
- function renderBlock(block, ctx) {
177
- if (block.visibleAt === "atLeast:regular" && ctx.width < REGULAR_WIDTH) {
178
- return [];
179
- }
180
- switch (block.type) {
181
- case "page":
182
- return renderPage(block, ctx);
183
- case "stack":
184
- return renderBlocks(block.children, ctx);
185
- case "columns":
186
- return renderBlocks(block.children.map((child) => child.block), ctx);
187
- case "grid":
188
- return renderBlocks(block.children.map((child) => child.block), ctx);
189
- case "section":
190
- return renderSection(block, ctx);
191
- case "markdown":
192
- return wrap(bindText(block.text, ctx), ctx.width);
193
- case "fields":
194
- return renderFields(block.rows, ctx);
195
- case "stat":
196
- return renderStat(block, ctx);
197
- case "callout":
198
- return renderCallout(block, ctx);
199
- case "image":
200
- return [`[image: ${bindText(block.alt, ctx)}]`];
201
- case "chart":
202
- return renderChart(block, ctx);
203
- case "meter":
204
- return renderMeter(block, ctx);
205
- case "log":
206
- return renderLog(block, ctx);
207
- case "tree":
208
- return renderTree(block, ctx);
209
- case "diff":
210
- return renderDiff(block);
211
- case "details":
212
- return [formatValue(block.value, undefined, ctx.locale)];
213
- case "collection":
214
- return renderCollection(block, ctx);
215
- case "form":
216
- return renderForm(block, ctx);
217
- default: {
218
- // A block type this version does not know (§4.4): the block's
219
- // `fallback` text, else the unsupported marker. Never a throw.
220
- // SAFETY: `block` is `never` in this arm for the closed union, so
221
- // reading `type`/`fallback` off it via a structural cast is the
222
- // one sanctioned way to project an unknown-shape node.
223
- const unknown = block;
224
- const type = unknown.type ?? "unknown";
225
- return unknown.fallback !== undefined && unknown.fallback !== ""
226
- ? [unknown.fallback]
227
- : [`[unsupported block: ${type}]`];
228
- }
229
- }
230
- }
231
- /** Render a list of blocks, blank-line separated. */
232
- function renderBlocks(blocks, ctx) {
233
- return joinBlocks(blocks
234
- .filter((child) => !isField(child))
235
- .map((child) => renderBlock(child, ctx)));
236
- }
237
- // ─── Shell and layout ─────────────────────────────────────────────────────
238
- /** The first collection in `main`, depth-first through layout blocks. */
239
- function firstCollection(blocks) {
240
- for (const child of blocks) {
241
- if (isField(child))
242
- continue;
243
- if (child.type === "collection")
244
- return child;
245
- let nested;
246
- switch (child.type) {
247
- case "stack":
248
- nested = child.children;
249
- break;
250
- case "section":
251
- nested = child.children;
252
- break;
253
- case "columns":
254
- nested = child.children.map((column) => column.block);
255
- break;
256
- case "grid":
257
- nested = child.children.map((cell) => cell.block);
258
- break;
259
- default:
260
- nested = undefined;
261
- }
262
- if (nested !== undefined) {
263
- const found = firstCollection(nested);
264
- if (found !== undefined)
265
- return found;
266
- }
267
- }
268
- return undefined;
269
- }
270
- /** `page`: header, then sidebar, main and detail as labelled sections. */
271
- function renderPage(page, ctx) {
272
- const head = [];
273
- const title = bindText(page.title, ctx);
274
- if (title !== "")
275
- head.push(`# ${title}`);
276
- const subtitle = bindText(page.subtitle, ctx);
277
- if (subtitle !== "")
278
- head.push(subtitle);
279
- const actionLabels = (page.actions ?? [])
280
- .map((button) => bindText(button.label, ctx))
281
- .filter((label) => label !== "");
282
- if (actionLabels.length > 0)
283
- head.push(`actions: ${actionLabels.join(", ")}`);
284
- // §5.1: `empty` is "shown instead of main" when main's first
285
- // collection binds to a missing path, null or [] — main is the
286
- // replaced region; the sidebar and detail slots keep their content.
287
- let main;
288
- const collection = firstCollection(page.main);
289
- if (page.empty !== undefined && collection !== undefined) {
290
- const items = collection.items ? resolvePointer(ctx.data, collection.items.$) : undefined;
291
- if (!Array.isArray(items) || items.length === 0) {
292
- main = [page.empty.title];
293
- if (page.empty.hint !== undefined && page.empty.hint !== "")
294
- main.push(page.empty.hint);
295
- }
296
- else {
297
- main = renderBlocks(page.main, ctx);
298
- }
299
- }
300
- else {
301
- main = renderBlocks(page.main, ctx);
302
- }
303
- const sidebar = page.sidebar !== undefined && page.sidebar.length > 0
304
- ? ["## Sidebar", ...renderBlocks(page.sidebar, ctx)]
305
- : [];
306
- const detail = page.detail !== undefined && page.detail.length > 0
307
- ? ["## Detail", ...renderBlocks(page.detail, ctx)]
308
- : [];
309
- return joinBlocks([head, sidebar, main, detail]);
310
- }
311
- /** `section`: an `##` heading (plus subtitle) over its children. */
312
- function renderSection(section, ctx) {
313
- const head = [];
314
- const title = bindText(section.title, ctx);
315
- if (title !== "")
316
- head.push(`## ${title}`);
317
- const subtitle = bindText(section.subtitle, ctx);
318
- if (subtitle !== "")
319
- head.push(subtitle);
320
- return joinBlocks([head, renderBlocks(section.children, ctx)]);
321
- }
322
- // ─── Content blocks ───────────────────────────────────────────────────────
323
- /** `fields`: one `label: value` line per row. */
324
- function renderFields(rows, ctx) {
325
- const raw = resolveBind(rows, ctx);
326
- if (!Array.isArray(raw))
327
- return [];
328
- const lines = [];
329
- for (const row of raw) {
330
- if (!isRecord(row))
331
- continue;
332
- lines.push(labeled(`${bindText(row.label, ctx)}`, textOf(resolveBind(row.value, ctx), ctx)));
333
- }
334
- return lines;
335
- }
336
- /** `stat`: `label: value unit` plus an optional caption line. */
337
- function renderStat(block, ctx) {
338
- const value = fmt(resolveBind(block.value, ctx), block.format, ctx);
339
- const unit = bindText(block.unit, ctx);
340
- const text = withUnit(value, unit);
341
- const label = bindText(block.label, ctx);
342
- const lines = [labeled(label, text)];
343
- const caption = bindText(block.caption, ctx);
344
- if (caption !== "")
345
- lines.push(caption);
346
- return lines;
347
- }
348
- /** `callout`: `[tone] title`, body, and its action labels. */
349
- function renderCallout(block, ctx) {
350
- const tone = bindText(block.tone, ctx);
351
- const title = bindText(block.title, ctx);
352
- const head = tone !== "" ? `[${tone}] ${title}` : title;
353
- const groups = [[head]];
354
- const body = bindText(block.body, ctx);
355
- if (body !== "")
356
- groups.push(wrap(body, ctx.width));
357
- const actionLabels = (block.actions ?? [])
358
- .map((button) => bindText(button.label, ctx))
359
- .filter((label) => label !== "");
360
- if (actionLabels.length > 0)
361
- groups.push([`actions: ${actionLabels.join(", ")}`]);
362
- return joinBlocks(groups);
363
- }
364
- /** `chart`: one summary line per series (min, max, latest, count). */
365
- function renderChart(block, ctx) {
366
- const out = [];
367
- const title = bindText(block.title, ctx);
368
- if (title !== "")
369
- out.push(`## ${title}`);
370
- const unit = block.y?.unit !== undefined ? bindText(block.y.unit, ctx) : "";
371
- const format = block.y?.format;
372
- const fmtPoint = (value) => withUnit(fmt(value, format, ctx), unit);
373
- for (const series of block.series) {
374
- const name = bindText(series.name, ctx);
375
- const points = seriesPoints(series.data, ctx);
376
- if (points.length === 0) {
377
- out.push(`${name}: no data`);
378
- continue;
379
- }
380
- let min = points[0][1];
381
- let max = points[0][1];
382
- for (const [, y] of points) {
383
- if (y < min)
384
- min = y;
385
- if (y > max)
386
- max = y;
387
- }
388
- const latest = points[points.length - 1][1];
389
- out.push(`${name}: min ${fmtPoint(min)}, max ${fmtPoint(max)}, latest ${fmtPoint(latest)} ` +
390
- `(${points.length} point${points.length === 1 ? "" : "s"})`);
391
- }
392
- for (const threshold of block.thresholds ?? []) {
393
- const at = fmt(threshold.at, "number", ctx);
394
- const label = bindText(threshold.label, ctx);
395
- out.push(label !== "" ? `threshold: ${label} at ${at}` : `threshold at ${at}`);
396
- }
397
- return out;
398
- }
399
- /** Validate a series' points down to `[number, number]` tuples. */
400
- function seriesPoints(data, ctx) {
401
- const raw = resolveBind(data, ctx);
402
- if (!Array.isArray(raw))
403
- return [];
404
- const points = [];
405
- for (const entry of raw) {
406
- if (Array.isArray(entry) &&
407
- entry.length >= 2 &&
408
- typeof entry[0] === "number" &&
409
- typeof entry[1] === "number") {
410
- points.push([entry[0], entry[1]]);
411
- }
412
- }
413
- return points;
414
- }
415
- /** `meter`: `label: value`, or the bare value when it has no label. */
416
- function renderMeter(block, ctx) {
417
- const text = fmt(resolveBind(block.value, ctx), block.format, ctx);
418
- const label = bindText(block.label, ctx);
419
- return [label === "" ? text : labeled(label, text)];
420
- }
421
- /** `log`: one line per entry: `time [level] text`. */
422
- function renderLog(block, ctx) {
423
- const raw = resolveBind(block.lines, ctx);
424
- if (!Array.isArray(raw))
425
- return [];
426
- const lines = [];
427
- for (const entry of raw) {
428
- if (!isRecord(entry))
429
- continue;
430
- const parts = [];
431
- const at = entry.at;
432
- if (at !== undefined && at !== null) {
433
- parts.push(formatValue(at, "time", ctx.locale, ctx.timeZone));
434
- }
435
- if (typeof entry.level === "string" && entry.level !== "") {
436
- parts.push(`[${entry.level}]`);
437
- }
438
- if (typeof entry.text === "string")
439
- parts.push(entry.text);
440
- lines.push(parts.join(" ").trimEnd());
441
- }
442
- return lines;
443
- }
444
- /** `tree`: indented node labels. */
445
- function renderTree(block, ctx) {
446
- const raw = resolveBind(block.nodes, ctx);
447
- if (!Array.isArray(raw))
448
- return [];
449
- const lines = [];
450
- const walk = (node, depth) => {
451
- if (!isRecord(node))
452
- return;
453
- const label = typeof node.label === "string" ? node.label : "";
454
- lines.push(`${" ".repeat(depth * 2)}${label}`);
455
- if (Array.isArray(node.children)) {
456
- for (const child of node.children)
457
- walk(child, depth + 1);
458
- }
459
- };
460
- for (const node of raw)
461
- walk(node, 0);
462
- return lines;
463
- }
464
- /** `diff`: `- `/`+ ` prefixed before/after lines under the path. */
465
- function renderDiff(block) {
466
- const out = [];
467
- if (block.path !== undefined && block.path !== "")
468
- out.push(`--- ${block.path}`);
469
- for (const line of block.before.split("\n"))
470
- out.push(`- ${line}`);
471
- for (const line of block.after.split("\n"))
472
- out.push(`+ ${line}`);
473
- return out;
474
- }
475
- // ─── Collection ───────────────────────────────────────────────────────────
476
- /** Render a `collection` in its display's text shape. */
477
- function renderCollection(block, ctx) {
478
- const raw = block.items ? resolvePointer(ctx.data, block.items.$) : undefined;
479
- const items = (Array.isArray(raw) ? raw : []).filter(isRecord);
480
- const ordered = sortedItems(items, block.sort);
481
- if (block.display === "table" && block.columns !== undefined && block.columns.length > 0) {
482
- return renderTable(block.columns, ordered, ctx);
483
- }
484
- if (block.display === "grid")
485
- return renderGrid(block, ordered, ctx);
486
- if (block.display === "calendar" && block.time !== undefined) {
487
- return renderCalendar(block, ordered, ctx);
488
- }
489
- if (block.groupBy !== undefined && block.groupBy !== "") {
490
- return renderGrouped(block, ordered, ctx, block.display === "lanes");
491
- }
492
- return renderList(block, ordered, ctx, 0);
493
- }
494
- /** Apply the declared initial `sort` (stable; numeric when both are numbers). */
495
- function sortedItems(items, sort) {
496
- if (sort === undefined)
497
- return [...items];
498
- const rawOf = (item) => item[sort.field] ?? null;
499
- const sign = sort.dir === "desc" ? -1 : 1;
500
- return [...items].sort((a, b) => {
501
- const left = rawOf(a);
502
- const right = rawOf(b);
503
- if (typeof left === "number" && typeof right === "number") {
504
- if (left === right)
505
- return 0;
506
- return (left < right ? -1 : 1) * sign;
507
- }
508
- const leftKey = left === null ? "" : String(left);
509
- const rightKey = right === null ? "" : String(right);
510
- if (leftKey === rightKey)
511
- return 0;
512
- return (leftKey < rightKey ? -1 : 1) * sign;
513
- });
514
- }
515
- /**
516
- * Group items by `groupBy`: declared groups in declared order, then
517
- * unseen values in first-appearance ITEM order (never object-key
518
- * order). With no `groups`, every distinct value gets its own group.
519
- */
520
- function groupItems(block, items, ctx) {
521
- const by = block.groupBy;
522
- const groups = [];
523
- const indexById = new Map();
524
- for (const declared of block.groups ?? []) {
525
- indexById.set(declared.id, groups.length);
526
- groups.push({ title: bindText(declared.title, ctx), items: [] });
527
- }
528
- for (const item of items) {
529
- const value = item[by];
530
- const id = value === undefined || value === null ? "" : String(value);
531
- const index = indexById.get(id);
532
- if (index !== undefined) {
533
- groups[index].items.push(item);
534
- continue;
535
- }
536
- indexById.set(id, groups.length);
537
- groups.push({ title: id, items: [item] });
538
- }
539
- return groups;
540
- }
541
- /** Grouped rendering (`list` with `groupBy`, and `lanes` as sections). */
542
- function renderGrouped(block, items, ctx, asLanes) {
543
- const groups = [];
544
- for (const group of groupItems(block, items, ctx)) {
545
- const head = group.title !== "" ? [`## ${group.title}`] : [];
546
- const rows = group.items.map((item, index) => listRow(block, item, index + 1, ctx, asLanes ? 1 : 0));
547
- groups.push([...head, ...joinRows(rows)]);
548
- }
549
- return joinBlocks(groups);
550
- }
551
- /** Ungrouped `list` rows. */
552
- function renderList(block, items, ctx, depth) {
553
- const rows = items.map((item, index) => listRow(block, item, index + 1, ctx, depth));
554
- return joinRows(rows);
555
- }
556
- /** One list row: the headline with its prefixes, then the card slots. */
557
- function listRow(block, item, number, ctx, depth) {
558
- const indent = depth * 2;
559
- let prefix = "";
560
- if (block.numbered === true)
561
- prefix += `${number}. `;
562
- if (block.checkable !== undefined) {
563
- prefix += item[block.checkable] === true ? "[x] " : "[ ] ";
564
- }
565
- const title = bindText(block.item.title, ctx, item);
566
- const head = `${" ".repeat(indent)}${prefix}${title}`.trimEnd();
567
- const card = cardLines(block.item, item, ctx, indent + 2);
568
- const nested = [];
569
- if (block.children !== undefined && block.children !== "") {
570
- const raw = item[block.children];
571
- if (Array.isArray(raw)) {
572
- const children = raw.filter(isRecord);
573
- nested.push(...joinRows(children.map((child, index) => listRow(block, child, index + 1, ctx, depth + 1))));
574
- }
575
- }
576
- return [head, ...card, ...nested];
577
- }
578
- /**
579
- * The card slots below the title (§5.5.1 slot order): eyebrow above
580
- * the headline is folded into the caller; here subtitle, body, image,
581
- * badges, meta, metrics, status, deps. Tone, icon, actions and quiet
582
- * are visual or interactive and carry no text.
583
- */
584
- function cardLines(card, item, ctx, indent) {
585
- const lines = [];
586
- const pad = " ".repeat(indent);
587
- const push = (line) => {
588
- if (line.trim() !== "")
589
- lines.push(line);
590
- };
591
- const pushLabeled = (label, value) => {
592
- const line = cardLabeled(label, value);
593
- if (line !== null)
594
- lines.push(`${pad}${line}`);
595
- };
596
- const subtitle = bindText(card.subtitle, ctx, item);
597
- push(`${pad}${subtitle}`);
598
- const body = bindText(card.body, ctx, item);
599
- for (const line of wrap(body, Math.max(1, ctx.width - indent))) {
600
- push(`${pad}${line}`);
601
- }
602
- const image = bindText(card.image, ctx, item);
603
- pushLabeled("image", image);
604
- const badges = (card.badges ?? [])
605
- .map((badge) => bindText(badge.text, ctx, item))
606
- .filter((text) => text !== "");
607
- pushLabeled("badges", badges.join(", "));
608
- const metaParts = [];
609
- for (const entry of card.meta ?? []) {
610
- if ("person" in entry) {
611
- const name = bindText(entry.person.name, ctx, item);
612
- if (name !== "")
613
- metaParts.push(name);
614
- }
615
- else if ("time" in entry) {
616
- const at = resolveBind(entry.time, ctx, item);
617
- const text = at === undefined ? "" : fmt(at, entry.format ?? "datetime", ctx);
618
- if (text !== "")
619
- metaParts.push(text);
620
- }
621
- else {
622
- const text = bindText(entry.text, ctx, item);
623
- if (text !== "")
624
- metaParts.push(text);
625
- }
626
- }
627
- pushLabeled("meta", metaParts.join(" · "));
628
- for (const metric of card.metrics ?? []) {
629
- const label = bindText(metric.label, ctx, item);
630
- const value = fmt(resolveBind(metric.value, ctx, item), metric.format, ctx);
631
- pushLabeled(label, value);
632
- }
633
- if (card.status !== undefined) {
634
- pushLabeled("status", bindText(card.status.label, ctx, item));
635
- }
636
- const depsRaw = resolveBind(card.deps, ctx, item);
637
- if (Array.isArray(depsRaw)) {
638
- const deps = depsRaw.filter((dep) => typeof dep === "string");
639
- pushLabeled("after", deps.join(", "));
640
- }
641
- return lines;
642
- }
643
- /** `grid`: each item as its own small card. */
644
- function renderGrid(block, items, ctx) {
645
- return joinBlocks(items.map((item) => {
646
- const title = bindText(block.item.title, ctx, item);
647
- return [title, ...cardLines(block.item, item, ctx, 2)];
648
- }));
649
- }
650
- /** `calendar`: agenda lines — `start title` per item, in data order. */
651
- function renderCalendar(block, items, ctx) {
652
- const time = block.time;
653
- if (time === undefined)
654
- return [];
655
- const lines = [];
656
- for (const item of items) {
657
- const at = item[time.start];
658
- const when = at === undefined || at === null ? "" : `${fmt(at, "datetime", ctx)} `;
659
- const title = bindText(block.item.title, ctx, item);
660
- lines.push(`${when}${title}`.trimEnd());
661
- }
662
- return lines;
663
- }
664
- /** `table`: aligned columns, `priority: low` columns last. */
665
- function renderTable(columns, items, ctx) {
666
- const ordered = [
667
- ...columns.filter((column) => column.priority !== "low"),
668
- ...columns.filter((column) => column.priority === "low"),
669
- ];
670
- const header = ordered.map((column) => bindText(column.label, ctx));
671
- const rows = items.map((item) => ordered.map((column) => fmt(item[column.field], column.format, ctx)));
672
- const widths = ordered.map((_, index) => {
673
- let width = header[index].length;
674
- for (const row of rows)
675
- width = Math.max(width, row[index].length);
676
- return width;
677
- });
678
- const cells = (values) => values
679
- .map((value, index) => index === values.length - 1 ? value : value.padEnd(widths[index]))
680
- .join(" ");
681
- const lines = [cells(header)];
682
- lines.push(ordered.map((_, index) => "-".repeat(widths[index])).join(" "));
683
- for (const row of rows)
684
- lines.push(cells(row));
685
- return lines;
686
- }
687
- // ─── Form and fields ──────────────────────────────────────────────────────
688
- /** `form`: its fields (sections as `##` headings) and the submit label. */
689
- function renderForm(block, ctx) {
690
- const groups = [];
691
- const root = block.bind === "/" ? "" : block.bind;
692
- for (const child of block.children) {
693
- if (child.type === "section") {
694
- const head = [];
695
- const title = bindText(child.title, ctx);
696
- if (title !== "")
697
- head.push(`## ${title}`);
698
- const fields = child.children
699
- .filter((entry) => isField(entry))
700
- .map((field) => fieldLines(field, `${root}/${field.name}`, ctx));
701
- groups.push(joinBlocks([head, ...fields]));
702
- }
703
- else {
704
- groups.push(fieldLines(child, `${root}/${child.name}`, ctx));
705
- }
706
- }
707
- const submit = bindText(block.submitLabel, ctx);
708
- if (submit !== "")
709
- groups.push([`submit: ${submit}`]);
710
- return joinBlocks(groups);
711
- }
712
- /** One field's `label: value` lines (choices list their options). */
713
- function fieldLines(field, path, ctx) {
714
- const label = bindText(field.label, ctx);
715
- const value = resolvePointer(ctx.data, path);
716
- switch (field.type) {
717
- case "toggle":
718
- return [`${labeled(label, value === true ? "true" : "false")}`];
719
- case "secret":
720
- case "file": {
721
- const set = value !== undefined && value !== null && value !== "";
722
- return [labeled(label, set ? "set" : "not set")];
723
- }
724
- case "choice":
725
- return choiceLines(field, label, value, ctx);
726
- case "list": {
727
- const entries = Array.isArray(value) ? value : [];
728
- const lines = [labeled(label, "")];
729
- for (const entry of entries) {
730
- const text = entry === null || entry === undefined
731
- ? ""
732
- : typeof entry === "object"
733
- ? formatValue(entry, undefined, ctx.locale)
734
- : textOf(entry, ctx);
735
- lines.push(` - ${text}`);
736
- }
737
- return lines;
738
- }
739
- case "date":
740
- case "datetime":
741
- return [labeled(label, fmt(value, field.type, ctx))];
742
- case "number":
743
- return [labeled(label, fmt(value, field.format, ctx))];
744
- default:
745
- return [labeled(label, textOf(value, ctx))];
746
- }
747
- }
748
- /** A choice: the current selection plus its options as checkbox rows. */
749
- function choiceLines(field, label, value, ctx) {
750
- // Spec §5.6: the value is the selected id (single) or ids (multi);
751
- // with `allowOther` and text entered it is `{other: text}` (single)
752
- // or the ids plus `{other: text}` as the last element (multi).
753
- const selected = [];
754
- let other = null;
755
- const readEntry = (entry) => {
756
- if (typeof entry === "string")
757
- selected.push(entry);
758
- else if (isRecord(entry) &&
759
- typeof entry.other === "string" &&
760
- entry.other !== "") {
761
- other = entry.other;
762
- }
763
- };
764
- if (Array.isArray(value)) {
765
- for (const entry of value)
766
- readEntry(entry);
767
- }
768
- else if (value !== undefined) {
769
- readEntry(value);
770
- }
771
- const summary = other === null ? selected.join(", ") : [...selected, `other: ${other}`].join(", ");
772
- const lines = [labeled(label, summary)];
773
- const options = resolveBind(field.options, ctx);
774
- if (Array.isArray(options)) {
775
- for (const option of options) {
776
- if (!isRecord(option))
777
- continue;
778
- // SAFETY: `isRecord` proved a JsonObject; the option shape is
779
- // then read field-by-field with typeof guards, so the cast only
780
- // names the already-checked structure.
781
- const choice = option;
782
- if (typeof choice.id !== "string")
783
- continue;
784
- const mark = selected.includes(choice.id) ? "[x]" : "[ ]";
785
- const optionLabel = bindText(choice.label, ctx);
786
- lines.push(` ${mark} ${optionLabel}`);
787
- }
788
- }
789
- return lines;
790
- }