@terreno/blocks 57.12.0 → 57.14.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.
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +13 -1
- package/dist/interactive.d.ts +26 -0
- package/dist/interactive.js +62 -0
- package/dist/limits.d.ts +26 -0
- package/dist/limits.js +26 -0
- package/dist/lint.d.ts +18 -1
- package/dist/lint.js +186 -26
- package/dist/plainText.d.ts +17 -0
- package/dist/plainText.js +56 -0
- package/dist/prompt.d.ts +10 -1
- package/dist/prompt.js +73 -3
- package/dist/schema.d.ts +113 -2
- package/dist/schema.js +121 -5
- package/dist/validate.js +26 -2
- package/package.json +1 -1
- package/src/__snapshots__/prompt.test.ts.snap +530 -0
- package/src/errors.ts +4 -0
- package/src/fixtures/golden/sunday-roast.yaml +174 -0
- package/src/fixtures/invalid/card-eyebrow-empty.yaml +7 -0
- package/src/fixtures/invalid/card-eyebrow-too-long.yaml +7 -0
- package/src/fixtures/invalid/checklist-duplicate-item.yaml +9 -0
- package/src/fixtures/invalid/checklist-item-id-too-long.yaml +7 -0
- package/src/fixtures/invalid/checklist-reserved-id.yaml +16 -0
- package/src/fixtures/invalid/copy-missing-text-and-target.yaml +10 -0
- package/src/fixtures/invalid/copy-target.yaml +14 -0
- package/src/fixtures/invalid/gallery-caption-too-long.yaml +9 -0
- package/src/fixtures/invalid/gallery-image-host.yaml +10 -0
- package/src/fixtures/invalid/gallery-too-few.yaml +6 -0
- package/src/fixtures/invalid/list-image-host.yaml +12 -0
- package/src/fixtures/invalid/list-missing-title.yaml +6 -0
- package/src/fixtures/invalid/list-too-many.yaml +17 -0
- package/src/fixtures/invalid/stepper-decimals.yaml +14 -0
- package/src/fixtures/invalid/stepper-out-of-range.yaml +10 -0
- package/src/fixtures/invalid/stepper-reserved-id.yaml +19 -0
- package/src/fixtures/valid/card-eyebrow.yaml +8 -0
- package/src/fixtures/valid/checklist.yaml +29 -0
- package/src/fixtures/valid/copy.yaml +85 -0
- package/src/fixtures/valid/gallery.yaml +22 -0
- package/src/fixtures/valid/list.yaml +24 -0
- package/src/fixtures/valid/stepper.yaml +35 -0
- package/src/golden.test.ts +234 -0
- package/src/index.test.ts +9 -0
- package/src/index.ts +24 -0
- package/src/interactive.test.ts +141 -0
- package/src/interactive.ts +75 -0
- package/src/limits.ts +26 -0
- package/src/lint.test.ts +672 -0
- package/src/lint.ts +264 -33
- package/src/parse.test.ts +40 -0
- package/src/plainText.test.ts +158 -0
- package/src/plainText.ts +81 -0
- package/src/prompt.test.ts +325 -2
- package/src/prompt.ts +87 -2
- package/src/schema.ts +259 -6
- 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
|
|
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.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
+
});
|
package/src/plainText.ts
ADDED
|
@@ -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
|
+
};
|