@terreno/blocks 57.12.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.test.ts CHANGED
@@ -287,3 +287,675 @@ describe("lintDocument", () => {
287
287
  expect(codes.has("DATASET_NOT_FOUND")).toBe(true);
288
288
  });
289
289
  });
290
+
291
+ const stepper = (fields: Record<string, unknown> = {}): Record<string, unknown> => ({
292
+ callback: {name: "scaleStepper"},
293
+ id: "guests",
294
+ label: "Number of people",
295
+ max: 20,
296
+ min: 1,
297
+ type: "stepper",
298
+ value: 5,
299
+ ...fields,
300
+ });
301
+
302
+ const codesAndPaths = (
303
+ doc: Record<string, unknown>,
304
+ options?: Parameters<typeof validateBlocks>[1]
305
+ ): {code: string; path: string}[] => {
306
+ const validated = validateBlocks(doc, options);
307
+ if (validated.ok) {
308
+ return [];
309
+ }
310
+ return validated.errors.map((error) => ({code: error.code, path: error.path}));
311
+ };
312
+
313
+ describe("stepper lint", () => {
314
+ it("checks the stepper callback name against hostActions", () => {
315
+ const doc = documentWith({blocks: [stepper()]});
316
+ expect(validateBlocks(doc).ok).toBe(true);
317
+ expect(validateBlocks(doc, {hostActions: ["scaleStepper"]}).ok).toBe(true);
318
+ const rejected = validateBlocks(doc, {hostActions: ["other"]});
319
+ expect(rejected.ok).toBe(false);
320
+ if (rejected.ok) {
321
+ return;
322
+ }
323
+ expect(rejected.errors.map((error) => ({code: error.code, path: error.path}))).toEqual([
324
+ {code: "UNKNOWN_HOST_ACTION", path: "blocks[0].callback.name"},
325
+ ]);
326
+ });
327
+
328
+ it("checks the stepper callback name against stepperActions when the host passes them", () => {
329
+ const doc = documentWith({blocks: [stepper()]});
330
+ const handled = validateBlocks(doc, {
331
+ hostActions: ["approve", "scaleStepper"],
332
+ stepperActions: ["scaleStepper"],
333
+ });
334
+ expect(handled.ok).toBe(true);
335
+ // Registered, but not a stepper action: its buttons would call a handler that is not for steppers.
336
+ const unhandled = validateBlocks(
337
+ documentWith({blocks: [stepper({callback: {name: "approve"}})]}),
338
+ {
339
+ hostActions: ["approve", "scaleStepper"],
340
+ stepperActions: ["scaleStepper"],
341
+ }
342
+ );
343
+ expect(unhandled.ok).toBe(false);
344
+ if (unhandled.ok) {
345
+ return;
346
+ }
347
+ expect(unhandled.errors).toEqual([
348
+ {
349
+ code: "UNKNOWN_HOST_ACTION",
350
+ fix: "Use one of: scaleStepper.",
351
+ message: expect.any(String),
352
+ path: "blocks[0].callback.name",
353
+ },
354
+ ]);
355
+ expect(codesAndPaths(doc, {hostActions: ["scaleStepper"], stepperActions: []})).toEqual([
356
+ {code: "UNKNOWN_HOST_ACTION", path: "blocks[0].callback.name"},
357
+ ]);
358
+ });
359
+
360
+ it("leaves a button callback to hostActions when stepperActions is set", () => {
361
+ const doc = documentWith({
362
+ blocks: [
363
+ {
364
+ elements: [
365
+ {
366
+ action: {kind: "callback", name: "approve"},
367
+ id: "ok",
368
+ text: "Approve",
369
+ type: "button",
370
+ },
371
+ ],
372
+ id: "row",
373
+ type: "actions",
374
+ },
375
+ ],
376
+ });
377
+ expect(
378
+ codesAndPaths(doc, {
379
+ checklistActions: [],
380
+ hostActions: ["approve", "scaleStepper"],
381
+ stepperActions: ["scaleStepper"],
382
+ })
383
+ ).toEqual([]);
384
+ });
385
+
386
+ it("reports OUT_OF_RANGE when value leaves min and max or min is not below max", () => {
387
+ expect(codesAndPaths(documentWith({blocks: [stepper({value: 0})]}))).toEqual([
388
+ {code: "OUT_OF_RANGE", path: "blocks[0].value"},
389
+ ]);
390
+ expect(codesAndPaths(documentWith({blocks: [stepper({max: 5, min: 5})]}))).toEqual([
391
+ {code: "OUT_OF_RANGE", path: "blocks[0].max"},
392
+ ]);
393
+ expect(codesAndPaths(documentWith({blocks: [stepper({value: 20})]}))).toEqual([]);
394
+ });
395
+
396
+ it("reserves the increase and decrease element ids before or after the stepper", () => {
397
+ const button = (id: string): Record<string, unknown> => ({
398
+ elements: [{action: {kind: "reply", text: "Hi"}, id, text: "Hi", type: "button"}],
399
+ id: "row",
400
+ type: "actions",
401
+ });
402
+ expect(codesAndPaths(documentWith({blocks: [button("guests_decrease"), stepper()]}))).toEqual([
403
+ {code: "DUPLICATE_ID", path: "blocks[0].elements[0].id"},
404
+ ]);
405
+ expect(
406
+ codesAndPaths(documentWith({blocks: [stepper(), {id: "guests_increase", type: "divider"}]}))
407
+ ).toEqual([{code: "DUPLICATE_ID", path: "blocks[1].id"}]);
408
+ });
409
+
410
+ it("caps the stepper id, items, decimals, and step with closed codes", () => {
411
+ expect(codesAndPaths(documentWith({blocks: [stepper({id: `g${"x".repeat(54)}`})]}))).toEqual([
412
+ {code: "TOO_LONG", path: "blocks[0].id"},
413
+ ]);
414
+ expect(codesAndPaths(documentWith({blocks: [stepper({id: `g${"x".repeat(53)}`})]}))).toEqual(
415
+ []
416
+ );
417
+ const items = Array.from({length: 13}, (_unused, index) => ({
418
+ amount: index,
419
+ label: `i${index}`,
420
+ }));
421
+ expect(codesAndPaths(documentWith({blocks: [stepper({items})]}))).toEqual([
422
+ {code: "TOO_MANY", path: "blocks[0].items"},
423
+ ]);
424
+ expect(codesAndPaths(documentWith({blocks: [stepper({step: 0})]}))).toEqual([
425
+ {code: "OUT_OF_RANGE", path: "blocks[0].step"},
426
+ ]);
427
+ const decimals = validateBlocks(
428
+ documentWith({blocks: [stepper({items: [{amount: 2, decimals: 4, label: "Lamb"}]})]})
429
+ );
430
+ expect(decimals.ok).toBe(false);
431
+ if (!decimals.ok) {
432
+ expect(decimals.errors[0]?.fix).toContain("3");
433
+ }
434
+ expect(
435
+ codesAndPaths(
436
+ documentWith({blocks: [stepper({items: [{amount: 2, decimals: -1, label: "Lamb"}]})]})
437
+ )
438
+ ).toEqual([{code: "OUT_OF_RANGE", path: "blocks[0].items[0].decimals"}]);
439
+ expect(
440
+ codesAndPaths(
441
+ documentWith({blocks: [stepper({items: [{amount: 2, label: "Lamb", round: "down"}]})]})
442
+ )
443
+ ).toEqual([{code: "INVALID_ENUM", path: "blocks[0].items[0].round"}]);
444
+ });
445
+ });
446
+
447
+ const checklist = (fields: Record<string, unknown> = {}): Record<string, unknown> => ({
448
+ id: "cooking",
449
+ items: [
450
+ {id: "oven", text: "Preheat the oven"},
451
+ {checked: true, id: "lamb", text: "Put the lamb in"},
452
+ ],
453
+ type: "checklist",
454
+ ...fields,
455
+ });
456
+
457
+ const checklistItems = (count: number): Record<string, unknown>[] =>
458
+ Array.from({length: count}, (_unused, index) => ({id: `i${index}`, text: `Step ${index}`}));
459
+
460
+ describe("checklist lint", () => {
461
+ it("accepts a checklist without a callback whatever actions the host registers", () => {
462
+ const doc = documentWith({blocks: [checklist()]});
463
+ expect(codesAndPaths(doc)).toEqual([]);
464
+ expect(codesAndPaths(doc, {checklistActions: [], hostActions: []})).toEqual([]);
465
+ });
466
+
467
+ it("checks the checklist callback name against hostActions when checklistActions is omitted", () => {
468
+ const doc = documentWith({blocks: [checklist({callback: {name: "toggleChecklist"}})]});
469
+ expect(codesAndPaths(doc)).toEqual([]);
470
+ expect(codesAndPaths(doc, {hostActions: ["toggleChecklist"]})).toEqual([]);
471
+ expect(codesAndPaths(doc, {hostActions: ["other"]})).toEqual([
472
+ {code: "UNKNOWN_HOST_ACTION", path: "blocks[0].callback.name"},
473
+ ]);
474
+ });
475
+
476
+ it("checks the checklist callback name against checklistActions when the host passes them", () => {
477
+ const options = {
478
+ checklistActions: ["toggleChecklist"],
479
+ hostActions: ["approve", "toggleChecklist"],
480
+ };
481
+ expect(
482
+ codesAndPaths(
483
+ documentWith({blocks: [checklist({callback: {name: "toggleChecklist"}})]}),
484
+ options
485
+ )
486
+ ).toEqual([]);
487
+ // Registered, but not a checklist action: a tick would call a handler that is not for checklists.
488
+ const unhandled = validateBlocks(
489
+ documentWith({blocks: [checklist({callback: {name: "approve"}})]}),
490
+ options
491
+ );
492
+ expect(unhandled.ok).toBe(false);
493
+ if (unhandled.ok) {
494
+ return;
495
+ }
496
+ expect(unhandled.errors).toEqual([
497
+ {
498
+ code: "UNKNOWN_HOST_ACTION",
499
+ fix: "Use one of: toggleChecklist.",
500
+ message: expect.any(String),
501
+ path: "blocks[0].callback.name",
502
+ },
503
+ ]);
504
+ expect(
505
+ codesAndPaths(documentWith({blocks: [checklist({callback: {name: "toggleChecklist"}})]}), {
506
+ checklistActions: [],
507
+ hostActions: ["toggleChecklist"],
508
+ })
509
+ ).toEqual([{code: "UNKNOWN_HOST_ACTION", path: "blocks[0].callback.name"}]);
510
+ });
511
+
512
+ it("does not let stepperActions decide a checklist callback, or checklistActions a stepper's", () => {
513
+ const doc = documentWith({
514
+ blocks: [checklist({callback: {name: "toggleChecklist"}}), stepper()],
515
+ });
516
+ expect(
517
+ codesAndPaths(doc, {
518
+ checklistActions: ["toggleChecklist"],
519
+ hostActions: ["scaleStepper", "toggleChecklist"],
520
+ stepperActions: ["scaleStepper"],
521
+ })
522
+ ).toEqual([]);
523
+ expect(
524
+ codesAndPaths(doc, {
525
+ checklistActions: ["scaleStepper"],
526
+ hostActions: ["scaleStepper", "toggleChecklist"],
527
+ stepperActions: ["toggleChecklist"],
528
+ })
529
+ ).toEqual([
530
+ {code: "UNKNOWN_HOST_ACTION", path: "blocks[0].callback.name"},
531
+ {code: "UNKNOWN_HOST_ACTION", path: "blocks[1].callback.name"},
532
+ ]);
533
+ });
534
+
535
+ it("rejects a repeated item id within one checklist but not across checklists", () => {
536
+ const repeated = checklist({
537
+ items: [
538
+ {id: "oven", text: "Preheat the oven"},
539
+ {id: "oven", text: "Turn the oven off"},
540
+ ],
541
+ });
542
+ expect(codesAndPaths(documentWith({blocks: [repeated]}))).toEqual([
543
+ {code: "DUPLICATE_ID", path: "blocks[0].items[1].id"},
544
+ ]);
545
+ expect(
546
+ codesAndPaths(
547
+ documentWith({
548
+ blocks: [checklist(), checklist({id: "prep", items: [{id: "oven", text: "Clean"}]})],
549
+ })
550
+ )
551
+ ).toEqual([]);
552
+ });
553
+
554
+ it("reserves <id>_<item id> before or after the checklist and names the checklist in the fix", () => {
555
+ const button = (id: string): Record<string, unknown> => ({
556
+ elements: [{action: {kind: "reply", text: "Hi"}, id, text: "Hi", type: "button"}],
557
+ id: "row",
558
+ type: "actions",
559
+ });
560
+ const before = validateBlocks(documentWith({blocks: [button("cooking_oven"), checklist()]}));
561
+ expect(before.ok).toBe(false);
562
+ if (before.ok) {
563
+ return;
564
+ }
565
+ expect(before.errors.map((error) => ({code: error.code, path: error.path}))).toEqual([
566
+ {code: "DUPLICATE_ID", path: "blocks[0].elements[0].id"},
567
+ ]);
568
+ expect(before.errors[0]?.fix).toContain("<id>_<item id>");
569
+ expect(
570
+ codesAndPaths(documentWith({blocks: [checklist(), {id: "cooking_lamb", type: "divider"}]}))
571
+ ).toEqual([{code: "DUPLICATE_ID", path: "blocks[1].id"}]);
572
+ });
573
+
574
+ it("reports a repeated block id once, not once per derived element id", () => {
575
+ expect(codesAndPaths(documentWith({blocks: [stepper(), stepper()]}))).toEqual([
576
+ {code: "DUPLICATE_ID", path: "blocks[1].id"},
577
+ ]);
578
+ expect(codesAndPaths(documentWith({blocks: [checklist(), checklist()]}))).toEqual([
579
+ {code: "DUPLICATE_ID", path: "blocks[1].id"},
580
+ ]);
581
+ });
582
+
583
+ it("rejects two ticks that would share an element id", () => {
584
+ const first = checklist({id: "a_b", items: [{id: "c", text: "One"}]});
585
+ const second = checklist({id: "a", items: [{id: "b_c", text: "Two"}]});
586
+ expect(codesAndPaths(documentWith({blocks: [first, second]}))).toEqual([
587
+ {code: "DUPLICATE_ID", path: "blocks[1].items[0].id"},
588
+ ]);
589
+ expect(
590
+ codesAndPaths(
591
+ documentWith({
592
+ blocks: [
593
+ stepper({id: "a_b"}),
594
+ checklist({id: "a", items: [{id: "b_increase", text: "Up"}]}),
595
+ ],
596
+ })
597
+ )
598
+ ).toEqual([{code: "DUPLICATE_ID", path: "blocks[1].items[0].id"}]);
599
+ expect(
600
+ codesAndPaths(
601
+ documentWith({
602
+ blocks: [
603
+ checklist({id: "a", items: [{id: "b_increase", text: "Up"}]}),
604
+ stepper({id: "a_b"}),
605
+ ],
606
+ })
607
+ )
608
+ ).toEqual([{code: "DUPLICATE_ID", path: "blocks[1].id"}]);
609
+ });
610
+
611
+ it("caps the checklist id, item ids, item count, and text fields with closed codes", () => {
612
+ expect(codesAndPaths(documentWith({blocks: [checklist({id: `c${"x".repeat(31)}`})]}))).toEqual([
613
+ {code: "TOO_LONG", path: "blocks[0].id"},
614
+ ]);
615
+ expect(codesAndPaths(documentWith({blocks: [checklist({id: `c${"x".repeat(30)}`})]}))).toEqual(
616
+ []
617
+ );
618
+ const itemWith = (fields: Record<string, unknown>): Record<string, unknown> =>
619
+ checklist({items: [{id: "oven", text: "Preheat", ...fields}]});
620
+ expect(codesAndPaths(documentWith({blocks: [itemWith({id: `i${"x".repeat(32)}`})]}))).toEqual([
621
+ {code: "TOO_LONG", path: "blocks[0].items[0].id"},
622
+ ]);
623
+ expect(codesAndPaths(documentWith({blocks: [itemWith({id: `i${"x".repeat(31)}`})]}))).toEqual(
624
+ []
625
+ );
626
+ expect(codesAndPaths(documentWith({blocks: [itemWith({id: "Oven"})]}))).toEqual([
627
+ {code: "INVALID_FORMAT", path: "blocks[0].items[0].id"},
628
+ ]);
629
+ expect(codesAndPaths(documentWith({blocks: [checklist({items: checklistItems(30)})]}))).toEqual(
630
+ []
631
+ );
632
+ expect(codesAndPaths(documentWith({blocks: [checklist({items: checklistItems(31)})]}))).toEqual(
633
+ [{code: "TOO_MANY", path: "blocks[0].items"}]
634
+ );
635
+ expect(codesAndPaths(documentWith({blocks: [checklist({items: []})]}))).toEqual([
636
+ {code: "TOO_FEW", path: "blocks[0].items"},
637
+ ]);
638
+ expect(codesAndPaths(documentWith({blocks: [checklist({title: "t".repeat(121)})]}))).toEqual([
639
+ {code: "TOO_LONG", path: "blocks[0].title"},
640
+ ]);
641
+ expect(codesAndPaths(documentWith({blocks: [itemWith({text: "t".repeat(121)})]}))).toEqual([
642
+ {code: "TOO_LONG", path: "blocks[0].items[0].text"},
643
+ ]);
644
+ expect(codesAndPaths(documentWith({blocks: [itemWith({detail: "d".repeat(281)})]}))).toEqual([
645
+ {code: "TOO_LONG", path: "blocks[0].items[0].detail"},
646
+ ]);
647
+ expect(codesAndPaths(documentWith({blocks: [itemWith({meta: "m".repeat(41)})]}))).toEqual([
648
+ {code: "TOO_LONG", path: "blocks[0].items[0].meta"},
649
+ ]);
650
+ expect(
651
+ codesAndPaths(
652
+ documentWith({
653
+ blocks: [
654
+ itemWith({detail: "d".repeat(280), meta: "m".repeat(40), text: "t".repeat(120)}),
655
+ ],
656
+ })
657
+ )
658
+ ).toEqual([]);
659
+ expect(codesAndPaths(documentWith({blocks: [itemWith({checked: "yes"})]}))).toEqual([
660
+ {code: "INVALID_TYPE", path: "blocks[0].items[0].checked"},
661
+ ]);
662
+ expect(codesAndPaths(documentWith({blocks: [checklist({items: [{id: "oven"}]})]}))).toEqual([
663
+ {code: "MISSING_REQUIRED", path: "blocks[0].items[0].text"},
664
+ ]);
665
+ });
666
+ });
667
+
668
+ const galleryImages = (count: number): Record<string, unknown>[] =>
669
+ Array.from({length: count}, (_unused, index) => ({alt: `Photo ${index}`, src: `file:p${index}`}));
670
+
671
+ const gallery = (fields: Record<string, unknown> = {}): Record<string, unknown> => ({
672
+ images: galleryImages(3),
673
+ type: "gallery",
674
+ ...fields,
675
+ });
676
+
677
+ describe("gallery lint", () => {
678
+ it("accepts data:image and file: tiles without imageHosts, with or without captions", () => {
679
+ const images = [
680
+ {alt: "Roast lamb", caption: "Roast lamb", src: "file:roast-lamb"},
681
+ {alt: "Potatoes", src: "data:image/png;base64,iVBORw0KGgo="},
682
+ ];
683
+ expect(codesAndPaths(documentWith({blocks: [gallery({id: "photos", images})]}))).toEqual([]);
684
+ });
685
+
686
+ it("names the tile path when a tile's https host is not in imageHosts", () => {
687
+ const images = [
688
+ {alt: "Lamb", src: "file:lamb"},
689
+ {alt: "Potatoes", src: "https://images.example.com/potatoes.jpg"},
690
+ {alt: "Carrots", src: "https://evil.example.net/carrots.jpg"},
691
+ ];
692
+ const doc = documentWith({blocks: [{type: "divider"}, gallery({images})]});
693
+ expect(codesAndPaths(doc)).toEqual([
694
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[1].images[1].src"},
695
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[1].images[2].src"},
696
+ ]);
697
+ expect(codesAndPaths(doc, {imageHosts: ["images.example.com"]})).toEqual([
698
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[1].images[2].src"},
699
+ ]);
700
+ expect(codesAndPaths(doc, {imageHosts: ["images.example.com", "EVIL.example.net"]})).toEqual(
701
+ []
702
+ );
703
+ });
704
+
705
+ it("applies the image src rules to tiles inside a card", () => {
706
+ const images = [
707
+ {alt: "Lamb", src: "http://images.example.com/lamb.jpg"},
708
+ {alt: "Potatoes", src: "file:"},
709
+ ];
710
+ const doc = documentWith({blocks: [{children: [gallery({images})], type: "card"}]});
711
+ expect(codesAndPaths(doc, {imageHosts: ["images.example.com"]})).toEqual([
712
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[0].children[0].images[0].src"},
713
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[0].children[0].images[1].src"},
714
+ ]);
715
+ });
716
+
717
+ it("caps the tile count, alt, and caption with closed codes", () => {
718
+ expect(codesAndPaths(documentWith({blocks: [gallery({images: galleryImages(2)})]}))).toEqual(
719
+ []
720
+ );
721
+ expect(codesAndPaths(documentWith({blocks: [gallery({images: galleryImages(6)})]}))).toEqual(
722
+ []
723
+ );
724
+ expect(codesAndPaths(documentWith({blocks: [gallery({images: galleryImages(1)})]}))).toEqual([
725
+ {code: "TOO_FEW", path: "blocks[0].images"},
726
+ ]);
727
+ expect(codesAndPaths(documentWith({blocks: [gallery({images: galleryImages(7)})]}))).toEqual([
728
+ {code: "TOO_MANY", path: "blocks[0].images"},
729
+ ]);
730
+ const tileWith = (fields: Record<string, unknown>): Record<string, unknown> =>
731
+ gallery({
732
+ images: [
733
+ {alt: "Lamb", src: "file:lamb", ...fields},
734
+ {alt: "Pots", src: "file:p"},
735
+ ],
736
+ });
737
+ expect(
738
+ codesAndPaths(
739
+ documentWith({blocks: [tileWith({alt: "a".repeat(200), caption: "c".repeat(120)})]})
740
+ )
741
+ ).toEqual([]);
742
+ expect(codesAndPaths(documentWith({blocks: [tileWith({alt: "a".repeat(201)})]}))).toEqual([
743
+ {code: "TOO_LONG", path: "blocks[0].images[0].alt"},
744
+ ]);
745
+ expect(codesAndPaths(documentWith({blocks: [tileWith({caption: "c".repeat(121)})]}))).toEqual([
746
+ {code: "TOO_LONG", path: "blocks[0].images[0].caption"},
747
+ ]);
748
+ expect(codesAndPaths(documentWith({blocks: [tileWith({caption: " "})]}))).toEqual([
749
+ {code: "TOO_SHORT", path: "blocks[0].images[0].caption"},
750
+ ]);
751
+ expect(codesAndPaths(documentWith({blocks: [tileWith({title: "Lamb"})]}))).toEqual([
752
+ {code: "UNKNOWN_KEY", path: "blocks[0].images[0].title"},
753
+ ]);
754
+ expect(
755
+ codesAndPaths(
756
+ documentWith({blocks: [gallery({images: [{src: "file:a"}, {alt: "B", src: "file:b"}]})]})
757
+ )
758
+ ).toEqual([{code: "MISSING_REQUIRED", path: "blocks[0].images[0].alt"}]);
759
+ });
760
+ });
761
+
762
+ const listItems = (count: number): Record<string, unknown>[] =>
763
+ Array.from({length: count}, (_unused, index) => ({title: `Dish ${index}`}));
764
+
765
+ const list = (fields: Record<string, unknown> = {}): Record<string, unknown> => ({
766
+ items: listItems(2),
767
+ type: "list",
768
+ ...fields,
769
+ });
770
+
771
+ describe("list lint", () => {
772
+ it("accepts items with and without text, meta, and a data:image or file: image", () => {
773
+ const items = [
774
+ {
775
+ image: {alt: "Roast lamb", src: "file:roast-lamb"},
776
+ meta: "Main",
777
+ text: "Rubbed with garlic.",
778
+ title: "Roast lamb",
779
+ },
780
+ {image: {alt: "Potatoes", src: "data:image/png;base64,iVBORw0KGgo="}, title: "Potatoes"},
781
+ {title: "Mint sauce"},
782
+ ];
783
+ expect(codesAndPaths(documentWith({blocks: [list({id: "menu", items})]}))).toEqual([]);
784
+ });
785
+
786
+ it("names the item image path when its https host is not in imageHosts", () => {
787
+ const items = [
788
+ {title: "Lamb"},
789
+ {image: {alt: "Potatoes", src: "https://images.example.com/potatoes.jpg"}, title: "Pots"},
790
+ {image: {alt: "Carrots", src: "https://evil.example.net/carrots.jpg"}, title: "Carrots"},
791
+ ];
792
+ const doc = documentWith({blocks: [{type: "divider"}, list({items})]});
793
+ expect(codesAndPaths(doc)).toEqual([
794
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[1].items[1].image.src"},
795
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[1].items[2].image.src"},
796
+ ]);
797
+ expect(codesAndPaths(doc, {imageHosts: ["images.example.com"]})).toEqual([
798
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[1].items[2].image.src"},
799
+ ]);
800
+ expect(codesAndPaths(doc, {imageHosts: ["images.example.com", "EVIL.example.net"]})).toEqual(
801
+ []
802
+ );
803
+ });
804
+
805
+ it("applies the image src rules to item images inside a card", () => {
806
+ const items = [
807
+ {image: {alt: "Lamb", src: "http://images.example.com/lamb.jpg"}, title: "Lamb"},
808
+ {image: {alt: "Potatoes", src: "file:"}, title: "Potatoes"},
809
+ ];
810
+ const doc = documentWith({blocks: [{children: [list({items})], type: "card"}]});
811
+ expect(codesAndPaths(doc, {imageHosts: ["images.example.com"]})).toEqual([
812
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[0].children[0].items[0].image.src"},
813
+ {code: "IMAGE_HOST_NOT_ALLOWED", path: "blocks[0].children[0].items[1].image.src"},
814
+ ]);
815
+ });
816
+
817
+ it("caps the item count and field lengths with closed codes", () => {
818
+ expect(codesAndPaths(documentWith({blocks: [list({items: listItems(1)})]}))).toEqual([]);
819
+ expect(codesAndPaths(documentWith({blocks: [list({items: listItems(12)})]}))).toEqual([]);
820
+ expect(codesAndPaths(documentWith({blocks: [list({items: []})]}))).toEqual([
821
+ {code: "TOO_FEW", path: "blocks[0].items"},
822
+ ]);
823
+ expect(codesAndPaths(documentWith({blocks: [list({items: listItems(13)})]}))).toEqual([
824
+ {code: "TOO_MANY", path: "blocks[0].items"},
825
+ ]);
826
+ const itemWith = (fields: Record<string, unknown>): Record<string, unknown> =>
827
+ list({items: [{title: "Lamb", ...fields}]});
828
+ expect(
829
+ codesAndPaths(
830
+ documentWith({
831
+ blocks: [
832
+ itemWith({
833
+ image: {alt: "a".repeat(200), src: "file:lamb"},
834
+ meta: "m".repeat(40),
835
+ text: "t".repeat(500),
836
+ title: "T".repeat(120),
837
+ }),
838
+ ],
839
+ })
840
+ )
841
+ ).toEqual([]);
842
+ expect(codesAndPaths(documentWith({blocks: [itemWith({title: "T".repeat(121)})]}))).toEqual([
843
+ {code: "TOO_LONG", path: "blocks[0].items[0].title"},
844
+ ]);
845
+ expect(codesAndPaths(documentWith({blocks: [itemWith({text: "t".repeat(501)})]}))).toEqual([
846
+ {code: "TOO_LONG", path: "blocks[0].items[0].text"},
847
+ ]);
848
+ expect(codesAndPaths(documentWith({blocks: [itemWith({meta: "m".repeat(41)})]}))).toEqual([
849
+ {code: "TOO_LONG", path: "blocks[0].items[0].meta"},
850
+ ]);
851
+ expect(
852
+ codesAndPaths(
853
+ documentWith({blocks: [itemWith({image: {alt: "a".repeat(201), src: "file:lamb"}})]})
854
+ )
855
+ ).toEqual([{code: "TOO_LONG", path: "blocks[0].items[0].image.alt"}]);
856
+ expect(codesAndPaths(documentWith({blocks: [itemWith({text: " "})]}))).toEqual([
857
+ {code: "TOO_SHORT", path: "blocks[0].items[0].text"},
858
+ ]);
859
+ expect(codesAndPaths(documentWith({blocks: [itemWith({id: "lamb"})]}))).toEqual([
860
+ {code: "UNKNOWN_KEY", path: "blocks[0].items[0].id"},
861
+ ]);
862
+ expect(
863
+ codesAndPaths(
864
+ documentWith({blocks: [itemWith({image: {alt: "Lamb", caption: "x", src: "file:a"}})]})
865
+ )
866
+ ).toEqual([{code: "UNKNOWN_KEY", path: "blocks[0].items[0].image.caption"}]);
867
+ expect(codesAndPaths(documentWith({blocks: [itemWith({image: {src: "file:a"}})]}))).toEqual([
868
+ {code: "MISSING_REQUIRED", path: "blocks[0].items[0].image.alt"},
869
+ ]);
870
+ });
871
+ });
872
+
873
+ const copyButton = (action: Record<string, unknown>): Record<string, unknown> => ({
874
+ elements: [{action, id: "copy_it", text: "Copy", type: "button"}],
875
+ id: "copy_actions",
876
+ type: "actions",
877
+ });
878
+
879
+ const COPY_TARGETS: Record<string, Record<string, unknown>> = {
880
+ checklist: {id: "target", items: [{id: "oven", text: "Preheat the oven"}], type: "checklist"},
881
+ list: {id: "target", items: [{title: "Mint sauce"}], type: "list"},
882
+ stepper: {
883
+ callback: {name: "scaleStepper"},
884
+ id: "target",
885
+ label: "Number of people",
886
+ max: 20,
887
+ min: 1,
888
+ type: "stepper",
889
+ value: 5,
890
+ },
891
+ table: {data: "signups", id: "target", type: "table"},
892
+ text: {id: "target", markdown: "**Shopping list**", type: "text"},
893
+ };
894
+
895
+ describe("copy action lint", () => {
896
+ it("accepts copy text, or a target naming a stepper, checklist, list, table, or text block", () => {
897
+ expect(
898
+ codesAndPaths(documentWith({blocks: [copyButton({kind: "copy", text: "2 kg lamb"})]}))
899
+ ).toEqual([]);
900
+ for (const [type, target] of Object.entries(COPY_TARGETS)) {
901
+ const datasets = type === "table" ? {signups: inline} : undefined;
902
+ const before = documentWith({
903
+ blocks: [copyButton({kind: "copy", target: "target"}), target],
904
+ datasets,
905
+ });
906
+ const after = documentWith({
907
+ blocks: [target, copyButton({kind: "copy", target: "target"})],
908
+ datasets,
909
+ });
910
+ const inCard = documentWith({
911
+ blocks: [{children: [target], type: "card"}, copyButton({kind: "copy", target: "target"})],
912
+ datasets,
913
+ });
914
+ expect({result: codesAndPaths(before), type}).toEqual({result: [], type});
915
+ expect({result: codesAndPaths(after), type}).toEqual({result: [], type});
916
+ expect({result: codesAndPaths(inCard), type}).toEqual({result: [], type});
917
+ }
918
+ });
919
+
920
+ it("requires exactly one of text or target, as an open action does", () => {
921
+ expect(codesAndPaths(documentWith({blocks: [copyButton({kind: "copy"})]}))).toEqual([
922
+ {code: "MISSING_REQUIRED", path: "blocks[0].elements[0].action"},
923
+ ]);
924
+ expect(
925
+ codesAndPaths(
926
+ documentWith({
927
+ blocks: [COPY_TARGETS.text, copyButton({kind: "copy", target: "target", text: "Hi"})],
928
+ })
929
+ )
930
+ ).toEqual([{code: "MISSING_REQUIRED", path: "blocks[1].elements[0].action"}]);
931
+ });
932
+
933
+ it("rejects a target that names another block type or no block", () => {
934
+ const heading = {id: "target", text: "Menu", type: "heading"};
935
+ expect(
936
+ codesAndPaths(documentWith({blocks: [heading, copyButton({kind: "copy", target: "target"})]}))
937
+ ).toEqual([{code: "COPY_TARGET_INVALID", path: "blocks[1].elements[0].action.target"}]);
938
+ expect(
939
+ codesAndPaths(documentWith({blocks: [copyButton({kind: "copy", target: "missing"})]}))
940
+ ).toEqual([{code: "COPY_TARGET_INVALID", path: "blocks[0].elements[0].action.target"}]);
941
+ // An action element id is not a block, so a copy button cannot target its own actions block's buttons.
942
+ expect(
943
+ codesAndPaths(documentWith({blocks: [copyButton({kind: "copy", target: "copy_it"})]}))
944
+ ).toEqual([{code: "COPY_TARGET_INVALID", path: "blocks[0].elements[0].action.target"}]);
945
+ });
946
+
947
+ it("caps copy text from BLOCK_LIMITS and never checks it against hostActions", () => {
948
+ const withText = (text: string): Record<string, unknown> =>
949
+ documentWith({blocks: [copyButton({kind: "copy", text})]});
950
+ expect(codesAndPaths(withText("a".repeat(4_000)), {hostActions: []})).toEqual([]);
951
+ expect(codesAndPaths(withText("a".repeat(4_001)))).toEqual([
952
+ {code: "TOO_LONG", path: "blocks[0].elements[0].action.text"},
953
+ ]);
954
+ expect(codesAndPaths(withText(" "))).toEqual([
955
+ {code: "TOO_SHORT", path: "blocks[0].elements[0].action.text"},
956
+ ]);
957
+ expect(
958
+ codesAndPaths(documentWith({blocks: [copyButton({kind: "copy", name: "x", text: "Hi"})]}))
959
+ ).toEqual([{code: "UNKNOWN_KEY", path: "blocks[0].elements[0].action.name"}]);
960
+ });
961
+ });