@terreno/blocks 57.11.0 → 57.13.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 (58) hide show
  1. package/dist/errors.d.ts +2 -0
  2. package/dist/errors.js +2 -0
  3. package/dist/index.d.ts +7 -2
  4. package/dist/index.js +13 -1
  5. package/dist/interactive.d.ts +26 -0
  6. package/dist/interactive.js +62 -0
  7. package/dist/limits.d.ts +26 -0
  8. package/dist/limits.js +26 -0
  9. package/dist/lint.d.ts +18 -1
  10. package/dist/lint.js +186 -26
  11. package/dist/plainText.d.ts +17 -0
  12. package/dist/plainText.js +56 -0
  13. package/dist/prompt.d.ts +10 -1
  14. package/dist/prompt.js +73 -3
  15. package/dist/schema.d.ts +113 -2
  16. package/dist/schema.js +121 -5
  17. package/dist/validate.js +26 -2
  18. package/package.json +1 -1
  19. package/src/__snapshots__/prompt.test.ts.snap +530 -0
  20. package/src/errors.ts +4 -0
  21. package/src/fixtures/golden/sunday-roast.yaml +174 -0
  22. package/src/fixtures/invalid/card-eyebrow-empty.yaml +7 -0
  23. package/src/fixtures/invalid/card-eyebrow-too-long.yaml +7 -0
  24. package/src/fixtures/invalid/checklist-duplicate-item.yaml +9 -0
  25. package/src/fixtures/invalid/checklist-item-id-too-long.yaml +7 -0
  26. package/src/fixtures/invalid/checklist-reserved-id.yaml +16 -0
  27. package/src/fixtures/invalid/copy-missing-text-and-target.yaml +10 -0
  28. package/src/fixtures/invalid/copy-target.yaml +14 -0
  29. package/src/fixtures/invalid/gallery-caption-too-long.yaml +9 -0
  30. package/src/fixtures/invalid/gallery-image-host.yaml +10 -0
  31. package/src/fixtures/invalid/gallery-too-few.yaml +6 -0
  32. package/src/fixtures/invalid/list-image-host.yaml +12 -0
  33. package/src/fixtures/invalid/list-missing-title.yaml +6 -0
  34. package/src/fixtures/invalid/list-too-many.yaml +17 -0
  35. package/src/fixtures/invalid/stepper-decimals.yaml +14 -0
  36. package/src/fixtures/invalid/stepper-out-of-range.yaml +10 -0
  37. package/src/fixtures/invalid/stepper-reserved-id.yaml +19 -0
  38. package/src/fixtures/valid/card-eyebrow.yaml +8 -0
  39. package/src/fixtures/valid/checklist.yaml +29 -0
  40. package/src/fixtures/valid/copy.yaml +85 -0
  41. package/src/fixtures/valid/gallery.yaml +22 -0
  42. package/src/fixtures/valid/list.yaml +24 -0
  43. package/src/fixtures/valid/stepper.yaml +35 -0
  44. package/src/golden.test.ts +234 -0
  45. package/src/index.test.ts +9 -0
  46. package/src/index.ts +24 -0
  47. package/src/interactive.test.ts +141 -0
  48. package/src/interactive.ts +75 -0
  49. package/src/limits.ts +26 -0
  50. package/src/lint.test.ts +672 -0
  51. package/src/lint.ts +264 -33
  52. package/src/parse.test.ts +40 -0
  53. package/src/plainText.test.ts +158 -0
  54. package/src/plainText.ts +81 -0
  55. package/src/prompt.test.ts +325 -2
  56. package/src/prompt.ts +87 -2
  57. package/src/schema.ts +259 -6
  58. package/src/validate.ts +26 -2
package/src/lint.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import {BLOCK_ERROR_CODES, BLOCK_WARNING_CODES, type BlockError} from "./errors";
2
2
  import {BLOCK_LIMITS} from "./limits";
3
- import type {Block, BlocksDocument, Dataset, DatasetColumn, InlineDataset} from "./schema";
3
+ import {
4
+ type Block,
5
+ type BlocksDocument,
6
+ type ChecklistBlock,
7
+ COPY_TARGET_TYPES,
8
+ type Dataset,
9
+ type DatasetColumn,
10
+ type InlineDataset,
11
+ type StepperBlock,
12
+ } from "./schema";
4
13
 
