@terreno/blocks 57.10.1

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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/biome.jsonc +18 -0
  4. package/bunfig.toml +6 -0
  5. package/dist/asks/errors.d.ts +77 -0
  6. package/dist/asks/errors.js +364 -0
  7. package/dist/asks/files.d.ts +53 -0
  8. package/dist/asks/files.js +171 -0
  9. package/dist/asks/formValues.d.ts +32 -0
  10. package/dist/asks/formValues.js +229 -0
  11. package/dist/asks/headless.d.ts +227 -0
  12. package/dist/asks/headless.js +94 -0
  13. package/dist/asks/jsonSchema.d.ts +6 -0
  14. package/dist/asks/jsonSchema.js +62 -0
  15. package/dist/asks/limits.d.ts +58 -0
  16. package/dist/asks/limits.js +61 -0
  17. package/dist/asks/prompt.d.ts +10 -0
  18. package/dist/asks/prompt.js +144 -0
  19. package/dist/asks/schema.d.ts +826 -0
  20. package/dist/asks/schema.js +864 -0
  21. package/dist/asks/simpleCard.d.ts +85 -0
  22. package/dist/asks/simpleCard.js +279 -0
  23. package/dist/asks/validateInput.d.ts +13 -0
  24. package/dist/asks/validateInput.js +19 -0
  25. package/dist/asks/validateResponse.d.ts +13 -0
  26. package/dist/asks/validateResponse.js +278 -0
  27. package/dist/cli.d.ts +11 -0
  28. package/dist/cli.js +58 -0
  29. package/dist/errors.d.ts +51 -0
  30. package/dist/errors.js +106 -0
  31. package/dist/index.d.ts +31 -0
  32. package/dist/index.js +111 -0
  33. package/dist/jsonSchema.d.ts +5 -0
  34. package/dist/jsonSchema.js +12 -0
  35. package/dist/limits.d.ts +27 -0
  36. package/dist/limits.js +30 -0
  37. package/dist/lint.d.ts +21 -0
  38. package/dist/lint.js +388 -0
  39. package/dist/parse.d.ts +16 -0
  40. package/dist/parse.js +95 -0
  41. package/dist/parsePartial.d.ts +12 -0
  42. package/dist/parsePartial.js +105 -0
  43. package/dist/prompt.d.ts +9 -0
  44. package/dist/prompt.js +95 -0
  45. package/dist/schema.d.ts +199 -0
  46. package/dist/schema.js +277 -0
  47. package/dist/validate.d.ts +17 -0
  48. package/dist/validate.js +331 -0
  49. package/package.json +60 -0
  50. package/schemas/pendingAsksResponse.schema.json +195 -0
  51. package/schemas/simpleCard.schema.json +135 -0
  52. package/schemas/turnRequest.schema.json +108 -0
  53. package/schemas/turnResponse.schema.json +204 -0
  54. package/scripts/writeJsonSchemas.ts +11 -0
  55. package/src/asks/docParity.test.ts +65 -0
  56. package/src/asks/errors.test.ts +250 -0
  57. package/src/asks/errors.ts +449 -0
  58. package/src/asks/files.test.ts +294 -0
  59. package/src/asks/files.ts +216 -0
  60. package/src/asks/fixtures/invalid/choice-blank-text.json +20 -0
  61. package/src/asks/fixtures/invalid/choice-default-not-in-options.json +13 -0
  62. package/src/asks/fixtures/invalid/choice-default-selects-two.json +16 -0
  63. package/src/asks/fixtures/invalid/choice-description-too-long.json +24 -0
  64. package/src/asks/fixtures/invalid/choice-duplicate-ids.json +17 -0
  65. package/src/asks/fixtures/invalid/choice-label-too-long.json +23 -0
  66. package/src/asks/fixtures/invalid/choice-many-bounds-not-whole.json +17 -0
  67. package/src/asks/fixtures/invalid/choice-many-bounds-out-of-range.json +17 -0
  68. package/src/asks/fixtures/invalid/choice-many-default-duplicate.json +17 -0
  69. package/src/asks/fixtures/invalid/choice-many-default-too-many.json +15 -0
  70. package/src/asks/fixtures/invalid/choice-many-max-above-options.json +14 -0
  71. package/src/asks/fixtures/invalid/choice-many-min-above-choices.json +13 -0
  72. package/src/asks/fixtures/invalid/choice-many-min-above-max.json +15 -0
  73. package/src/asks/fixtures/invalid/choice-missing-required.json +14 -0
  74. package/src/asks/fixtures/invalid/choice-mixed-errors.json +19 -0
  75. package/src/asks/fixtures/invalid/choice-not-an-object.json +5 -0
  76. package/src/asks/fixtures/invalid/choice-one-with-many-fields.json +19 -0
  77. package/src/asks/fixtures/invalid/choice-option-id-format.json +35 -0
  78. package/src/asks/fixtures/invalid/choice-options-not-objects.json +12 -0
  79. package/src/asks/fixtures/invalid/choice-other-label-too-long.json +25 -0
  80. package/src/asks/fixtures/invalid/choice-other-label-without-allow-other.json +14 -0
  81. package/src/asks/fixtures/invalid/choice-prompt-empty.json +12 -0
  82. package/src/asks/fixtures/invalid/choice-prompt-too-long.json +23 -0
  83. package/src/asks/fixtures/invalid/choice-select-unknown.json +12 -0
  84. package/src/asks/fixtures/invalid/choice-submit-label-too-long.json +13 -0
  85. package/src/asks/fixtures/invalid/choice-title-too-long.json +24 -0
  86. package/src/asks/fixtures/invalid/choice-too-few-options.json +9 -0
  87. package/src/asks/fixtures/invalid/choice-too-many-options.json +219 -0
  88. package/src/asks/fixtures/invalid/choice-unknown-key-option.json +15 -0
  89. package/src/asks/fixtures/invalid/choice-unknown-key-root.json +13 -0
  90. package/src/asks/fixtures/invalid/choice-wrong-types.json +16 -0
  91. package/src/asks/fixtures/invalid/confirm-emoji-label-too-long.json +13 -0
  92. package/src/asks/fixtures/invalid/confirm-label-too-long.json +14 -0
  93. package/src/asks/fixtures/invalid/confirm-labels-alike-after-trim.json +14 -0
  94. package/src/asks/fixtures/invalid/confirm-labels-alike.json +13 -0
  95. package/src/asks/fixtures/invalid/confirm-missing-prompt-blank-label.json +16 -0
  96. package/src/asks/fixtures/invalid/confirm-unknown-keys.json +23 -0
  97. package/src/asks/fixtures/invalid/confirm-wrong-types.json +18 -0
  98. package/src/asks/fixtures/invalid/files-accept-duplicate.json +13 -0
  99. package/src/asks/fixtures/invalid/files-accept-empty.json +13 -0
  100. package/src/asks/fixtures/invalid/files-accept-unknown.json +13 -0
  101. package/src/asks/fixtures/invalid/files-counts-out-of-range.json +19 -0
  102. package/src/asks/fixtures/invalid/files-min-above-max.json +15 -0
  103. package/src/asks/fixtures/invalid/files-missing-accept-unknown-key.json +17 -0
  104. package/src/asks/fixtures/invalid/form-default-bad-format.json +40 -0
  105. package/src/asks/fixtures/invalid/form-default-invalid-date.json +40 -0
  106. package/src/asks/fixtures/invalid/form-default-not-in-options.json +58 -0
  107. package/src/asks/fixtures/invalid/form-default-out-of-range.json +33 -0
  108. package/src/asks/fixtures/invalid/form-default-text-bounds.json +42 -0
  109. package/src/asks/fixtures/invalid/form-default-wrong-type.json +30 -0
  110. package/src/asks/fixtures/invalid/form-duplicate-field-ids.json +24 -0
  111. package/src/asks/fixtures/invalid/form-field-id-format.json +19 -0
  112. package/src/asks/fixtures/invalid/form-field-text-limits.json +33 -0
  113. package/src/asks/fixtures/invalid/form-keys-of-another-type.json +39 -0
  114. package/src/asks/fixtures/invalid/form-length-above-cap.json +30 -0
  115. package/src/asks/fixtures/invalid/form-min-above-max.json +32 -0
  116. package/src/asks/fixtures/invalid/form-no-fields.json +13 -0
  117. package/src/asks/fixtures/invalid/form-select-options.json +44 -0
  118. package/src/asks/fixtures/invalid/form-too-many-fields.json +59 -0
  119. package/src/asks/fixtures/invalid/form-unknown-field-type.json +27 -0
  120. package/src/asks/fixtures/invalid/markdown-bounds-out-of-range.json +18 -0
  121. package/src/asks/fixtures/invalid/markdown-initial-above-cap.json +13 -0
  122. package/src/asks/fixtures/invalid/markdown-max-above-cap.json +13 -0
  123. package/src/asks/fixtures/invalid/markdown-min-above-cap.json +13 -0
  124. package/src/asks/fixtures/invalid/markdown-min-above-max.json +14 -0
  125. package/src/asks/fixtures/invalid/markdown-placeholder-too-long.json +13 -0
  126. package/src/asks/fixtures/invalid/markdown-wrong-types-unknown-key.json +27 -0
  127. package/src/asks/fixtures/valid/choice-emoji-label-cut.json +32 -0
  128. package/src/asks/fixtures/valid/choice-labels-collide-after-cut.json +21 -0
  129. package/src/asks/fixtures/valid/choice-long-text-cut.json +41 -0
  130. package/src/asks/fixtures/valid/choice-many-default-below-min.json +24 -0
  131. package/src/asks/fixtures/valid/choice-many-every-choice-with-other.json +24 -0
  132. package/src/asks/fixtures/valid/choice-many-no-default.json +21 -0
  133. package/src/asks/fixtures/valid/choice-many-optional-no-buttons.json +20 -0
  134. package/src/asks/fixtures/valid/choice-many-options-default-label-collides.json +23 -0
  135. package/src/asks/fixtures/valid/choice-many-options-no-buttons.json +21 -0
  136. package/src/asks/fixtures/valid/choice-many-options-no-default.json +22 -0
  137. package/src/asks/fixtures/valid/choice-many-options-with-default.json +30 -0
  138. package/src/asks/fixtures/valid/choice-many-required-with-default.json +29 -0
  139. package/src/asks/fixtures/valid/choice-many-single-pick-or-other.json +29 -0
  140. package/src/asks/fixtures/valid/choice-many-toppings-with-default.json +36 -0
  141. package/src/asks/fixtures/valid/choice-max-limits.json +245 -0
  142. package/src/asks/fixtures/valid/choice-plan-with-default.json +42 -0
  143. package/src/asks/fixtures/valid/choice-two-options-no-decline.json +32 -0
  144. package/src/asks/fixtures/valid/choice-two-options.json +32 -0
  145. package/src/asks/fixtures/valid/confirm-allow-decline.json +40 -0
  146. package/src/asks/fixtures/valid/confirm-default-labels.json +36 -0
  147. package/src/asks/fixtures/valid/confirm-destructive-delete.json +41 -0
  148. package/src/asks/fixtures/valid/confirm-emoji-labels.json +38 -0
  149. package/src/asks/fixtures/valid/confirm-long-text-cut.json +40 -0
  150. package/src/asks/fixtures/valid/files-every-type.json +28 -0
  151. package/src/asks/fixtures/valid/files-one-csv-no-decline.json +16 -0
  152. package/src/asks/fixtures/valid/files-receipt-photos.json +24 -0
  153. package/src/asks/fixtures/valid/form-boolean-newsletter.json +30 -0
  154. package/src/asks/fixtures/valid/form-date-due.json +44 -0
  155. package/src/asks/fixtures/valid/form-datetime-meeting.json +44 -0
  156. package/src/asks/fixtures/valid/form-datetime-minutes-offset.json +44 -0
  157. package/src/asks/fixtures/valid/form-eight-fields.json +71 -0
  158. package/src/asks/fixtures/valid/form-email-contact.json +44 -0
  159. package/src/asks/fixtures/valid/form-invoice-details.json +68 -0
  160. package/src/asks/fixtures/valid/form-multiselect-channels.json +64 -0
  161. package/src/asks/fixtures/valid/form-number-budget.json +45 -0
  162. package/src/asks/fixtures/valid/form-phone-callback.json +44 -0
  163. package/src/asks/fixtures/valid/form-select-shipping.json +58 -0
  164. package/src/asks/fixtures/valid/form-text-required-default.json +47 -0
  165. package/src/asks/fixtures/valid/form-textarea-notes.json +23 -0
  166. package/src/asks/fixtures/valid/form-time-reminder.json +44 -0
  167. package/src/asks/fixtures/valid/form-url-website.json +30 -0
  168. package/src/asks/fixtures/valid/markdown-announcement-draft.json +39 -0
  169. package/src/asks/fixtures/valid/markdown-empty-draft.json +34 -0
  170. package/src/asks/fixtures/valid/markdown-initial-blank-below-min.json +24 -0
  171. package/src/asks/fixtures/valid/markdown-initial-over-max.json +24 -0
  172. package/src/asks/fixtures/valid/markdown-long-text-cut.json +37 -0
  173. package/src/asks/fixtures/valid/markdown-no-decline.json +28 -0
  174. package/src/asks/fixtures/valid/markdown-no-initial-with-min.json +25 -0
  175. package/src/asks/formValues.ts +320 -0
  176. package/src/asks/headless.test.ts +146 -0
  177. package/src/asks/headless.ts +130 -0
  178. package/src/asks/jsonSchema.test.ts +171 -0
  179. package/src/asks/jsonSchema.ts +89 -0
  180. package/src/asks/limits.ts +58 -0
  181. package/src/asks/prompt.test.ts +200 -0
  182. package/src/asks/prompt.ts +173 -0
  183. package/src/asks/schema.test.ts +198 -0
  184. package/src/asks/schema.ts +1203 -0
  185. package/src/asks/simpleCard.test.ts +998 -0
  186. package/src/asks/simpleCard.ts +341 -0
  187. package/src/asks/validateInput.test.ts +875 -0
  188. package/src/asks/validateInput.ts +24 -0
  189. package/src/asks/validateResponse.test.ts +740 -0
  190. package/src/asks/validateResponse.ts +341 -0
  191. package/src/blocksDocParity.test.ts +21 -0
  192. package/src/cli.test.ts +134 -0
  193. package/src/cli.ts +65 -0
  194. package/src/display.test.ts +68 -0
  195. package/src/errors.test.ts +29 -0
  196. package/src/errors.ts +125 -0
  197. package/src/fixtures/invalid/column-not-found.yaml +14 -0
  198. package/src/fixtures/invalid/column-type.yaml +14 -0
  199. package/src/fixtures/invalid/dataset-not-found.yaml +7 -0
  200. package/src/fixtures/invalid/dataset-too-large.yaml +20 -0
  201. package/src/fixtures/invalid/depth-exceeded.yaml +10 -0
  202. package/src/fixtures/invalid/duplicate-id.yaml +6 -0
  203. package/src/fixtures/invalid/invalid-enum.yaml +5 -0
  204. package/src/fixtures/invalid/key-order.yaml +4 -0
  205. package/src/fixtures/invalid/missing-required.yaml +3 -0
  206. package/src/fixtures/invalid/not-a-document.txt +1 -0
  207. package/src/fixtures/invalid/row-arity.yaml +11 -0
  208. package/src/fixtures/invalid/select-target.yaml +12 -0
  209. package/src/fixtures/invalid/table-too-wide.yaml +9 -0
  210. package/src/fixtures/invalid/too-many-points.yaml +8 -0
  211. package/src/fixtures/invalid/unknown-key.yaml +5 -0
  212. package/src/fixtures/invalid/unsupported-version.yaml +4 -0
  213. package/src/fixtures/invalid/yaml-anchor.yaml +6 -0
  214. package/src/fixtures/partial/cut-after-divider.yaml +5 -0
  215. package/src/fixtures/valid/actions.yaml +32 -0
  216. package/src/fixtures/valid/chart.yaml +20 -0
  217. package/src/fixtures/valid/layout.yaml +26 -0
  218. package/src/fixtures/valid/prose.yaml +4 -0
  219. package/src/html.test.ts +52 -0
  220. package/src/index.test.ts +114 -0
  221. package/src/index.ts +175 -0
  222. package/src/jsonSchema.test.ts +33 -0
  223. package/src/jsonSchema.ts +11 -0
  224. package/src/limits.ts +27 -0
  225. package/src/lint.test.ts +289 -0
  226. package/src/lint.ts +484 -0
  227. package/src/parse.test.ts +281 -0
  228. package/src/parse.ts +104 -0
  229. package/src/parsePartial.test.ts +53 -0
  230. package/src/parsePartial.ts +116 -0
  231. package/src/prompt.test.ts +24 -0
  232. package/src/prompt.ts +104 -0
  233. package/src/schema.ts +509 -0
  234. package/src/tests/askFixtures.ts +40 -0
  235. package/src/validate.perf.test.ts +43 -0
  236. package/src/validate.ts +369 -0
  237. package/tsconfig.json +18 -0