5
14
  export interface KnownDataset {
6
15
  columns: DatasetColumn[];
@@ -9,10 +18,20 @@ export interface KnownDataset {
9
18
  export interface LintBlocksOptions {
10
19
  /** When false or omitted, an `html` block fails with `HTML_DISABLED`. */
11
20
  allowHtml?: boolean;
21
+ /**
22
+ * Host actions that handle a checklist. When set, a checklist `callback.name` outside it fails
23
+ * with `UNKNOWN_HOST_ACTION`.
24
+ */
25
+ checklistActions?: readonly string[];
12
26
  hostActions?: readonly string[];
13
27
  /** Hostnames allowed on `https` image `src` values. Empty means no https images. */
14
28
  imageHosts?: readonly string[];
15
29
  knownDatasets?: Record<string, KnownDataset>;
30
+ /**
31
+ * Host actions that handle a stepper. When set, a stepper `callback.name` outside it fails with
32
+ * `UNKNOWN_HOST_ACTION`, even when the name is in `hostActions`. Omitted, `hostActions` applies.
33
+ */
34
+ stepperActions?: readonly string[];
16
35
  }
17
36
 
18
37
  const issue = ({code, fix, message, path}: BlockError): BlockError => ({code, fix, message, path});
@@ -49,6 +68,118 @@ const imageSourceIssue = (
49
68
  });
50
69
  };
51
70
 
71
+ /** The element ids a stepper's − and + buttons use in callback events. */
72
+ export const stepperElementIds = (stepperId: string): {decrease: string; increase: string} => ({
73
+ decrease: `${stepperId}_decrease`,
74
+ increase: `${stepperId}_increase`,
75
+ });
76
+
77
+ /** The element id a checklist item's tick uses in callback events. */
78
+ export const checklistElementId = (checklistId: string, itemId: string): string =>
79
+ `${checklistId}_${itemId}`;
80
+
81
+ const unknownHostActionIssue = (
82
+ name: string,
83
+ hostActions: readonly string[] | undefined,
84
+ path: string
85
+ ): BlockError | undefined => {
86
+ if (hostActions === undefined || hostActions.includes(name)) {
87
+ return undefined;
88
+ }
89
+ return issue({
90
+ code: "UNKNOWN_HOST_ACTION",
91
+ fix: `Use one of: ${hostActions.join(", ") || "(none registered)"}.`,
92
+ message: BLOCK_ERROR_CODES.UNKNOWN_HOST_ACTION,
93
+ path,
94
+ });
95
+ };
96
+
97
+ const stepperIssues = (
98
+ block: StepperBlock,
99
+ path: string,
100
+ options: LintBlocksOptions | undefined
101
+ ): BlockError[] => {
102
+ const found: BlockError[] = [];
103
+ if (block.min >= block.max) {
104
+ found.push(
105
+ issue({
106
+ code: "OUT_OF_RANGE",
107
+ fix: "Set max above min.",
108
+ message: BLOCK_ERROR_CODES.OUT_OF_RANGE,
109
+ path: `${path}.max`,
110
+ })
111
+ );
112
+ } else if (block.value < block.min || block.value > block.max) {
113
+ found.push(
114
+ issue({
115
+ code: "OUT_OF_RANGE",
116
+ fix: `Set value between ${block.min} and ${block.max}.`,
117
+ message: BLOCK_ERROR_CODES.OUT_OF_RANGE,
118
+ path: `${path}.value`,
119
+ })
120
+ );
121
+ }
122
+ if (block.step !== undefined && block.step <= 0) {
123
+ found.push(
124
+ issue({
125
+ code: "OUT_OF_RANGE",
126
+ fix: "Set step above 0, or leave it out for 1.",
127
+ message: BLOCK_ERROR_CODES.OUT_OF_RANGE,
128
+ path: `${path}.step`,
129
+ })
130
+ );
131
+ }
132
+ const unknown = unknownHostActionIssue(
133
+ block.callback.name,
134
+ options?.stepperActions ?? options?.hostActions,
135
+ `${path}.callback.name`
136
+ );
137
+ if (unknown) {
138
+ found.push(unknown);
139
+ }
140
+ return found;
141
+ };
142
+
143
+ const checklistIssues = (
144
+ block: ChecklistBlock,
145
+ path: string,
146
+ options: LintBlocksOptions | undefined
147
+ ): BlockError[] => {
148
+ const found: BlockError[] = [];
149
+ const seenItemIds = new Set<string>();
150
+ block.items.forEach((item, index) => {
151
+ if (seenItemIds.has(item.id)) {
152
+ found.push(
153
+ issue({
154
+ code: "DUPLICATE_ID",
155
+ fix: `Give ${path}.items[${index}] an id no other item in this checklist uses.`,
156
+ message: BLOCK_ERROR_CODES.DUPLICATE_ID,
157
+ path: `${path}.items[${index}].id`,
158
+ })
159
+ );
160
+ }
161
+ seenItemIds.add(item.id);
162
+ });
163
+ if (block.callback === undefined) {
164
+ return found;
165
+ }
166
+ const unknown = unknownHostActionIssue(
167
+ block.callback.name,
168
+ options?.checklistActions ?? options?.hostActions,
169
+ `${path}.callback.name`
170
+ );
171
+ if (unknown) {
172
+ found.push(unknown);
173
+ }
174
+ return found;
175
+ };
176
+
177
+ const STEPPER_RESERVED_FIX = "A stepper uses <id>_decrease and <id>_increase for its buttons.";
178
+ const CHECKLIST_RESERVED_FIX = "A checklist uses <id>_<item id> for each item's tick.";
179
+
180
+ const copyTargetTypes: ReadonlySet<string> = new Set(COPY_TARGET_TYPES);
181
+ const COPY_TARGET_FIX = `Set target to the id of a ${COPY_TARGET_TYPES.slice(0, -1).join(", ")}, or ${COPY_TARGET_TYPES.at(-1)} block in this document.`;
182
+
52
183
  const isInline = (dataset: Dataset): dataset is InlineDataset => dataset.source !== "ref";