@@ -0,0 +1,341 @@
1
+ import {
2
+ type AskErrorDraft,
3
+ type AskValidationError,
4
+ finalizeAskErrors,
5
+ formatAskPath,
6
+ issuesToAskErrors,
7
+ quoteValue,
8
+ } from "./errors";
9
+ import {acceptedFileMimeTypes, base64ByteLength, fileTooLargeDraft, parseAskDataUrl} from "./files";
10
+ import {checkFormValue, isBlankFormValue} from "./formValues";
11
+ import {ASK_LIMITS} from "./limits";
12
+ import {
13
+ type Ask,
14
+ type AskKind,
15
+ askAllowsDecline,
16
+ askResponseSchema,
17
+ type ChoiceAskInput,
18
+ choiceAnswerSchema,
19
+ choiceSelectionBounds,
20
+ confirmAnswerSchema,
21
+ type FilesAskInput,
22
+ type FormAskInput,
23
+ filesAnswerSchema,
24
+ filesCountBounds,
25
+ formAnswerEnvelopeSchema,
26
+ type MarkdownAskInput,
27
+ markdownAnswerSchema,
28
+ markdownLengthBounds,
29
+ } from "./schema";
30
+
31
+ const countText = (count: number): string => (count === 1 ? "1 option" : `${count} options`);
32
+
33
+ /** "exactly 2 options", "1 to 3 options", or "at most 3 options". */
34
+ const boundsText = ({max, min}: {max: number; min: number}): string => {
35
+ if (min === max) {
36
+ return `exactly ${countText(min)}`;
37
+ }
38
+ if (min === 0) {
39
+ return `at most ${countText(max)}`;
40
+ }
41
+ return `${min} to ${countText(max)}`;
42
+ };
43
+
44
+ const selectionCountDraft = ({
45
+ count,
46
+ hasOther,
47
+ input,
48
+ }: {
49
+ count: number;
50
+ hasOther: boolean;
51
+ input: ChoiceAskInput;
52
+ }): AskErrorDraft => {
53
+ const bounds = choiceSelectionBounds(input);
54
+ if (input.select === "one") {
55
+ return {
56
+ code: "SELECTION_COUNT",
57
+ fix: "Send exactly one option id in content.selected.",
58
+ message: `Choose exactly one option; the answer selects ${count}.`,
59
+ segments: ["content", "selected"],
60
+ };
61
+ }
62
+ const otherNote = input.allowOther === true ? " Other counts as one choice." : "";
63
+ return {
64
+ code: "SELECTION_COUNT",
65
+ fix: `Send ${boundsText(bounds)} in content.selected.${otherNote}`,
66
+ message: `Choose ${boundsText(bounds)}; the answer selects ${count}${hasOther ? ", counting Other" : ""}.`,
67
+ segments: ["content", "selected"],
68
+ };
69
+ };
70
+
71
+ const validateChoiceAnswer = (input: ChoiceAskInput, content: unknown): AskErrorDraft[] => {
72
+ const parsed = choiceAnswerSchema.safeParse(content);
73
+ if (!parsed.success) {
74
+ return issuesToAskErrors({issues: parsed.error.issues, prefix: ["content"], root: content});
75
+ }
76
+ const {other, selected} = parsed.data;
77
+ const drafts: AskErrorDraft[] = [];
78
+ const isOtherAllowed = input.allowOther === true;
79
+ if (other !== undefined && !isOtherAllowed) {
80
+ drafts.push({
81
+ code: "OTHER_NOT_ALLOWED",
82
+ fix: "Remove content.other and pick from the options.",
83
+ message: "This ask does not accept an Other answer.",
84
+ segments: ["content", "other"],
85
+ });
86
+ }
87
+ const hasOther = other !== undefined && isOtherAllowed;
88
+ const count = selected.length + (hasOther ? 1 : 0);
89
+ const {max, min} = choiceSelectionBounds(input);
90
+ if (count < min || count > max) {
91
+ drafts.push(selectionCountDraft({count, hasOther, input}));
92
+ }
93
+ const offeredIds = new Set(input.options.map((option) => option.id));
94
+ const firstIndexById = new Map<string, number>();
95
+ selected.forEach((id, index) => {
96
+ const firstIndex = firstIndexById.get(id);
97
+ if (firstIndex !== undefined) {
98
+ drafts.push({
99
+ code: "DUPLICATE_ID",
100
+ fix: "List each option id in content.selected once.",
101
+ message: `${quoteValue(id)} is already selected at ${formatAskPath(["content", "selected", firstIndex])}.`,
102
+ segments: ["content", "selected", index],
103
+ });
104
+ return;
105
+ }
106
+ firstIndexById.set(id, index);
107
+ if (offeredIds.has(id)) {
108
+ return;
109
+ }
110
+ drafts.push({
111
+ code: "OPTION_NOT_OFFERED",
112
+ fix: "Use the id of one of the ask's options.",
113
+ message: `${quoteValue(id)} is not one of the offered options.`,
114
+ segments: ["content", "selected", index],
115
+ });
116
+ });
117
+ return drafts;
118
+ };
119
+
120
+ const validateConfirmAnswer = (content: unknown): AskErrorDraft[] => {
121
+ const parsed = confirmAnswerSchema.safeParse(content);
122
+ if (parsed.success) {
123
+ return [];
124
+ }
125
+ return issuesToAskErrors({issues: parsed.error.issues, prefix: ["content"], root: content});
126
+ };
127
+
128
+ const characters = (count: number): string => (count === 1 ? "1 character" : `${count} characters`);
129
+
130
+ /**
131
+ * The text must fit the ask's bounds, and `changed` must say truthfully whether it differs from
132
+ * the draft, so the agent can trust `changed: false` to mean the user approved the draft as is.
133
+ */
134
+ const markdownTooLong = (length: number, max: number): AskErrorDraft => ({
135
+ code: "TOO_LONG",
136
+ fix: `Shorten content.markdown to ${characters(max)} or fewer.`,
137
+ message: `The text is ${characters(length)}, but this ask allows at most ${max}.`,
138
+ segments: ["content", "markdown"],
139
+ });
140
+
141
+ const isMarkdownTooLong = ({code, segments}: AskErrorDraft): boolean =>
142
+ code === "TOO_LONG" &&
143
+ segments.length === 2 &&
144
+ segments[0] === "content" &&
145
+ segments[1] === "markdown";
146
+
147
+ const validateMarkdownAnswer = (input: MarkdownAskInput, content: unknown): AskErrorDraft[] => {
148
+ const {max, min} = markdownLengthBounds(input);
149
+ const parsed = markdownAnswerSchema.safeParse(content);
150
+ if (!parsed.success) {
151
+ const sent =
152
+ typeof content === "object" && content !== null
153
+ ? (content as {markdown?: unknown}).markdown
154
+ : undefined;
155
+ const length = typeof sent === "string" ? sent.length : 0;
156
+ // The schema's 20,000 cap fires before the ask's own maxLength, so the fix names the tighter one.
157
+ return issuesToAskErrors({issues: parsed.error.issues, prefix: ["content"], root: content}).map(
158
+ (draft) => (isMarkdownTooLong(draft) ? markdownTooLong(length, max) : draft)
159
+ );
160
+ }
161
+ const {changed, markdown} = parsed.data;
162
+ const drafts: AskErrorDraft[] = [];
163
+ if (markdown.length > max) {
164
+ drafts.push(markdownTooLong(markdown.length, max));
165
+ }
166
+ const visibleLength = markdown.trim().length;
167
+ if (visibleLength < min) {
168
+ drafts.push({
169
+ code: "TOO_SHORT",
170
+ fix: `Write at least ${characters(min)} in content.markdown.`,
171
+ message: `The text is ${characters(visibleLength)}, but this ask needs at least ${min}.`,
172
+ segments: ["content", "markdown"],
173
+ });
174
+ }
175
+ const isChanged = markdown !== (input.initial ?? "");
176
+ if (changed !== isChanged) {
177
+ drafts.push({
178
+ code: "CHANGED_MISMATCH",
179
+ fix: `Set content.changed to ${String(isChanged)}.`,
180
+ message: isChanged
181
+ ? "The text differs from the draft, but changed is false."
182
+ : "The text is the same as the draft, but changed is true.",
183
+ segments: ["content", "changed"],
184
+ });
185
+ }
186
+ return drafts;
187
+ };
188
+
189
+ /**
190
+ * Every value must belong to a field, every required field needs a non-blank value, and every
191
+ * value must be one its field accepts. A blank value on an optional field counts as unanswered.
192
+ */
193
+ const validateFormAnswer = (input: FormAskInput, content: unknown): AskErrorDraft[] => {
194
+ const parsed = formAnswerEnvelopeSchema.safeParse(content);
195
+ if (!parsed.success) {
196
+ return issuesToAskErrors({issues: parsed.error.issues, prefix: ["content"], root: content});
197
+ }
198
+ const {values} = parsed.data;
199
+ const fieldIds = new Set(input.fields.map((field) => field.id));
200
+ const drafts: AskErrorDraft[] = Object.keys(values)
201
+ .filter((id) => !fieldIds.has(id))
202
+ .map((id) => ({
203
+ code: "UNKNOWN_KEY",
204
+ fix: `Remove ${quoteValue(id)} from content.values.`,
205
+ message: `${quoteValue(id)} is not the id of any field in this form.`,
206
+ segments: ["content", "values", id],
207
+ }));
208
+ for (const field of input.fields) {
209
+ const segments = ["content", "values", field.id];
210
+ const value = Object.hasOwn(values, field.id) ? values[field.id] : undefined;
211
+ if (value === undefined || isBlankFormValue(value)) {
212
+ if (field.required === true) {
213
+ drafts.push({
214
+ code: "REQUIRED_FIELD",
215
+ fix: `Fill in ${quoteValue(field.label)} (content.values.${field.id}).`,
216
+ message: `${quoteValue(field.label)} is required.`,
217
+ segments,
218
+ });
219
+ }
220
+ continue;
221
+ }
222
+ drafts.push(...checkFormValue({field, isDefault: false, segments, value}));
223
+ }
224
+ return drafts;
225
+ };
226
+
227
+ const fileCountText = (count: number): string => (count === 1 ? "1 file" : `${count} files`);
228
+
229
+ const fileBoundsText = ({max, min}: {max: number; min: number}): string =>
230
+ min === max ? `exactly ${fileCountText(min)}` : `${min} to ${fileCountText(max)}`;
231
+
232
+ /**
233
+ * The count must fit the ask, each declared type must be one `accept` allows, each file must fit
234
+ * the per-file cap, and a data URL's media type must be the declared type. The server then checks
235
+ * the bytes themselves with `checkAskFileBytes`.
236
+ */
237
+ const validateFilesAnswer = (
238
+ input: FilesAskInput,
239
+ content: unknown,
240
+ maxFileSizeBytes: number
241
+ ): AskErrorDraft[] => {
242
+ const parsed = filesAnswerSchema.safeParse(content);
243
+ if (!parsed.success) {
244
+ return issuesToAskErrors({issues: parsed.error.issues, prefix: ["content"], root: content});
245
+ }
246
+ const {files} = parsed.data;
247
+ const drafts: AskErrorDraft[] = [];
248
+ const bounds = filesCountBounds(input);
249
+ if (files.length < bounds.min || files.length > bounds.max) {
250
+ drafts.push({
251
+ code: "FILE_COUNT",
252
+ fix: `Send ${fileBoundsText(bounds)} in content.files.`,
253
+ message: `This ask takes ${fileBoundsText(bounds)}; the answer has ${files.length}.`,
254
+ segments: ["content", "files"],
255
+ });
256
+ }
257
+ const accepted: readonly string[] = acceptedFileMimeTypes(input.accept);
258
+ files.forEach((file, index) => {
259
+ const segments = ["content", "files", index];
260
+ if (!accepted.includes(file.mimeType)) {
261
+ drafts.push({
262
+ code: "FILE_TYPE_NOT_ACCEPTED",
263
+ fix: `Send a file of type ${accepted.join(", ")}.`,
264
+ message: `${quoteValue(file.filename)} is ${file.mimeType}, which this ask does not accept.`,
265
+ segments: [...segments, "mimeType"],
266
+ });
267
+ }
268
+ const dataUrl = file.url === undefined ? undefined : parseAskDataUrl(file.url);
269
+ const size = dataUrl === undefined ? file.size : base64ByteLength(dataUrl.base64);
270
+ if (Math.max(size, file.size) > maxFileSizeBytes) {
271
+ drafts.push(
272
+ fileTooLargeDraft({
273
+ maxFileSizeBytes,
274
+ segments: [...segments, "size"],
275
+ size: Math.max(size, file.size),
276
+ })
277
+ );
278
+ }
279
+ if (dataUrl !== undefined && dataUrl.mediaType !== file.mimeType) {
280
+ drafts.push({
281
+ code: "MIME_MISMATCH",
282
+ fix: `Make the data URL's media type ${file.mimeType}, or declare ${dataUrl.mediaType}.`,
283
+ message: `The data URL holds ${dataUrl.mediaType}, but the file is declared as ${file.mimeType}.`,
284
+ segments: [...segments, "url"],
285
+ });
286
+ }
287
+ });
288
+ return drafts;
289
+ };
290
+
291
+ /**
292
+ * Checks a user's answer against the ask it answers. The client runs it before enabling Submit and
293
+ * the server runs it before resuming the turn. Returns no errors when the answer is valid.
294
+ * `maxFileSizeBytes` is the host's per-file cap for `files` answers (10 MB by default).
295
+ */
296
+ export const validateAskResponse = ({
297
+ input,
298
+ kind,
299
+ maxFileSizeBytes = ASK_LIMITS.files.defaultMaxFileSizeBytes,
300
+ response,
301
+ }: {
302
+ input: Ask["input"];
303
+ kind: AskKind;
304
+ maxFileSizeBytes?: number;
305
+ response: unknown;
306
+ }): AskValidationError[] => {
307
+ const ask = {input, kind} as Ask;
308
+ const envelope = askResponseSchema.safeParse(response);
309
+ if (!envelope.success) {
310
+ return finalizeAskErrors(issuesToAskErrors({issues: envelope.error.issues, root: response}));
311
+ }
312
+ const answer = envelope.data;
313
+ if (answer.action === "cancel") {
314
+ return [];
315
+ }
316
+ if (answer.action === "decline") {
317
+ if (askAllowsDecline(ask)) {
318
+ return [];
319
+ }
320
+ return [
321
+ {
322
+ code: "DECLINE_NOT_ALLOWED",
323
+ fix: 'Answer with action "accept".',
324
+ message: "This ask cannot be skipped.",
325
+ path: "action",
326
+ },
327
+ ];
328
+ }
329
+ switch (ask.kind) {
330
+ case "choice":
331
+ return finalizeAskErrors(validateChoiceAnswer(ask.input, answer.content));
332
+ case "confirm":
333
+ return finalizeAskErrors(validateConfirmAnswer(answer.content));
334
+ case "markdown":
335
+ return finalizeAskErrors(validateMarkdownAnswer(ask.input, answer.content));
336
+ case "form":
337
+ return finalizeAskErrors(validateFormAnswer(ask.input, answer.content));
338
+ case "files":
339
+ return finalizeAskErrors(validateFilesAnswer(ask.input, answer.content, maxFileSizeBytes));
340
+ }
341
+ };
@@ -0,0 +1,21 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {readFileSync} from "node:fs";
3
+ import {join} from "node:path";
4
+
5
+ import {BLOCK_ERROR_CODES, BLOCK_WARNING_CODES} from "./errors";
6
+
7
+ const reference = readFileSync(
8
+ join(import.meta.dir, "..", "..", "docs", "reference", "blocks.md"),
9
+ "utf8"
10
+ );
11
+
12
+ describe("blocks reference parity", () => {
13
+ it("names every error and warning code", () => {
14
+ for (const code of Object.keys(BLOCK_ERROR_CODES)) {
15
+ expect(reference).toContain(`\`${code}\``);
16
+ }
17
+ for (const code of Object.keys(BLOCK_WARNING_CODES)) {
18
+ expect(reference).toContain(`\`${code}\``);
19
+ }
20
+ });
21
+ });
@@ -0,0 +1,134 @@
1
+ import {describe, expect, it} from "bun:test";
2
+ import {join} from "node:path";
3
+
4
+ import {launchBlocksCli, runBlocksCli} from "./cli";
5
+
6
+ const validFixture = join(import.meta.dir, "fixtures", "valid", "prose.yaml");
7
+ const invalidFixture = join(import.meta.dir, "fixtures", "invalid", "unknown-key.yaml");
8
+
9
+ describe("terreno-blocks validate", () => {
10
+ it("exits 0 for a valid file and 1 for an invalid file", async () => {
11
+ const valid = Bun.spawn(["bun", join(import.meta.dir, "cli.ts"), "validate", validFixture], {
12
+ stderr: "pipe",
13
+ stdout: "pipe",
14
+ });
15
+ expect(await valid.exited).toBe(0);
16
+ expect(await new Response(valid.stdout).text()).toBe("");
17
+
18
+ const invalid = Bun.spawn(
19
+ ["bun", join(import.meta.dir, "cli.ts"), "validate", invalidFixture],
20
+ {stderr: "pipe", stdout: "pipe"}
21
+ );
22
+ expect(await invalid.exited).toBe(1);
23
+ const report = await new Response(invalid.stdout).text();
24
+ expect(report).toContain("blocks[0].color UNKNOWN_KEY ");
25
+ expect(report).toContain(" — ");
26
+ });
27
+
28
+ it("reads stdin when the path is -", () => {
29
+ const result = runBlocksCli(["validate", "-"], {
30
+ readFile: () => {
31
+ throw new Error("readFile should not run");
32
+ },
33
+ readStdin: () => "v: 1\nblocks:\n - type: divider\n",
34
+ });
35
+ expect(result).toEqual({code: 0, stderr: "", stdout: ""});
36
+ });
37
+
38
+ it("prints the file error, a non-document, and a field error", () => {
39
+ const missing = runBlocksCli(["validate", "missing.yaml"], {
40
+ readFile: () => {
41
+ throw new Error("ENOENT");
42
+ },
43
+ readStdin: () => "",
44
+ });
45
+ expect(missing).toEqual({code: 1, stderr: "ENOENT\n", stdout: ""});
46
+
47
+ const unnamed = runBlocksCli(["validate", "missing.yaml"], {
48
+ readFile: () => {
49
+ throw "nope";
50
+ },
51
+ readStdin: () => "",
52
+ });
53
+ expect(unnamed.stderr).toBe("Could not read the file.\n");
54
+
55
+ const prose = runBlocksCli(["validate", "note.txt"], {
56
+ readFile: () => "hello",
57
+ readStdin: () => "",
58
+ });
59
+ expect(prose.code).toBe(1);
60
+ expect(prose.stdout).toContain("NOT_A_DOCUMENT");
61
+
62
+ const invalid = runBlocksCli(["validate", "bad.yaml"], {
63
+ readFile: () => "v: 1\nblocks:\n - type: heading\n text: Hi\n color: red\n",
64
+ readStdin: () => "",
65
+ });
66
+ expect(invalid.code).toBe(1);
67
+ expect(invalid.stdout).toContain("UNKNOWN_KEY");
68
+ expect(invalid.stdout).toContain(" — ");
69
+ });
70
+
71
+ it("runs from the installed bin name and writes both streams", () => {
72
+ const run = (argv1: string, args: string[]): {code: number; stderr: string; stdout: string} => {
73
+ const stdout: string[] = [];
74
+ const stderr: string[] = [];
75
+ const originalArgv = process.argv;
76
+ const originalExit = process.exit;
77
+ const originalStdout = process.stdout.write;
78
+ const originalStderr = process.stderr.write;
79
+ process.argv = ["bun", argv1, ...args];
80
+ process.stdout.write = ((chunk: string | Uint8Array) => {
81
+ stdout.push(String(chunk));
82
+ return true;
83
+ }) as typeof process.stdout.write;
84
+ process.stderr.write = ((chunk: string | Uint8Array) => {
85
+ stderr.push(String(chunk));
86
+ return true;
87
+ }) as typeof process.stderr.write;
88
+ let code = 0;
89
+ process.exit = ((status?: number) => {
90
+ code = status ?? 0;
91
+ throw new Error("exit");
92
+ }) as typeof process.exit;
93
+ try {
94
+ launchBlocksCli(argv1);
95
+ } catch (error) {
96
+ if (!(error instanceof Error) || error.message !== "exit") {
97
+ throw error;
98
+ }
99
+ } finally {
100
+ process.argv = originalArgv;
101
+ process.exit = originalExit;
102
+ process.stdout.write = originalStdout;
103
+ process.stderr.write = originalStderr;
104
+ }
105
+ return {code, stderr: stderr.join(""), stdout: stdout.join("")};
106
+ };
107
+
108
+ const invalid = run("terreno-blocks", ["validate", invalidFixture]);
109
+ expect(invalid.code).toBe(1);
110
+ expect(invalid.stdout).toContain("UNKNOWN_KEY");
111
+
112
+ const usage = run("cli.js", ["nope"]);
113
+ expect(usage.code).toBe(1);
114
+ expect(usage.stderr).toContain("Usage: terreno-blocks validate");
115
+
116
+ const valid = run("cli.ts", ["validate", validFixture]);
117
+ expect(valid).toEqual({code: 0, stderr: "", stdout: ""});
118
+
119
+ const stdin = run("terreno-blocks", ["validate", "-"]);
120
+ expect(stdin.code).toBe(1);
121
+ expect(stdin.stdout).toContain("NOT_A_DOCUMENT");
122
+
123
+ expect(run("bun", ["validate", validFixture])).toEqual({code: 0, stderr: "", stdout: ""});
124
+ });
125
+
126
+ it("exits 1 when the command is not validate", () => {
127
+ const result = runBlocksCli(["nope"], {
128
+ readFile: () => "",
129
+ readStdin: () => "",
130
+ });
131
+ expect(result.code).toBe(1);
132
+ expect(result.stderr).toContain("Usage: terreno-blocks validate");
133
+ });
134
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import {readFileSync} from "node:fs";
4
+ import {basename} from "node:path";
5
+
6
+ import {parseBlocks} from "./parse";
7
+ import {validateBlocks} from "./validate";
8
+
9
+ const usage = "Usage: terreno-blocks validate <file | ->\n";
10
+
11
+ const formatError = (error: {code: string; fix: string; message: string; path: string}): string =>
12
+ `${error.path} ${error.code} ${error.message} — ${error.fix}`;
13
+
14
+ export const runBlocksCli = (
15
+ argv: readonly string[],
16
+ input: {readFile: (path: string) => string; readStdin: () => string}
17
+ ): {code: number; stderr: string; stdout: string} => {
18
+ const [command, target, extra] = argv;
19
+ if (command !== "validate" || target === undefined || extra !== undefined) {
20
+ return {code: 1, stderr: usage, stdout: ""};
21
+ }
22
+ let text: string;
23
+ try {
24
+ text = target === "-" ? input.readStdin() : input.readFile(target);
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : "Could not read the file.";
27
+ return {code: 1, stderr: `${message}\n`, stdout: ""};
28
+ }
29
+ const parsed = parseBlocks(text);
30
+ if (!parsed.ok) {
31
+ return {code: 1, stderr: "", stdout: `${parsed.errors.map(formatError).join("\n")}\n`};
32
+ }
33
+ const validated = validateBlocks(parsed.value);
34
+ if (!validated.ok) {
35
+ return {code: 1, stderr: "", stdout: `${validated.errors.map(formatError).join("\n")}\n`};
36
+ }
37
+ const warnings =
38
+ validated.warnings.length === 0 ? "" : `${validated.warnings.map(formatError).join("\n")}\n`;
39
+ return {code: 0, stderr: "", stdout: warnings};
40
+ };
41
+
42
+ const main = (): void => {
43
+ const result = runBlocksCli(process.argv.slice(2), {
44
+ readFile: (path) => readFileSync(path, "utf8"),
45
+ readStdin: () => readFileSync(0, "utf8"),
46
+ });
47
+ if (result.stdout !== "") {
48
+ process.stdout.write(result.stdout);
49
+ }
50
+ if (result.stderr !== "") {
51
+ process.stderr.write(result.stderr);
52
+ }
53
+ process.exit(result.code);
54
+ };
55
+
56
+ /** Runs validate when the process was started as the `terreno-blocks` bin. */
57
+ export const launchBlocksCli = (argv1: string | undefined): void => {
58
+ const entry = basename(argv1 ?? "");
59
+ // npm links the bin as `terreno-blocks`, so argv[1] is that name rather than cli.js.
60
+ if (entry === "cli.ts" || entry === "cli.js" || entry === "terreno-blocks") {
61
+ main();
62
+ }
63
+ };
64
+
65
+ launchBlocksCli(process.argv[1]);
@@ -0,0 +1,68 @@
1
+ import {describe, expect, it} from "bun:test";
2
+
3
+ import {blocksPromptSection} from "./prompt";
4
+ import {validateBlocks} from "./validate";
5
+
6
+ const documentWith = (block: Record<string, unknown>): unknown => {
7
+ const doc: Record<string, unknown> = {};
8
+ doc.v = 1;
9
+ doc.blocks = [block];
10
+ return doc;
11
+ };
12
+
13
+ const image = (src: string): unknown => {
14
+ const block: Record<string, unknown> = {};
15
+ block.type = "image";
16
+ block.alt = "Receipt";
17
+ block.src = src;
18
+ return documentWith(block);
19
+ };
20
+
21
+ describe("callout, image, and details", () => {
22
+ it("accepts a callout, a details block, a data image, and a file ref", () => {
23
+ const callout: Record<string, unknown> = {};
24
+ callout.type = "callout";
25
+ callout.status = "warning";
26
+ callout.text = "Seats renew on Friday.";
27
+ const details: Record<string, unknown> = {};
28
+ details.type = "details";
29
+ details.title = "Invoice notes";
30
+ details.text = "Twelve seats, billed monthly.";
31
+
32
+ expect(validateBlocks(documentWith(callout)).ok).toBe(true);
33
+ expect(validateBlocks(documentWith(details)).ok).toBe(true);
34
+ expect(validateBlocks(image("data:image/png;base64,aaaa")).ok).toBe(true);
35
+ expect(validateBlocks(image("file:6710c2a4f1")).ok).toBe(true);
36
+ });
37
+
38
+ it("rejects an image without alt", () => {
39
+ const block: Record<string, unknown> = {};
40
+ block.type = "image";
41
+ block.src = "data:image/png;base64,aaaa";
42
+ const result = validateBlocks(documentWith(block));
43
+
44
+ expect(result.ok).toBe(false);
45
+ if (!result.ok) {
46
+ expect(result.errors.map((error) => error.code)).toContain("MISSING_REQUIRED");
47
+ }
48
+ });
49
+
50
+ it("rejects an https image whose host is not listed and accepts a listed host", () => {
51
+ const blocked = validateBlocks(image("https://evil.test/pixel.png"));
52
+ const allowed = validateBlocks(image("https://cdn.example.com/pixel.png"), {
53
+ imageHosts: ["cdn.example.com"],
54
+ });
55
+
56
+ expect(blocked.ok).toBe(false);
57
+ if (!blocked.ok) {
58
+ expect(blocked.errors.map((error) => error.code)).toContain("IMAGE_HOST_NOT_ALLOWED");
59
+ }
60
+ expect(allowed.ok).toBe(true);
61
+ });
62
+
63
+ it("tells the model which image hosts are allowed", () => {
64
+ expect(blocksPromptSection()).toContain("Do not emit an https image src");
65
+ expect(blocksPromptSection()).toContain("callout");
66
+ expect(blocksPromptSection({imageHosts: ["cdn.example.com"]})).toContain("cdn.example.com");
67
+ });
68
+ });
@@ -0,0 +1,29 @@
1
+ import {describe, expect, it} from "bun:test";
2
+
3
+ import {type BlockError, sortBlockErrors} from "./errors";
4
+
5
+ const error = (path: string, code: BlockError["code"]): BlockError => ({
6
+ code,
7
+ fix: "Fix it.",
8
+ message: "Bad.",
9
+ path,
10
+ });
11
+
12
+ describe("sortBlockErrors", () => {
13
+ it("orders shorter paths first, indexes numerically, and codes alphabetically", () => {
14
+ const sorted = sortBlockErrors([
15
+ error("blocks[10].text", "TOO_LONG"),
16
+ error("blocks[2].text", "MISSING_REQUIRED"),
17
+ error("blocks[2].text", "INVALID_TYPE"),
18
+ error("blocks[2]", "UNKNOWN_KEY"),
19
+ error("blocks.name", "INVALID_ENUM"),
20
+ ]);
21
+ expect(sorted.map((item) => `${item.path} ${item.code}`)).toEqual([
22
+ "blocks[2] UNKNOWN_KEY",
23
+ "blocks[2].text INVALID_TYPE",
24
+ "blocks[2].text MISSING_REQUIRED",
25
+ "blocks[10].text TOO_LONG",
26
+ "blocks.name INVALID_ENUM",
27
+ ]);
28
+ });
29
+ });