53
184
 
54
185
  const columnsFor = (
@@ -203,12 +334,74 @@ export const lintDocument = (
203
334
 
204
335
  const seenIds = new Set<string>();
205
336
  const selectableIds = new Set<string>();
337
+ // Block ids a copy action may target, collected first so a target may sit before or after it.
338
+ const copyTargetIds = new Set<string>();
339
+ // Element ids a renderer derives from a block id, mapped to the fix that explains the clash.
340
+ const reservedIds = new Map<string, {fix: string; ownerId: string}>();
341
+ // Two blocks deriving the same element id would send ambiguous callback events. A clash within
342
+ // one block id is a repeated item or block id, which DUPLICATE_ID already reports elsewhere.
343
+ const reserve = ({
344
+ elementId,
345
+ fix,
346
+ ownerId,
347
+ path,
348
+ }: {
349
+ elementId: string;
350
+ fix: string;
351
+ ownerId: string;
352
+ path: string;
353
+ }): void => {
354
+ const taken = reservedIds.get(elementId);
355
+ if (taken !== undefined && taken.ownerId !== ownerId) {
356
+ errors.push(
357
+ issue({
358
+ code: "DUPLICATE_ID",
359
+ fix: `Rename ${path}. Its element id ${elementId} is already used. ${taken.fix}`,
360
+ message: BLOCK_ERROR_CODES.DUPLICATE_ID,
361
+ path,
362
+ })
363
+ );
364
+ }
365
+ reservedIds.set(elementId, {fix, ownerId});
366
+ };
206
367
  const walked = walk(doc.blocks, "blocks");
207
- for (const {block} of walked) {
368
+ for (const {block, path} of walked) {
208
369
  if ((block.type === "chart" || block.type === "table") && block.id !== undefined) {
209
370
  selectableIds.add(block.id);
210
371
  }
372
+ if (copyTargetTypes.has(block.type) && block.id !== undefined) {
373
+ copyTargetIds.add(block.id);
374
+ }
375
+ if (block.type === "stepper") {
376
+ const {decrease, increase} = stepperElementIds(block.id);
377
+ for (const elementId of [decrease, increase]) {
378
+ reserve({elementId, fix: STEPPER_RESERVED_FIX, ownerId: block.id, path: `${path}.id`});
379
+ }
380
+ }
381
+ if (block.type === "checklist") {
382
+ block.items.forEach((item, index) => {
383
+ reserve({
384
+ elementId: checklistElementId(block.id, item.id),
385
+ fix: CHECKLIST_RESERVED_FIX,
386
+ ownerId: block.id,
387
+ path: `${path}.items[${index}].id`,
388
+ });
389
+ });
390
+ }
211
391
  }
392
+ const idIssue = (id: string, path: string): BlockError | undefined => {
393
+ const reservedFix = reservedIds.get(id)?.fix;
394
+ if (!seenIds.has(id) && reservedFix === undefined) {
395
+ return undefined;
396
+ }
397
+ return issue({
398
+ code: "DUPLICATE_ID",
399
+ fix:
400
+ reservedFix === undefined ? `Give ${path} a unique id.` : `Rename ${path}. ${reservedFix}`,
401
+ message: BLOCK_ERROR_CODES.DUPLICATE_ID,
402
+ path: `${path}.id`,
403
+ });
404
+ };
212
405
  for (const {block, path} of walked) {
213
406
  if (block.type === "html") {
214
407
  if (options?.allowHtml !== true) {
@@ -239,19 +432,46 @@ export const lintDocument = (
239
432
  errors.push(imageIssue);
240
433
  }
241
434
  }
242
- if (block.id !== undefined) {
243
- if (seenIds.has(block.id)) {
244
- errors.push(
245
- issue({
246
- code: "DUPLICATE_ID",
247
- fix: `Give ${path} a unique id.`,
248
- message: BLOCK_ERROR_CODES.DUPLICATE_ID,
249
- path: `${path}.id`,
250
- })
435
+ if (block.type === "gallery") {
436
+ block.images.forEach((image, index) => {
437
+ const tileIssue = imageSourceIssue(
438
+ image.src,
439
+ options?.imageHosts,
440
+ `${path}.images[${index}]`
441
+ );
442
+ if (tileIssue) {
443
+ errors.push(tileIssue);
444
+ }
445
+ });
446
+ }
447
+ if (block.type === "list") {
448
+ block.items.forEach((item, index) => {
449
+ if (item.image === undefined) {
450
+ return;
451
+ }
452
+ const thumbnailIssue = imageSourceIssue(
453
+ item.image.src,
454
+ options?.imageHosts,
455
+ `${path}.items[${index}].image`
251
456
  );
457
+ if (thumbnailIssue) {
458
+ errors.push(thumbnailIssue);
459
+ }
460
+ });
461
+ }
462
+ if (block.id !== undefined) {
463
+ const duplicate = idIssue(block.id, path);
464
+ if (duplicate) {
465
+ errors.push(duplicate);
252
466
  }
253
467
  seenIds.add(block.id);
254
468
  }
469
+ if (block.type === "stepper") {
470
+ errors.push(...stepperIssues(block, path, options));
471
+ }
472
+ if (block.type === "checklist") {
473
+ errors.push(...checklistIssues(block, path, options));
474
+ }
255
475
  if (block.type === "chart") {
256
476
  const hasPoints = block.points !== undefined;
257
477
  const hasData = block.data !== undefined || block.x !== undefined || block.y !== undefined;
@@ -371,15 +591,9 @@ export const lintDocument = (
371
591
  if (block.type === "actions") {
372
592
  block.elements.forEach((element, index) => {
373
593
  const elementPath = `${path}.elements[${index}]`;
374
- if (seenIds.has(element.id)) {
375
- errors.push(
376
- issue({
377
- code: "DUPLICATE_ID",
378
- fix: `Give ${elementPath} a unique id.`,
379
- message: BLOCK_ERROR_CODES.DUPLICATE_ID,
380
- path: `${elementPath}.id`,
381
- })
382
- );
594
+ const duplicate = idIssue(element.id, elementPath);
595
+ if (duplicate) {
596
+ errors.push(duplicate);
383
597
  }
384
598
  seenIds.add(element.id);
385
599
  if (element.type === "segmented" && !selectableIds.has(element.target)) {
@@ -406,20 +620,15 @@ export const lintDocument = (
406
620
  })
407
621
  );
408
622
  }
409
- if (
410
- element.type === "button" &&
411
- element.action.kind === "callback" &&
412
- options?.hostActions !== undefined &&
413
- !options.hostActions.includes(element.action.name)
414
- ) {
415
- errors.push(
416
- issue({
417
- code: "UNKNOWN_HOST_ACTION",
418
- fix: `Use one of: ${options.hostActions.join(", ") || "(none registered)"}.`,
419
- message: BLOCK_ERROR_CODES.UNKNOWN_HOST_ACTION,
420
- path: `${elementPath}.action.name`,
421
- })
623
+ if (element.type === "button" && element.action.kind === "callback") {
624
+ const unknown = unknownHostActionIssue(
625
+ element.action.name,
626
+ options?.hostActions,
627
+ `${elementPath}.action.name`
422
628
  );
629
+ if (unknown) {
630
+ errors.push(unknown);
631
+ }
423
632
  }
424
633
  if (element.type === "button" && element.action.kind === "open") {
425
634
  const hasRoute = element.action.route !== undefined;
@@ -435,6 +644,28 @@ export const lintDocument = (
435
644
  );
436
645
  }
437
646
  }
647
+ if (element.type === "button" && element.action.kind === "copy") {
648
+ const {target, text} = element.action;
649
+ if ((target === undefined) === (text === undefined)) {
650
+ errors.push(
651
+ issue({
652
+ code: "MISSING_REQUIRED",
653
+ fix: "Set either text or target on a copy action.",
654
+ message: BLOCK_ERROR_CODES.MISSING_REQUIRED,
655
+ path: `${elementPath}.action`,
656
+ })
657
+ );
658
+ } else if (target !== undefined && !copyTargetIds.has(target)) {
659
+ errors.push(
660
+ issue({
661
+ code: "COPY_TARGET_INVALID",
662
+ fix: COPY_TARGET_FIX,
663
+ message: BLOCK_ERROR_CODES.COPY_TARGET_INVALID,
664
+ path: `${elementPath}.action.target`,
665
+ })
666
+ );
667
+ }
668
+ }
438
669
  });
439
670
  }
440
671
  if (block.type === "table") {
package/src/parse.test.ts CHANGED
@@ -9,20 +9,42 @@ import {validateBlocks} from "./validate";
9
9
  const fixturesDir = join(import.meta.dir, "fixtures");
10
10
 
11
11
  const expectedInvalid: Record<string, {code: string; path: string}[]> = {
12
+ // An empty visibleText string trips both the min(1) and the blank-text checks.
13
+ "card-eyebrow-empty.yaml": [
14
+ {code: "TOO_SHORT", path: "blocks[0].eyebrow"},
15
+ {code: "TOO_SHORT", path: "blocks[0].eyebrow"},
16
+ ],
17
+ "card-eyebrow-too-long.yaml": [{code: "TOO_LONG", path: "blocks[0].eyebrow"}],
18
+ "checklist-duplicate-item.yaml": [{code: "DUPLICATE_ID", path: "blocks[0].items[1].id"}],
19
+ "checklist-item-id-too-long.yaml": [{code: "TOO_LONG", path: "blocks[0].items[0].id"}],
20
+ "checklist-reserved-id.yaml": [{code: "DUPLICATE_ID", path: "blocks[1].elements[0].id"}],
12
21
  "column-not-found.yaml": [{code: "COLUMN_NOT_FOUND", path: "blocks[0].x"}],
13
22
  "column-type.yaml": [{code: "COLUMN_TYPE_MISMATCH", path: "blocks[0].y"}],
23
+ "copy-missing-text-and-target.yaml": [
24
+ {code: "MISSING_REQUIRED", path: "blocks[0].elements[0].action"},
25
+ ],
26
+ "copy-target.yaml": [{code: "COPY_TARGET_INVALID", path: "blocks[1].elements[0].action.target"}],
14
27
  "dataset-not-found.yaml": [{code: "DATASET_NOT_FOUND", path: "blocks[0].data"}],
15
28
  "dataset-too-large.yaml": [{code: "DATASET_TOO_LARGE", path: "datasets.signups.columns"}],
16
29
  "depth-exceeded.yaml": [{code: "DEPTH_EXCEEDED", path: "blocks[0].children[0]"}],
17
30
  "duplicate-id.yaml": [{code: "DUPLICATE_ID", path: "blocks[1].id"}],
31
+ "gallery-caption-too-long.yaml": [{code: "TOO_LONG", path: "blocks[0].images[1].caption"}],
32
+ "gallery-image-host.yaml": [{code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[0].images[2].src"}],
33
+ "gallery-too-few.yaml": [{code: "TOO_FEW", path: "blocks[0].images"}],
18
34
  "invalid-enum.yaml": [{code: "INVALID_ENUM", path: "blocks[0].size"}],
19
35
  "key-order.yaml": [{code: "KEY_ORDER", path: "blocks"}],
36
+ "list-image-host.yaml": [{code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[0].items[1].image.src"}],
37
+ "list-missing-title.yaml": [{code: "MISSING_REQUIRED", path: "blocks[0].items[0].title"}],
38
+ "list-too-many.yaml": [{code: "TOO_MANY", path: "blocks[0].items"}],
20
39
  "missing-required.yaml": [{code: "MISSING_REQUIRED", path: "blocks[0].markdown"}],
21
40
  "not-a-document.txt": [{code: "NOT_A_DOCUMENT", path: ""}],
22
41
  "row-arity.yaml": [{code: "ROW_ARITY_MISMATCH", path: "datasets.signups.rows[0]"}],
23
42
  "select-target.yaml": [
24
43
  {code: "SELECT_TARGET_INVALID", path: "blocks[0].elements[0].action.target"},
25
44
  ],
45
+ "stepper-decimals.yaml": [{code: "OUT_OF_RANGE", path: "blocks[0].items[0].decimals"}],
46
+ "stepper-out-of-range.yaml": [{code: "OUT_OF_RANGE", path: "blocks[0].value"}],
47
+ "stepper-reserved-id.yaml": [{code: "DUPLICATE_ID", path: "blocks[1].elements[0].id"}],
26
48
  "table-too-wide.yaml": [{code: "TABLE_TOO_WIDE", path: "blocks[0].columns"}],
27
49
  "too-many-points.yaml": [{code: "TOO_MANY_POINTS", path: "datasets.signups.limit"}],
28
50
  "unknown-key.yaml": [{code: "UNKNOWN_KEY", path: "blocks[0].color"}],
@@ -279,3 +301,21 @@ describe("validateBlocks limits", () => {
279
301
  }
280
302
  });
281
303
  });
304
+
305
+ describe("card eyebrow", () => {
306
+ it("is accepted by validation, whatever the prompt offers", () => {
307
+ const parsed = parseBlocks(
308
+ readFileSync(join(fixturesDir, "valid", "card-eyebrow.yaml"), "utf8")
309
+ );
310
+ expect(parsed.ok).toBe(true);
311
+ if (!parsed.ok) {
312
+ return;
313
+ }
314
+ const validated = validateBlocks(parsed.value);
315
+ expect(validated.ok).toBe(true);
316
+ if (!validated.ok) {
317
+ return;
318
+ }
319
+ expect(validated.doc.blocks[0]).toMatchObject({eyebrow: "Your dinner plan", type: "card"});
320
+ });
321
+ });
@@ -0,0 +1,158 @@
1
+ import {describe, expect, it} from "bun:test";
2
+
3
+ import {blockPlainText} from "./plainText";
4
+ import type {
5
+ ChecklistBlock,
6
+ InlineDataset,
7
+ ListBlock,
8
+ StepperBlock,
9
+ TableBlock,
10
+ TextBlock,
11
+ } from "./schema";
12
+
13
+ const stepper: StepperBlock = {
14
+ callback: {name: "scaleStepper"},
15
+ id: "people",
16
+ items: [
17
+ {amount: 2, decimals: 1, label: "Bone-in leg of lamb", unit: "kg"},
18
+ {amount: 8, label: "Carrots"},
19
+ {amount: 0.625, decimals: 3, label: "Broccoli", unit: "kg"},
20
+ ],
21
+ itemsTitle: "Your shopping quantities",
22
+ label: "Number of people",
23
+ max: 20,
24
+ min: 1,
25
+ note: "Generous portions.",
26
+ type: "stepper",
27
+ unit: "People",
28
+ value: 5,
29
+ };
30
+
31
+ const checklist: ChecklistBlock = {
32
+ id: "cooking",
33
+ items: [
34
+ {checked: true, id: "oven", meta: "1:00 pm", text: "Preheat the oven"},
35
+ {detail: "Fat side up.", id: "lamb_in", text: "Put the lamb in"},
36
+ {id: "rest", text: "Rest the lamb"},
37
+ ],
38
+ title: "Cooking checklist",
39
+ type: "checklist",
40
+ };
41
+
42
+ const guests: InlineDataset = {
43
+ columns: [
44
+ {name: "guests", type: "number"},
45
+ {name: "lamb_kg", type: "number"},
46
+ {name: "note", type: "string"},
47
+ ],
48
+ rows: [
49
+ [4, 1.6, "Small"],
50
+ [6, 2.4, null],
51
+ ],
52
+ };
53
+
54
+ const table: TableBlock = {
55
+ data: "guests",
56
+ id: "lamb_table",
57
+ title: "How much lamb?",
58
+ type: "table",
59
+ };
60
+
61
+ describe("blockPlainText", () => {
62
+ describe("stepper", () => {
63
+ it("writes the label, value, and unit, then one line per item formatted with decimals", () => {
64
+ expect(blockPlainText(stepper)).toBe(
65
+ [
66
+ "Number of people: 5 People",
67
+ "Bone-in leg of lamb: 2.0 kg",
68
+ "Carrots: 8",
69
+ "Broccoli: 0.625 kg",
70
+ ].join("\n")
71
+ );
72
+ });
73
+
74
+ it("omits a missing unit without a trailing space and writes only the first line without items", () => {
75
+ const bare: StepperBlock = {...stepper, items: undefined, unit: undefined, value: 6};
76
+ expect(blockPlainText(bare)).toBe("Number of people: 6");
77
+ });
78
+
79
+ it("copies the block it is given, so an override's value and amounts are what is copied", () => {
80
+ const scaled: StepperBlock = {
81
+ ...stepper,
82
+ items: [{amount: 2.4, decimals: 1, label: "Bone-in leg of lamb", unit: "kg"}],
83
+ value: 6,
84
+ };
85
+ expect(blockPlainText(scaled)).toBe(
86
+ "Number of people: 6 People\nBone-in leg of lamb: 2.4 kg"
87
+ );
88
+ });
89
+ });
90
+
91
+ describe("checklist", () => {
92
+ it("writes one [x] or [ ] line per item from each item's checked", () => {
93
+ expect(blockPlainText(checklist)).toBe(
94
+ "[x] Preheat the oven\n[ ] Put the lamb in\n[ ] Rest the lamb"
95
+ );
96
+ });
97
+
98
+ it("uses the current toggles over each item's checked and ignores unknown ids", () => {
99
+ expect(
100
+ blockPlainText(checklist, {checked: {lamb_in: true, missing: true, oven: false}})
101
+ ).toBe("[ ] Preheat the oven\n[x] Put the lamb in\n[ ] Rest the lamb");
102
+ });
103
+ });
104
+
105
+ describe("list", () => {
106
+ it("writes one - title: text line per item, and - title when an item has no text", () => {
107
+ const list: ListBlock = {
108
+ items: [
109
+ {meta: "Main", text: "Rubbed with garlic.", title: "Roast leg of lamb"},
110
+ {image: {alt: "Mint", src: "file:mint"}, title: "Mint sauce"},
111
+ ],
112
+ type: "list",
113
+ };
114
+ expect(blockPlainText(list)).toBe("- Roast leg of lamb: Rubbed with garlic.\n- Mint sauce");
115
+ });
116
+ });
117
+
118
+ describe("table", () => {
119
+ it("writes a header row, then the rows, tab-separated, with null cells empty", () => {
120
+ expect(blockPlainText(table, {datasets: {guests}})).toBe(
121
+ "guests\tlamb kg\tnote\n4\t1.6\tSmall\n6\t2.4\t"
122
+ );
123
+ });
124
+
125
+ it("writes header names with underscores as spaces, as the table shows them", () => {
126
+ const header = blockPlainText(table, {datasets: {guests}}).split("\n")[0];
127
+ expect(header).toBe("guests\tlamb kg\tnote");
128
+ expect(header).not.toContain("_");
129
+ });
130
+
131
+ it("follows the table's columns in their order", () => {
132
+ expect(blockPlainText({...table, columns: ["lamb_kg", "guests"]}, {datasets: {guests}})).toBe(
133
+ "lamb kg\tguests\n1.6\t4\n2.4\t6"
134
+ );
135
+ });
136
+
137
+ it("returns an empty string when the dataset is missing or is an unresolved ref", () => {
138
+ expect(blockPlainText(table)).toBe("");
139
+ expect(blockPlainText(table, {datasets: {}})).toBe("");
140
+ expect(blockPlainText(table, {datasets: {guests: {id: "ds_1", source: "ref"}}})).toBe("");
141
+ });
142
+ });
143
+
144
+ describe("text", () => {
145
+ it("returns the markdown as written", () => {
146
+ const text: TextBlock = {
147
+ markdown: "**Tips**\n\n- Rest the lamb\n- Warm the plates",
148
+ type: "text",
149
+ };
150
+ expect(blockPlainText(text)).toBe("**Tips**\n\n- Rest the lamb\n- Warm the plates");
151
+ });
152
+ });
153
+
154
+ it("returns an empty string for a block a copy action cannot target", () => {
155
+ expect(blockPlainText({text: "Menu", type: "heading"})).toBe("");
156
+ expect(blockPlainText({children: [{markdown: "Hi", type: "text"}], type: "card"})).toBe("");
157
+ });
158
+ });
@@ -0,0 +1,81 @@
1
+ import type {Block, ChecklistBlock, Dataset, ListBlock, StepperBlock, TableBlock} from "./schema";
2
+
3
+ export interface BlockPlainTextOptions {
4
+ /**
5
+ * Current ticks by checklist item id. An entry overrides that item's `checked`; ids the
6
+ * checklist does not have are ignored.
7
+ */
8
+ checked?: Record<string, boolean>;
9
+ /** The document's datasets. A table copies only an inline dataset, so resolve refs first. */
10
+ datasets?: Record<string, Dataset>;
11
+ }
12
+
13
+ const withUnit = (value: string, unit: string | undefined): string =>
14
+ unit === undefined ? value : `${value} ${unit}`;
15
+
16
+ /** Matches the renderer: each amount is fixed to the item's `decimals`, so 2 at 1 is "2.0". */
17
+ const stepperText = (block: StepperBlock): string =>
18
+ [
19
+ `${block.label}: ${withUnit(String(block.value), block.unit)}`,
20
+ ...(block.items ?? []).map(
21
+ (item) => `${item.label}: ${withUnit(item.amount.toFixed(item.decimals ?? 0), item.unit)}`
22
+ ),
23
+ ].join("\n");
24
+
25
+ const checklistText = (
26
+ block: ChecklistBlock,
27
+ checked: Record<string, boolean> | undefined
28
+ ): string =>
29
+ block.items
30
+ .map((item) => `${(checked?.[item.id] ?? item.checked) === true ? "[x]" : "[ ]"} ${item.text}`)
31
+ .join("\n");
32
+
33
+ const listText = (block: ListBlock): string =>
34
+ block.items
35
+ .map((item) => (item.text === undefined ? `- ${item.title}` : `- ${item.title}: ${item.text}`))
36
+ .join("\n");
37
+
38
+ const cellText = (value: unknown): string => {
39
+ if (value === null || value === undefined) {
40
+ return "";
41
+ }
42
+ return String(value);
43
+ };
44
+
45
+ const tableText = (block: TableBlock, datasets: Record<string, Dataset> | undefined): string => {
46
+ const dataset = datasets?.[block.data];
47
+ if (dataset === undefined || dataset.source === "ref") {
48
+ return "";
49
+ }
50
+ const names = block.columns ?? dataset.columns.map((column) => column.name);
51
+ const indexes = names.map((name) => dataset.columns.findIndex((column) => column.name === name));
52
+ const rows = dataset.rows.map((row) =>
53
+ indexes.map((index) => (index < 0 ? "" : cellText(row[index]))).join("\t")
54
+ );
55
+ // The header matches the table on screen, which shows underscores in column names as spaces.
56
+ const header = names.map((name) => name.replaceAll("_", " ")).join("\t");
57
+ return [header, ...rows].join("\n");
58
+ };
59
+
60
+ /**
61
+ * The text a copy action writes to the clipboard for its target block. Pass the block as it is
62
+ * shown now (a host override applied) and the current checklist ticks in `checked`. Blocks a
63
+ * copy action cannot target return an empty string, as does a table whose dataset is missing or
64
+ * an unresolved ref.
65
+ */
66
+ export const blockPlainText = (block: Block, options: BlockPlainTextOptions = {}): string => {
67
+ switch (block.type) {
68
+ case "stepper":
69
+ return stepperText(block);
70
+ case "checklist":
71
+ return checklistText(block, options.checked);
72
+ case "list":
73
+ return listText(block);
74
+ case "table":
75
+ return tableText(block, options.datasets);
76
+ case "text":
77
+ return block.markdown;
78
+ default:
79
+ return "";
80
+ }
81
+ };