@transloadit/node 4.2.0 → 4.3.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 (66) hide show
  1. package/README.md +58 -0
  2. package/dist/Transloadit.d.ts +19 -0
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +22 -0
  5. package/dist/Transloadit.js.map +1 -1
  6. package/dist/alphalib/assembly-linter.d.ts +123 -0
  7. package/dist/alphalib/assembly-linter.d.ts.map +1 -0
  8. package/dist/alphalib/assembly-linter.js +1142 -0
  9. package/dist/alphalib/assembly-linter.js.map +1 -0
  10. package/dist/alphalib/assembly-linter.lang.en.d.ts +87 -0
  11. package/dist/alphalib/assembly-linter.lang.en.d.ts.map +1 -0
  12. package/dist/alphalib/assembly-linter.lang.en.js +326 -0
  13. package/dist/alphalib/assembly-linter.lang.en.js.map +1 -0
  14. package/dist/alphalib/object.d.ts +20 -0
  15. package/dist/alphalib/object.d.ts.map +1 -0
  16. package/dist/alphalib/object.js +23 -0
  17. package/dist/alphalib/object.js.map +1 -0
  18. package/dist/alphalib/stepParsing.d.ts +93 -0
  19. package/dist/alphalib/stepParsing.d.ts.map +1 -0
  20. package/dist/alphalib/stepParsing.js +1154 -0
  21. package/dist/alphalib/stepParsing.js.map +1 -0
  22. package/dist/alphalib/templateMerge.d.ts +4 -0
  23. package/dist/alphalib/templateMerge.d.ts.map +1 -0
  24. package/dist/alphalib/templateMerge.js +22 -0
  25. package/dist/alphalib/templateMerge.js.map +1 -0
  26. package/dist/cli/commands/assemblies.d.ts +20 -1
  27. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  28. package/dist/cli/commands/assemblies.js +137 -2
  29. package/dist/cli/commands/assemblies.js.map +1 -1
  30. package/dist/cli/commands/auth.d.ts.map +1 -1
  31. package/dist/cli/commands/auth.js +19 -19
  32. package/dist/cli/commands/auth.js.map +1 -1
  33. package/dist/cli/commands/index.d.ts.map +1 -1
  34. package/dist/cli/commands/index.js +2 -1
  35. package/dist/cli/commands/index.js.map +1 -1
  36. package/dist/cli/docs/assemblyLintingExamples.d.ts +2 -0
  37. package/dist/cli/docs/assemblyLintingExamples.d.ts.map +1 -0
  38. package/dist/cli/docs/assemblyLintingExamples.js +10 -0
  39. package/dist/cli/docs/assemblyLintingExamples.js.map +1 -0
  40. package/dist/cli/helpers.d.ts +11 -0
  41. package/dist/cli/helpers.d.ts.map +1 -1
  42. package/dist/cli/helpers.js +29 -0
  43. package/dist/cli/helpers.js.map +1 -1
  44. package/dist/lintAssemblyInput.d.ts +10 -0
  45. package/dist/lintAssemblyInput.d.ts.map +1 -0
  46. package/dist/lintAssemblyInput.js +73 -0
  47. package/dist/lintAssemblyInput.js.map +1 -0
  48. package/dist/lintAssemblyInstructions.d.ts +29 -0
  49. package/dist/lintAssemblyInstructions.d.ts.map +1 -0
  50. package/dist/lintAssemblyInstructions.js +33 -0
  51. package/dist/lintAssemblyInstructions.js.map +1 -0
  52. package/package.json +5 -2
  53. package/src/Transloadit.ts +39 -0
  54. package/src/alphalib/assembly-linter.lang.en.ts +393 -0
  55. package/src/alphalib/assembly-linter.ts +1475 -0
  56. package/src/alphalib/object.ts +27 -0
  57. package/src/alphalib/stepParsing.ts +1465 -0
  58. package/src/alphalib/templateMerge.ts +32 -0
  59. package/src/alphalib/typings/json-to-ast.d.ts +34 -0
  60. package/src/cli/commands/assemblies.ts +161 -2
  61. package/src/cli/commands/auth.ts +19 -22
  62. package/src/cli/commands/index.ts +2 -0
  63. package/src/cli/docs/assemblyLintingExamples.ts +9 -0
  64. package/src/cli/helpers.ts +50 -0
  65. package/src/lintAssemblyInput.ts +89 -0
  66. package/src/lintAssemblyInstructions.ts +72 -0
@@ -0,0 +1,1465 @@
1
+ import type { ZodObject, ZodRawShape } from 'zod'
2
+ import { z } from 'zod'
3
+
4
+ import { entries } from './object.ts'
5
+ import { robotsSchema } from './types/robots/_index.ts'
6
+ import {
7
+ useParamArrayOfStringsSchema,
8
+ useParamArrayOfUseParamObjectSchema,
9
+ useParamObjectOfStepsSchema,
10
+ useParamStringSchema,
11
+ } from './types/robots/_instructions-primitives.ts'
12
+ import type { RobotFilePreviewInstructionsInput } from './types/robots/file-preview.ts'
13
+ import type { RobotFileServeInstructionsInput } from './types/robots/file-serve.ts'
14
+ import type { RobotS3StoreInstructionsInput } from './types/robots/s3-store.ts'
15
+ import type {
16
+ AssemblyInstructionsInput,
17
+ StepInput,
18
+ StepInputWithUse,
19
+ StepsInput,
20
+ } from './types/template.ts'
21
+ import { assemblyInstructionsSchema } from './types/template.ts'
22
+ import type { ZodIssueWithContext } from './zodParseWithContext.ts'
23
+ import { zodParseWithContext } from './zodParseWithContext.ts'
24
+
25
+ type StepInputRecord = StepInput & Record<string, unknown>
26
+
27
+ function isRecord(value: unknown): value is Record<string, unknown> {
28
+ return typeof value === 'object' && value !== null
29
+ }
30
+
31
+ // Add this type to represent steps that don't require use
32
+ export type NoUseRobot =
33
+ | '/upload/handle'
34
+ | '/google/import'
35
+ | '/dropbox/import'
36
+ | '/supabase/import'
37
+ | '/swift/import'
38
+ | '/backblaze/import'
39
+ | '/ftp/import'
40
+ | '/cloudfiles/import'
41
+ | '/cloudflare/import'
42
+ | '/digitalocean/import'
43
+ | '/http/import'
44
+ | '/s3/import'
45
+ | '/azure/import'
46
+ | '/minio/import'
47
+ | '/wasabi/import'
48
+ | '/edgly/deliver'
49
+ | '/tlcdn/deliver'
50
+ | '/sftp/import'
51
+
52
+ export type StepInputWithoutUse = Omit<StepInput, 'use'> & {
53
+ robot: NoUseRobot
54
+ }
55
+
56
+ export function doesRobotSupportUse(
57
+ robot: string,
58
+ ): robot is Exclude<StepInput['robot'], NoUseRobot> {
59
+ return (
60
+ robot !== '/upload/handle' &&
61
+ robot !== '/google/import' &&
62
+ robot !== '/dropbox/import' &&
63
+ robot !== '/supabase/import' &&
64
+ robot !== '/swift/import' &&
65
+ robot !== '/backblaze/import' &&
66
+ robot !== '/ftp/import' &&
67
+ robot !== '/cloudfiles/import' &&
68
+ robot !== '/cloudflare/import' &&
69
+ robot !== '/digitalocean/import' &&
70
+ robot !== '/http/import' &&
71
+ robot !== '/s3/import' &&
72
+ robot !== '/azure/import' &&
73
+ robot !== '/minio/import' &&
74
+ robot !== '/wasabi/import' &&
75
+ robot !== '/edgly/deliver' &&
76
+ robot !== '/tlcdn/deliver' &&
77
+ robot !== '/sftp/import'
78
+ )
79
+ }
80
+
81
+ export function doesStepRobotSupportUse(step: StepInput): step is StepInputWithUse {
82
+ return 'robot' in step && doesRobotSupportUse(step.robot)
83
+ }
84
+
85
+ export interface ParsedTemplateField {
86
+ mostCommonExampleValue: FieldOccurrence['exampleValues'][number]
87
+ fieldName: string
88
+ value?: string
89
+ occurrences: FieldOccurrence[]
90
+ }
91
+
92
+ export interface FieldOccurrence {
93
+ errors: string[]
94
+ exampleValues: (string | number | boolean)[]
95
+ leader: string
96
+ paramName: string
97
+ path: (number | string)[]
98
+ requiresDenoEval: boolean
99
+ rName: string
100
+ stepName: string
101
+ trailer: string
102
+ }
103
+
104
+ export interface ValidationError {
105
+ stepName: string
106
+ robotName: string
107
+ paramName: string
108
+ fieldNames: string[]
109
+ message: string
110
+ value: unknown
111
+ }
112
+
113
+ export interface Recommendation {
114
+ id: string
115
+ robotName: string
116
+ description: string
117
+ applyFunction: (content: string) => string
118
+ iconSrc: string
119
+ }
120
+
121
+ export interface InterpolatedTemplateError {
122
+ stepName: string
123
+ robotName: string
124
+ paramName: string
125
+ fieldNames: string[]
126
+ message: string
127
+ value: unknown
128
+ }
129
+ export function nonSignedSmartCDNUrl(
130
+ argWorkspaceSlug: string,
131
+ argTemplateSlug: string,
132
+ argInputField: string,
133
+ params: Record<string, string | number> = {},
134
+ ) {
135
+ const workspaceSlug = encodeURIComponent(argWorkspaceSlug)
136
+ const templateSlug = encodeURIComponent(argTemplateSlug)
137
+ const inputField = encodeURIComponent(argInputField)
138
+
139
+ const queryParams = new URLSearchParams(
140
+ Object.fromEntries(entries(params).map(([key, value]) => [key, String(value)])),
141
+ )
142
+ queryParams.sort()
143
+
144
+ const nonSignedUrl = `https://${workspaceSlug}.tlcdn.com/${templateSlug}/${inputField}?${queryParams}`
145
+ return nonSignedUrl
146
+ }
147
+
148
+ export function simplifyUse(step: StepInput): StepInput {
149
+ if (!doesStepRobotSupportUse(step)) {
150
+ return step
151
+ }
152
+
153
+ if (!('use' in step)) {
154
+ return step
155
+ }
156
+
157
+ // const zodRes1 = useParamStringSchema.safeParse(step.use)
158
+ const zodRes2 = useParamArrayOfStringsSchema.safeParse(step.use)
159
+ // const zodRes3 = useParamArrayOfUseParamObjectSchema.safeParse(step.use)
160
+ // const zodRes4 = useParamObjectOfStepsSchema.safeParse(step.use)
161
+
162
+ if (zodRes2.success) {
163
+ // Turn single element array into a string
164
+ if (zodRes2.data.length === 1) {
165
+ step.use = zodRes2.data[0]
166
+ }
167
+ }
168
+
169
+ return step
170
+ }
171
+
172
+ export function getLastUsedStepName(step: StepInput): string | undefined {
173
+ if (!doesStepRobotSupportUse(step)) {
174
+ return undefined
175
+ }
176
+ if (!('use' in step)) {
177
+ return undefined
178
+ }
179
+
180
+ const zodRes1 = useParamStringSchema.safeParse(step.use)
181
+ const zodRes2 = useParamArrayOfStringsSchema.safeParse(step.use)
182
+ const zodRes3 = useParamArrayOfUseParamObjectSchema.safeParse(step.use)
183
+ const zodRes4 = useParamObjectOfStepsSchema.safeParse(step.use)
184
+
185
+ if (zodRes1.success) {
186
+ return zodRes1.data
187
+ }
188
+ if (zodRes2.success) {
189
+ return zodRes2.data[zodRes2.data.length - 1]
190
+ }
191
+ if (zodRes3.success) {
192
+ return zodRes3.data[zodRes3.data.length - 1].name
193
+ }
194
+ if (zodRes4.success) {
195
+ const zodRes41 = useParamStringSchema.safeParse(zodRes4.data.steps)
196
+ const zodRes42 = useParamArrayOfStringsSchema.safeParse(zodRes4.data.steps)
197
+ const zodRes43 = useParamArrayOfUseParamObjectSchema.safeParse(zodRes4.data.steps)
198
+
199
+ if (zodRes41.success) {
200
+ return zodRes41.data
201
+ }
202
+ if (zodRes42.success) {
203
+ return zodRes42.data[zodRes42.data.length - 1]
204
+ }
205
+ if (zodRes43.success) {
206
+ return zodRes43.data[zodRes43.data.length - 1].name
207
+ }
208
+ throw new Error('Invalid use value')
209
+ }
210
+
211
+ return undefined
212
+ }
213
+
214
+ export function addUseReference(
215
+ step: StepInput,
216
+ newName: string,
217
+ opts?: {
218
+ leading?: boolean
219
+ silent?: boolean
220
+ },
221
+ ): StepInput {
222
+ // const step = structuredClone(step)
223
+ const { leading = false } = opts ?? {}
224
+
225
+ if (!doesStepRobotSupportUse(step)) {
226
+ return step
227
+ }
228
+
229
+ if (!('use' in step)) {
230
+ // TypeScript now knows this step supports 'use' due to the doesStepRobotSupportUse check
231
+ // We need to return a new object with the use property
232
+ return { ...step, use: newName } as StepInput
233
+ }
234
+
235
+ const zodRes1 = useParamStringSchema.safeParse(step.use)
236
+ const zodRes2 = useParamArrayOfStringsSchema.safeParse(step.use)
237
+ const zodRes3 = useParamArrayOfUseParamObjectSchema.safeParse(step.use)
238
+ const zodRes4 = useParamObjectOfStepsSchema.safeParse(step.use)
239
+
240
+ if (zodRes1.success) {
241
+ step.use = leading ? [newName, zodRes1.data] : [zodRes1.data, newName]
242
+ } else if (zodRes2.success) {
243
+ step.use = leading ? [newName, ...zodRes2.data] : [...zodRes2.data, newName]
244
+ } else if (zodRes3.success) {
245
+ step.use = leading ? [{ name: newName }, ...zodRes3.data] : [...zodRes3.data, { name: newName }]
246
+ } else if (zodRes4.success) {
247
+ const zodRes41 = useParamStringSchema.safeParse(zodRes4.data.steps)
248
+ const zodRes42 = useParamArrayOfStringsSchema.safeParse(zodRes4.data.steps)
249
+ const zodRes43 = useParamArrayOfUseParamObjectSchema.safeParse(zodRes4.data.steps)
250
+
251
+ if (zodRes41.success) {
252
+ step.use = leading
253
+ ? { ...zodRes4.data, steps: [newName, zodRes41.data] }
254
+ : { ...zodRes4.data, steps: [zodRes41.data, newName] }
255
+ } else if (zodRes42.success) {
256
+ step.use = leading
257
+ ? { ...zodRes4.data, steps: [newName, ...zodRes42.data] }
258
+ : { ...zodRes4.data, steps: [...zodRes42.data, newName] }
259
+ } else if (zodRes43.success) {
260
+ step.use = leading
261
+ ? { ...zodRes4.data, steps: [{ name: newName }, ...zodRes43.data] }
262
+ : { ...zodRes4.data, steps: [...zodRes43.data, { name: newName }] }
263
+ } else {
264
+ throw new Error('Invalid use value')
265
+ }
266
+ }
267
+
268
+ return simplifyUse(step)
269
+ }
270
+
271
+ // Helper function to update 'use' references
272
+ export function renameUseReferences(step: StepInput, oldName: string, newName: string): StepInput {
273
+ if (!('use' in step)) {
274
+ return step
275
+ }
276
+
277
+ const zodRes1 = useParamStringSchema.safeParse(step.use)
278
+ const zodRes2 = useParamArrayOfStringsSchema.safeParse(step.use)
279
+ const zodRes3 = useParamArrayOfUseParamObjectSchema.safeParse(step.use)
280
+ const zodRes4 = useParamObjectOfStepsSchema.safeParse(step.use)
281
+
282
+ if (zodRes1.success) {
283
+ step.use = step.use === oldName ? newName : step.use
284
+ } else if (zodRes2.success) {
285
+ const newUse: z.infer<typeof useParamArrayOfStringsSchema> = []
286
+ for (const currentName of zodRes2.data) {
287
+ if (currentName === oldName) {
288
+ if (!newUse.includes(newName)) {
289
+ newUse.push(newName)
290
+ }
291
+ } else if (!newUse.includes(currentName)) {
292
+ newUse.push(currentName)
293
+ }
294
+ }
295
+ step.use = newUse
296
+ } else if (zodRes3.success) {
297
+ step.use = zodRes3.data.map((u) => ({
298
+ ...u,
299
+ name: u.name === oldName ? newName : u.name,
300
+ }))
301
+ } else if (zodRes4.success) {
302
+ const zodRes41 = useParamStringSchema.safeParse(zodRes4.data.steps)
303
+ const zodRes42 = useParamArrayOfStringsSchema.safeParse(zodRes4.data.steps)
304
+ const zodRes43 = useParamArrayOfUseParamObjectSchema.safeParse(zodRes4.data.steps)
305
+
306
+ if (zodRes41.success) {
307
+ step.use = {
308
+ ...zodRes4.data,
309
+ steps: zodRes41.data === oldName ? newName : zodRes41.data,
310
+ }
311
+ } else if (zodRes42.success) {
312
+ step.use = {
313
+ ...zodRes4.data,
314
+ steps: zodRes42.data.map((u) => (u === oldName ? newName : u)),
315
+ }
316
+ } else if (zodRes43.success) {
317
+ step.use = {
318
+ ...zodRes4.data,
319
+ steps: zodRes43.data.map((u) => ({
320
+ ...u,
321
+ name: u.name === oldName ? newName : u.name,
322
+ })),
323
+ }
324
+ } else {
325
+ throw new Error('Invalid use value')
326
+ }
327
+ }
328
+
329
+ return simplifyUse(step)
330
+ }
331
+
332
+ export function removeUseReference(step: StepInput, nameToRemove: string): StepInput {
333
+ if (!('use' in step)) {
334
+ return step
335
+ }
336
+
337
+ const zodRes1 = useParamStringSchema.safeParse(step.use)
338
+ const zodRes2 = useParamArrayOfStringsSchema.safeParse(step.use)
339
+ const zodRes3 = useParamArrayOfUseParamObjectSchema.safeParse(step.use)
340
+ const zodRes4 = useParamObjectOfStepsSchema.safeParse(step.use)
341
+
342
+ if (zodRes1.success) {
343
+ if (step.use === nameToRemove) {
344
+ return { ...step, use: [] }
345
+ }
346
+ return step
347
+ }
348
+
349
+ if (zodRes2.success) {
350
+ const newUse = zodRes2.data.filter((u) => u !== nameToRemove)
351
+ return simplifyUse({ ...step, use: newUse })
352
+ }
353
+
354
+ if (zodRes3.success) {
355
+ const newUse = zodRes3.data.filter((u) => u.name !== nameToRemove)
356
+ return simplifyUse({ ...step, use: newUse })
357
+ }
358
+
359
+ if (zodRes4.success) {
360
+ const zodRes41 = useParamStringSchema.safeParse(zodRes4.data.steps)
361
+ const zodRes42 = useParamArrayOfStringsSchema.safeParse(zodRes4.data.steps)
362
+ const zodRes43 = useParamArrayOfUseParamObjectSchema.safeParse(zodRes4.data.steps)
363
+
364
+ if (zodRes41.success) {
365
+ if (zodRes41.data === nameToRemove) {
366
+ return { ...step, use: { ...zodRes4.data, steps: [] } }
367
+ }
368
+ return step
369
+ }
370
+
371
+ if (zodRes42.success) {
372
+ const newSteps = zodRes42.data.filter((u) => u !== nameToRemove)
373
+ return simplifyUse({ ...step, use: { ...zodRes4.data, steps: newSteps } })
374
+ }
375
+
376
+ if (zodRes43.success) {
377
+ const newSteps = zodRes43.data.filter((u) => u.name !== nameToRemove)
378
+ return simplifyUse({ ...step, use: { ...zodRes4.data, steps: newSteps } })
379
+ }
380
+
381
+ throw new Error('Invalid use value')
382
+ }
383
+
384
+ return step
385
+ }
386
+
387
+ function isZodObject(schema: z.ZodType<unknown>): schema is ZodObject<ZodRawShape> {
388
+ return 'shape' in schema
389
+ }
390
+
391
+ function getSchemaForRobot(rName: string): z.ZodType<unknown> | undefined {
392
+ // Extract all robot schemas from the union and convert readonly array to regular array
393
+ const schemas = [...robotsSchema._def.options]
394
+
395
+ // Find the matching schema based on the robot name
396
+ return schemas.find((schema) => {
397
+ if (!isZodObject(schema)) return false
398
+ const shape = schema.shape
399
+
400
+ if (!('robot' in shape)) return false
401
+ const robotField = shape.robot
402
+
403
+ if (!(robotField instanceof z.ZodLiteral)) return false
404
+
405
+ return robotField.value === rName
406
+ })
407
+ }
408
+
409
+ interface ParseSafeTemplateOpts {
410
+ silent?: boolean
411
+ }
412
+
413
+ export const getIndentation = (templateContent: string) => {
414
+ const lines = templateContent.split('\n')
415
+
416
+ // Find the first line with content and its indentation level
417
+ let baseIndent = ''
418
+ let firstContentLine = ''
419
+ for (const line of lines) {
420
+ const trimmed = line.trim()
421
+ if (trimmed.length > 0) {
422
+ firstContentLine = line
423
+ baseIndent = line.slice(0, line.length - trimmed.length)
424
+ break
425
+ }
426
+ }
427
+
428
+ // Find the first nested line's indentation
429
+ let nestedIndent = ''
430
+ let foundNested = false
431
+ for (const line of lines) {
432
+ const trimmed = line.trim()
433
+ if (trimmed.length === 0) continue
434
+
435
+ // Skip the first content line
436
+ if (line === firstContentLine) continue
437
+
438
+ const currentIndent = line.slice(0, line.length - trimmed.length)
439
+ if (currentIndent.length > baseIndent.length) {
440
+ nestedIndent = currentIndent.slice(baseIndent.length)
441
+ foundNested = true
442
+ break
443
+ }
444
+ }
445
+
446
+ // If we found nested indentation, use that
447
+ if (foundNested) {
448
+ // For tabs, return the actual number of tabs used
449
+ if (nestedIndent.includes('\t')) {
450
+ return '\t'.repeat(nestedIndent.split('\t').length - 1)
451
+ }
452
+ // For spaces, return the actual number of spaces used
453
+ return nestedIndent
454
+ }
455
+
456
+ // Default to 2 spaces if no nested indentation found
457
+ return ' '
458
+ }
459
+
460
+ export class StepParsingError extends Error {
461
+ zodIssuesWithContext: ZodIssueWithContext[]
462
+
463
+ humanReadable: string
464
+
465
+ constructor(message: string, zodIssuesWithContext: ZodIssueWithContext[], humanReadable: string) {
466
+ super(message)
467
+ this.zodIssuesWithContext = zodIssuesWithContext
468
+ this.humanReadable = humanReadable
469
+ }
470
+ }
471
+
472
+ function formatZodIssuesForLog(
473
+ issues: ZodIssueWithContext[],
474
+ ): Array<{ path: (string | number)[]; code: string; message: string }> {
475
+ return issues.map((issue) => ({
476
+ path: issue.path,
477
+ code: issue.code,
478
+ message: issue.message,
479
+ }))
480
+ }
481
+
482
+ export const parseSafeTemplate = (
483
+ templateContent: string,
484
+ opts?: ParseSafeTemplateOpts,
485
+ ): [StepParsingError, null] | [null, AssemblyInstructionsInput, string] => {
486
+ const silent = opts?.silent ?? (process.env.NODE_ENV ?? 'production') === 'production'
487
+ let parsed: unknown
488
+ let indent = ' '
489
+ try {
490
+ parsed = JSON.parse(templateContent)
491
+
492
+ indent = getIndentation(templateContent)
493
+ } catch (error) {
494
+ if (!silent) {
495
+ console.error('templateContent', { length: templateContent.length })
496
+ }
497
+ return [
498
+ new StepParsingError(
499
+ `Error parsing valid type from Template. ${error}. Input length: ${templateContent.length}`,
500
+ [],
501
+ '',
502
+ ),
503
+ null,
504
+ ]
505
+ }
506
+ const {
507
+ success,
508
+ errors: zodIssuesWithContext,
509
+ humanReadable,
510
+ } = zodParseWithContext(assemblyInstructionsSchema, parsed)
511
+ if (!success) {
512
+ if (!silent) {
513
+ console.error('zodIssuesWithContext', formatZodIssuesForLog(zodIssuesWithContext))
514
+ }
515
+ return [
516
+ new StepParsingError(
517
+ 'Error validating Template against assemblyInstructionsSchema. ',
518
+ zodIssuesWithContext,
519
+ humanReadable,
520
+ ),
521
+ null,
522
+ ]
523
+ }
524
+
525
+ // We won't return zodRes.data because that will add all the defaults to the
526
+ // original input. So if we know it will pass, we return the original input.
527
+ // One of the few cases where `as` is safe and allowed.
528
+ const safe = parsed as AssemblyInstructionsInput
529
+ return [null, safe, indent]
530
+ }
531
+
532
+ export function botNeedsInput(robotName: string, stepName?: string, step?: StepInput) {
533
+ if (robotName.endsWith('/import')) {
534
+ return false
535
+ }
536
+ if (robotName === '/upload/handle') {
537
+ return false
538
+ }
539
+ if (robotName === '/html/convert') {
540
+ if (step && 'url' in step && typeof step.url === 'string' && step.url) {
541
+ return false
542
+ }
543
+ }
544
+ if (robotName === '/text/speak') {
545
+ if (step && 'prompt' in step && typeof step.prompt === 'string' && step.prompt) {
546
+ return false
547
+ }
548
+ }
549
+ if (robotName === '/image/generate') {
550
+ return false
551
+ }
552
+ if (stepName === ':original') {
553
+ return false
554
+ }
555
+
556
+ return true
557
+ }
558
+
559
+ export function getFirstStepNameThatDoesNotNeedInput(
560
+ templateContent: string,
561
+ excludeBots: string[] = [],
562
+ ): string {
563
+ // Used by functions that fix missing use parameters, which is
564
+ // a violation of our schema so we cannot use parseSafeTemplate
565
+ // here.
566
+ let parsed: unknown
567
+ try {
568
+ parsed = JSON.parse(templateContent)
569
+ } catch (_e) {
570
+ return ''
571
+ }
572
+
573
+ if (!isRecord(parsed) || !('steps' in parsed)) {
574
+ return ''
575
+ }
576
+
577
+ const stepsValue = (parsed as Record<string, unknown>).steps
578
+ if (!isRecord(stepsValue)) {
579
+ return ''
580
+ }
581
+
582
+ const stepsRecord = stepsValue as Record<string, StepInputRecord>
583
+
584
+ return getFirstStepNameThatDoesNotNeedInputFromSteps(stepsRecord, excludeBots)
585
+ }
586
+
587
+ const getFirstStepNameThatDoesNotNeedInputFromSteps = (
588
+ steps: Record<string, StepInputRecord>,
589
+ excludeBots: string[] = [],
590
+ ): string => {
591
+ return (
592
+ Object.keys(steps).find((stepName) => {
593
+ const step = steps[stepName]
594
+ return (
595
+ typeof step === 'object' &&
596
+ step !== null &&
597
+ typeof step.robot === 'string' &&
598
+ !botNeedsInput(step.robot, stepName, step) &&
599
+ !excludeBots.includes(step.robot)
600
+ )
601
+ }) ?? ''
602
+ )
603
+ }
604
+
605
+ function hasRobotInSteps(steps: StepsInput, rName: string | RegExp): boolean {
606
+ return Object.values(steps).some((step) => {
607
+ return typeof rName === 'string' ? step.robot === rName : rName.test(step.robot)
608
+ })
609
+ }
610
+
611
+ const hasSteps = (
612
+ template: AssemblyInstructionsInput,
613
+ ): template is AssemblyInstructionsInput & { steps: StepsInput } => {
614
+ return typeof template.steps === 'object' && template.steps !== null
615
+ }
616
+
617
+ export const hasRobot = (
618
+ templateContent: string,
619
+ rName: string | RegExp,
620
+ silent?: boolean,
621
+ ): boolean => {
622
+ const parseOpts = silent === undefined ? undefined : { silent }
623
+ const [templateError, template] = parseSafeTemplate(templateContent, parseOpts)
624
+ if (templateError) {
625
+ return false
626
+ }
627
+
628
+ return Object.values(template.steps ?? {}).some((step) => {
629
+ return typeof rName === 'string' ? step.robot === rName : rName.test(step.robot)
630
+ })
631
+ }
632
+
633
+ export const doesContentRequireUpload = (templateContent: string, opts?: { silent: boolean }) => {
634
+ const hasFileUploadRobot = hasRobot(templateContent, '/file/upload', opts?.silent)
635
+ const hasOriginalStepName = templateContent.includes(':original')
636
+
637
+ if (hasFileUploadRobot || hasOriginalStepName) {
638
+ return true
639
+ }
640
+
641
+ return false
642
+ }
643
+
644
+ export const canAssemblyJustRun = (templateContent: string) => {
645
+ const firstStepNameThatDoesNotNeedInput = getFirstStepNameThatDoesNotNeedInput(templateContent, [
646
+ '/upload/handle',
647
+ ])
648
+
649
+ if (firstStepNameThatDoesNotNeedInput !== '') {
650
+ return true
651
+ }
652
+
653
+ return false
654
+ }
655
+
656
+ export function addOptimizeRobots(templateContent: string): string {
657
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
658
+ if (templateError) {
659
+ return templateContent
660
+ }
661
+
662
+ const newSteps: StepsInput = {}
663
+ let hasResizeStep = false
664
+
665
+ for (const [stepName, step] of entries(template.steps)) {
666
+ newSteps[stepName] = step
667
+
668
+ if (step.robot === '/image/resize') {
669
+ hasResizeStep = true
670
+ const optimizeStepName = `${stepName}_optimized`
671
+ const optimizeStep: StepInput = {
672
+ robot: '/image/optimize',
673
+ use: [],
674
+ }
675
+ addUseReference(optimizeStep, stepName)
676
+ newSteps[optimizeStepName] = optimizeStep
677
+
678
+ // Update subsequent steps to use the new optimize step
679
+ for (const [, nextStep] of entries(template.steps)) {
680
+ renameUseReferences(nextStep, stepName, optimizeStepName)
681
+ }
682
+ }
683
+ }
684
+
685
+ if (!hasResizeStep) {
686
+ return templateContent
687
+ }
688
+
689
+ template.steps = newSteps
690
+ return JSON.stringify(template, null, indent)
691
+ }
692
+
693
+ export function removeRobots(templateContent: string, robots: string[] | RegExp = []): string {
694
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
695
+ if (templateError) {
696
+ return templateContent
697
+ }
698
+ if (!('steps' in template) || typeof template.steps !== 'object') {
699
+ return templateContent
700
+ }
701
+
702
+ const newSteps: StepsInput = {}
703
+
704
+ const firstImportStepName = Object.keys(template.steps).find((stepName) =>
705
+ template.steps?.[stepName]?.robot?.endsWith('/import'),
706
+ )
707
+
708
+ let usedBefore = ''
709
+ for (const [stepName, step] of entries(template.steps)) {
710
+ if (Array.isArray(robots) ? robots.includes(step.robot) : robots.test(step.robot)) {
711
+ usedBefore = stepName
712
+ continue
713
+ }
714
+
715
+ if (step.robot === '/file/serve' && firstImportStepName && usedBefore) {
716
+ // Copy step before modifying
717
+ const updatedStep = { ...step }
718
+ renameUseReferences(updatedStep, usedBefore, firstImportStepName)
719
+ newSteps[stepName] = updatedStep
720
+ } else {
721
+ newSteps[stepName] = step
722
+ }
723
+ }
724
+
725
+ return JSON.stringify({ ...template, steps: newSteps }, null, indent)
726
+ }
727
+
728
+ export function removeFileServeRobots(templateContent: string): string {
729
+ return removeRobots(templateContent, ['/file/serve'])
730
+ }
731
+
732
+ export function removeUploading(templateContent: string): string {
733
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
734
+ if (templateError) {
735
+ return templateContent
736
+ }
737
+ if (!('steps' in template) || typeof template.steps !== 'object') {
738
+ return templateContent
739
+ }
740
+
741
+ const newSteps: StepsInput = {}
742
+
743
+ const firstImportStepName = Object.keys(template.steps).find((stepName) =>
744
+ template.steps?.[stepName]?.robot?.endsWith('/import'),
745
+ )
746
+
747
+ for (const [stepName, step] of entries(template.steps)) {
748
+ if (step.robot !== '/upload/handle') {
749
+ newSteps[stepName] = firstImportStepName
750
+ ? renameUseReferences(step, ':original', firstImportStepName)
751
+ : step
752
+ }
753
+ }
754
+
755
+ template.steps = newSteps
756
+ return JSON.stringify(template, null, indent)
757
+ }
758
+
759
+ export function getRobotList(templateContent: string): string[] {
760
+ const [templateError, template] = parseSafeTemplate(templateContent)
761
+ if (templateError) {
762
+ return []
763
+ }
764
+
765
+ return entries(template.steps).map(([, step]) => step.robot)
766
+ }
767
+
768
+ export function addStorageRobot(content: string): string {
769
+ const [templateError2, template2, indent] = parseSafeTemplate(content)
770
+ if (templateError2) {
771
+ return content
772
+ }
773
+ if (!('steps' in template2) || typeof template2.steps !== 'object') {
774
+ return content
775
+ }
776
+
777
+ // Find the last non-storage step
778
+ let lastNonStorageStep = ''
779
+ for (const [stepName, step] of entries(template2.steps)) {
780
+ if (!step.robot.endsWith('/store')) {
781
+ lastNonStorageStep = stepName
782
+ }
783
+ }
784
+
785
+ const storedStep: RobotS3StoreInstructionsInput = {
786
+ robot: '/s3/store' as const,
787
+ credentials: 'YOUR_S3_CREDENTIALS',
788
+ path: '/uploads/${file.id}/${file.name}',
789
+ use: [],
790
+ }
791
+
792
+ // Add references in order
793
+ if (lastNonStorageStep) {
794
+ addUseReference(storedStep, lastNonStorageStep)
795
+ }
796
+ if (template2.steps[':original']) {
797
+ addUseReference(storedStep, ':original')
798
+ }
799
+
800
+ template2.steps.stored = storedStep
801
+
802
+ return JSON.stringify(template2, null, indent)
803
+ }
804
+
805
+ export function addFilePreviewRobot(templateContent: string): string {
806
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
807
+ if (templateError) {
808
+ return templateContent
809
+ }
810
+ if (!('steps' in template) || typeof template.steps !== 'object') {
811
+ return templateContent
812
+ }
813
+ const steps = template.steps
814
+ const newSteps: StepsInput = {}
815
+
816
+ let importStepName: string | null = null
817
+ let serveStep: string | null = null
818
+ let width: RobotFilePreviewInstructionsInput['width']
819
+ let height: RobotFilePreviewInstructionsInput['height']
820
+ let format: RobotFilePreviewInstructionsInput['format']
821
+
822
+ // Identify steps and extract width
823
+ for (const [stepName, step] of entries(steps)) {
824
+ if (!botNeedsInput(step.robot)) {
825
+ importStepName = stepName
826
+ } else if (['/image/resize', '/video/thumb'].includes(step.robot)) {
827
+ if ('width' in step && (typeof step.width === 'number' || typeof step.width === 'string')) {
828
+ width = step.width
829
+ }
830
+ if (
831
+ 'height' in step &&
832
+ (typeof step.height === 'number' || typeof step.height === 'string')
833
+ ) {
834
+ height = step.height
835
+ }
836
+ if ('format' in step) {
837
+ if (step.format === 'png') {
838
+ format = 'png'
839
+ } else if (step.format === 'jpg') {
840
+ format = 'jpg'
841
+ } else if (step.format === 'gif') {
842
+ format = 'gif'
843
+ }
844
+ }
845
+ } else if (step.robot === '/file/serve') {
846
+ serveStep = stepName
847
+ }
848
+ }
849
+
850
+ // Add import step
851
+ if (importStepName) {
852
+ newSteps[importStepName] = steps[importStepName]
853
+ }
854
+
855
+ if (!width) {
856
+ if (serveStep) {
857
+ width = '${fields.w}'
858
+ } else {
859
+ width = 500
860
+ }
861
+ }
862
+ if (!height) {
863
+ if (serveStep) {
864
+ height = '${fields.h}'
865
+ } else {
866
+ height = 250
867
+ }
868
+ }
869
+
870
+ if (!format) {
871
+ format = 'png'
872
+ }
873
+
874
+ // Add preview step
875
+ newSteps.previewed = {
876
+ robot: '/file/preview',
877
+ use: [],
878
+ format,
879
+ width,
880
+ height,
881
+ resize_strategy: 'min_fit',
882
+ } satisfies RobotFilePreviewInstructionsInput
883
+ if (importStepName) {
884
+ addUseReference(newSteps.previewed, importStepName)
885
+ }
886
+
887
+ // Update serve step
888
+ if (serveStep) {
889
+ newSteps[serveStep] = {
890
+ robot: '/file/serve',
891
+ use: [],
892
+ } satisfies RobotFileServeInstructionsInput
893
+ // Always use 'previewed' as the source for serve step
894
+ addUseReference(newSteps[serveStep], 'previewed')
895
+ }
896
+
897
+ template.steps = newSteps
898
+ return JSON.stringify(template, null, indent)
899
+ }
900
+
901
+ export function getRecommendations(templateContent: string, silent?: boolean): Recommendation[] {
902
+ const parseOpts = silent === undefined ? undefined : { silent }
903
+ const [templateError, template] = parseSafeTemplate(templateContent, parseOpts)
904
+ if (templateError) {
905
+ return []
906
+ }
907
+ if (!hasSteps(template)) {
908
+ return []
909
+ }
910
+
911
+ const recommendations: Recommendation[] = []
912
+ const steps = template.steps
913
+
914
+ const stepCount = Object.keys(steps).length
915
+ const canJustRun = getFirstStepNameThatDoesNotNeedInputFromSteps(steps, ['/upload/handle']) !== ''
916
+ const hasFileServe = hasRobotInSteps(steps, '/file/serve')
917
+ const hasStorageRobot = hasRobotInSteps(steps, /\/store$/)
918
+ const hasImageResizeStep = hasRobotInSteps(steps, '/image/resize')
919
+ const hasVideoThumbStep = hasRobotInSteps(steps, '/video/thumb')
920
+ const hasImageResizeOrVideoThumb = hasImageResizeStep || hasVideoThumbStep
921
+
922
+ // Add the storage recommendation if needed
923
+ if (!hasStorageRobot && !hasFileServe) {
924
+ recommendations.push({
925
+ id: 'ADD_STORAGE',
926
+ robotName: 'Add Storage Robot',
927
+ description: 'Add a storage Robot to permanently save your processed files.',
928
+ applyFunction: (templateContent2) => addStorageRobot(templateContent2),
929
+ iconSrc: '/assets/images/robots/s3-store.png',
930
+ })
931
+ }
932
+
933
+ if (stepCount === 2 && canJustRun && hasFileServe) {
934
+ recommendations.push({
935
+ id: 'ADD_FILE_PREVIEW',
936
+ robotName: 'Add /file/preview',
937
+ description:
938
+ 'You are serving assets directly to users. Consider adding a preview Step to reduce sizes and bandwidth usage.',
939
+ applyFunction: (templateContent2) => addFilePreviewRobot(templateContent2),
940
+ iconSrc: '/assets/images/robots/file-preview.png',
941
+ })
942
+ } else if (stepCount === 3 && canJustRun && hasFileServe && hasImageResizeOrVideoThumb) {
943
+ const robotToSwap = hasImageResizeStep ? '/image/resize' : '/video/thumb'
944
+ const imageOrVideo = hasImageResizeStep ? 'image' : 'video'
945
+ const negative = hasImageResizeStep ? 'video' : 'image'
946
+
947
+ recommendations.push({
948
+ id: 'REPLACE_THUMBESQUE_WITH_FILE_PREVIEW',
949
+ robotName: 'Use /file/preview',
950
+ description: `If you swap out the ${robotToSwap} Step for /file/preview, you can serve previews of not only ${imageOrVideo}s but also ${negative}s, audio, PDFs, and more.`,
951
+ applyFunction: (templateContent2) => addFilePreviewRobot(templateContent2),
952
+ iconSrc: '/assets/images/robots/file-preview.png',
953
+ })
954
+ } else if (
955
+ hasRobotInSteps(steps, '/image/resize') &&
956
+ !hasRobotInSteps(steps, '/image/optimize')
957
+ ) {
958
+ recommendations.push({
959
+ id: 'ADD_IMAGE_OPTIMIZE',
960
+ robotName: 'Add /image/optimize',
961
+ description: 'Optimize your resized images to reduce file size without losing quality.',
962
+ applyFunction: (templateContent2) => addOptimizeRobots(templateContent2),
963
+ iconSrc: '/assets/images/robots/image-optimize.png',
964
+ })
965
+ }
966
+
967
+ // Sort recommendations by id to ensure consistent order
968
+ return recommendations.sort((a, b) => a.id.localeCompare(b.id))
969
+ }
970
+
971
+ export function addFileServeRobot(templateContent: string): string {
972
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
973
+ if (templateError) {
974
+ return templateContent
975
+ }
976
+ if (!('steps' in template) || typeof template.steps !== 'object') {
977
+ return templateContent
978
+ }
979
+
980
+ const { steps } = template
981
+
982
+ let lastNonImportStepName = ''
983
+ let lastExportStepName = ''
984
+
985
+ // Find the last non-import step and any export step
986
+ for (const [stepName, step] of entries(steps)) {
987
+ if (!step.robot.endsWith('/import')) {
988
+ lastNonImportStepName = stepName
989
+ }
990
+ if (step.robot.includes('/store')) {
991
+ lastExportStepName = stepName
992
+ }
993
+ }
994
+
995
+ // If there's an export step, replace it with /file/serve
996
+ if (lastExportStepName) {
997
+ const lastExportStep = steps[lastExportStepName]
998
+ delete steps[lastExportStepName]
999
+
1000
+ const finalUse = getLastUsedStepName(lastExportStep)
1001
+ if (finalUse) {
1002
+ steps.served = {
1003
+ robot: '/file/serve',
1004
+ use: [],
1005
+ } satisfies RobotFileServeInstructionsInput
1006
+ addUseReference(steps.served, finalUse)
1007
+ }
1008
+ } else {
1009
+ // If no export step, append /file/serve as a new step
1010
+ steps.served = {
1011
+ robot: '/file/serve',
1012
+ use: [],
1013
+ } satisfies RobotFileServeInstructionsInput
1014
+ addUseReference(steps.served, lastNonImportStepName)
1015
+ }
1016
+
1017
+ return JSON.stringify(template, null, indent)
1018
+ }
1019
+
1020
+ export function addUploadHandleRobot(templateContent: string): string {
1021
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
1022
+ if (templateError) {
1023
+ return templateContent
1024
+ }
1025
+ if (!('steps' in template) || typeof template.steps !== 'object') {
1026
+ return templateContent
1027
+ }
1028
+
1029
+ const steps = template.steps
1030
+
1031
+ const stepsOrder = Object.keys(steps)
1032
+ let firstImportStepName: string | null = null
1033
+ let firstImportStepIndex = -1
1034
+
1035
+ // Find the first import step
1036
+ for (let i = 0; i < stepsOrder.length; i++) {
1037
+ const stepName = stepsOrder[i]
1038
+ const step = steps[stepName]
1039
+ if (step.robot.endsWith('/import')) {
1040
+ firstImportStepName = stepName
1041
+ firstImportStepIndex = i
1042
+ break
1043
+ }
1044
+ }
1045
+
1046
+ const newSteps: StepsInput = {}
1047
+ if (firstImportStepName !== null && firstImportStepIndex >= 0) {
1048
+ // Replace the first import step with ':original' at the same position
1049
+ const stepsOrderNew = [...stepsOrder]
1050
+ stepsOrderNew[firstImportStepIndex] = ':original'
1051
+
1052
+ for (const stepName of stepsOrderNew) {
1053
+ if (stepName === ':original') {
1054
+ newSteps[stepName] = { robot: '/upload/handle' }
1055
+ } else {
1056
+ let step = steps[stepName]
1057
+ step = renameUseReferences(step, firstImportStepName, ':original')
1058
+ newSteps[stepName] = step
1059
+ }
1060
+ }
1061
+ } else {
1062
+ // No import step, insert ':original' before the first step
1063
+ const stepsOrderNew = [':original', ...stepsOrder]
1064
+ let isFirstStep = true
1065
+ for (const stepName of stepsOrderNew) {
1066
+ if (stepName === ':original') {
1067
+ newSteps[stepName] = { robot: '/upload/handle' }
1068
+ } else {
1069
+ const step = steps[stepName]
1070
+ if (
1071
+ isFirstStep &&
1072
+ (!('use' in step) || (Array.isArray(step.use) && step.use.length === 0)) &&
1073
+ doesStepRobotSupportUse(step)
1074
+ ) {
1075
+ step.use = ':original'
1076
+ isFirstStep = false
1077
+ }
1078
+ newSteps[stepName] = step
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ template.steps = newSteps
1084
+ return JSON.stringify(template, null, indent)
1085
+ }
1086
+
1087
+ export function addImportRobot(templateContent: string): string {
1088
+ const [templateError, template, indent] = parseSafeTemplate(templateContent)
1089
+ if (templateError) {
1090
+ return templateContent
1091
+ }
1092
+ if (!('steps' in template) || typeof template.steps !== 'object') {
1093
+ return templateContent
1094
+ }
1095
+
1096
+ const steps = template.steps
1097
+ const newSteps: StepsInput = {}
1098
+
1099
+ // Check if an import or html/convert robot already exists
1100
+ const hasImportRobot =
1101
+ Boolean(getFirstStepNameThatDoesNotNeedInput(templateContent)) &&
1102
+ !hasRobot(templateContent, '/upload/handle')
1103
+ if (hasImportRobot) {
1104
+ return templateContent // No changes needed
1105
+ }
1106
+
1107
+ const uploadHandleStepName = Object.keys(steps).find(
1108
+ (stepName) => steps[stepName].robot === '/upload/handle',
1109
+ )
1110
+
1111
+ // Find the first non-import-non-export step to determine media type
1112
+ let firstNonImportExportStep: StepInput | undefined
1113
+ for (const [, step] of entries(steps)) {
1114
+ if (
1115
+ !firstNonImportExportStep &&
1116
+ !step.robot.endsWith('/import') &&
1117
+ !step.robot.includes('/store') &&
1118
+ step.robot !== '/file/serve'
1119
+ ) {
1120
+ firstNonImportExportStep = step
1121
+ }
1122
+ }
1123
+
1124
+ // Add the import step, replacing the upload/handle step if it exists
1125
+ const importStepName = 'imported'
1126
+
1127
+ let url: string
1128
+
1129
+ // Set URL based on the type of media being processed
1130
+ const robotName = firstNonImportExportStep?.robot || ''
1131
+ if (robotName.startsWith('/image/') || robotName === '/file/preview') {
1132
+ url = 'https://demos.transloadit.com/inputs/prinsengracht.jpg'
1133
+ } else if (robotName.startsWith('/video/')) {
1134
+ url = 'https://demos.transloadit.com/inputs/wave10.mp4'
1135
+ } else if (robotName.startsWith('/audio/')) {
1136
+ url = 'https://demos.transloadit.com/inputs/joakim_karud-rock_angel.mp3'
1137
+ } else if (robotName.startsWith('/document/')) {
1138
+ url = 'https://demos.transloadit.com/inputs/aws-cloud-best-practices.pdf'
1139
+ } else {
1140
+ url = 'https://demos.transloadit.com/inputs/prinsengracht.jpg'
1141
+ }
1142
+
1143
+ newSteps[importStepName] = {
1144
+ robot: '/http/import',
1145
+ url,
1146
+ } satisfies StepInput
1147
+
1148
+ // Update references and add other steps
1149
+ for (const [stepName, step] of entries(steps)) {
1150
+ if (stepName !== uploadHandleStepName) {
1151
+ const updatedStep = { ...step }
1152
+ if (uploadHandleStepName) {
1153
+ renameUseReferences(updatedStep, uploadHandleStepName, importStepName)
1154
+ } else if (
1155
+ !('use' in updatedStep) ||
1156
+ (Array.isArray(updatedStep.use) && updatedStep.use.length === 0)
1157
+ ) {
1158
+ addUseReference(updatedStep, importStepName)
1159
+ }
1160
+ newSteps[stepName] = updatedStep
1161
+ }
1162
+ }
1163
+
1164
+ template.steps = newSteps
1165
+ return JSON.stringify(template, null, indent)
1166
+ }
1167
+
1168
+ export function addFieldsInput(templateContent: string): string {
1169
+ let parsed: unknown
1170
+ let indent = ' '
1171
+ try {
1172
+ parsed = JSON.parse(templateContent)
1173
+ indent = getIndentation(templateContent)
1174
+ } catch (_e) {
1175
+ return templateContent
1176
+ }
1177
+
1178
+ if (!isRecord(parsed)) {
1179
+ return templateContent
1180
+ }
1181
+
1182
+ const parsedRecord = parsed as Record<string, unknown>
1183
+ const stepsValue = parsedRecord.steps
1184
+ if (!isRecord(stepsValue)) {
1185
+ return templateContent
1186
+ }
1187
+
1188
+ const steps = stepsValue as Record<string, StepInputRecord>
1189
+
1190
+ for (const [, step] of entries(steps)) {
1191
+ if (step.robot === '/http/import') {
1192
+ if ('url' in step && typeof step.url === 'string') {
1193
+ if (!step.url.includes('${fields.input}')) {
1194
+ let url: URL
1195
+ try {
1196
+ url = new URL(step.url)
1197
+ url.pathname = '${fields.input}'
1198
+ step.url = url.toString().replaceAll('%7B', '{').replaceAll('%7D', '}')
1199
+ } catch (_e) {
1200
+ step.url = 'https://demos.transloadit.com/${fields.input}'
1201
+ }
1202
+ }
1203
+ } else {
1204
+ step.url = 'https://demos.transloadit.com/${fields.input}'
1205
+ }
1206
+ } else if (step.robot.endsWith('/import')) {
1207
+ if ('path' in step && typeof step.path === 'string') {
1208
+ if (!step.path.includes('${fields.input}')) {
1209
+ let url: URL
1210
+ try {
1211
+ url = new URL(step.path)
1212
+ url.pathname = '${fields.input}'
1213
+ step.path = url.toString().replaceAll('%7B', '{').replaceAll('%7D', '}')
1214
+ } catch (_e) {
1215
+ step.path = '${fields.input}'
1216
+ }
1217
+ }
1218
+ } else {
1219
+ step.path = '${fields.input}'
1220
+ }
1221
+ break
1222
+ }
1223
+ }
1224
+
1225
+ parsedRecord.steps = steps
1226
+
1227
+ return JSON.stringify(parsedRecord, null, indent)
1228
+ }
1229
+
1230
+ function getExampleValueForField(
1231
+ rName: string,
1232
+ paramName: string,
1233
+ ): FieldOccurrence['exampleValues'][number][] {
1234
+ if (rName === '/http/import' && paramName === 'url') {
1235
+ return ['inputs/prinsengracht.jpg']
1236
+ }
1237
+ if (rName.endsWith('/import') && paramName === 'path') {
1238
+ return ['inputs/prinsengracht.jpg']
1239
+ }
1240
+
1241
+ if (paramName === 'width') {
1242
+ return [400]
1243
+ }
1244
+ if (paramName === 'height') {
1245
+ return [180]
1246
+ }
1247
+
1248
+ return []
1249
+ }
1250
+
1251
+ function getMostCommonExampleValue(
1252
+ occurrences: FieldOccurrence[],
1253
+ ): FieldOccurrence['exampleValues'][number] {
1254
+ const exampleValues = occurrences.map((occurrence) => occurrence.exampleValues[0])
1255
+ const exampleValueCounts = new Map<FieldOccurrence['exampleValues'][number], number>()
1256
+
1257
+ for (const value of exampleValues) {
1258
+ exampleValueCounts.set(value, (exampleValueCounts.get(value) || 0) + 1)
1259
+ }
1260
+
1261
+ let mostCommonValue: string | number | boolean | undefined
1262
+ let maxCount = 0
1263
+
1264
+ for (const [value, count] of exampleValueCounts.entries()) {
1265
+ if (count > maxCount) {
1266
+ maxCount = count
1267
+ mostCommonValue = value
1268
+ }
1269
+ }
1270
+
1271
+ if (mostCommonValue === undefined) {
1272
+ return ''
1273
+ }
1274
+
1275
+ return mostCommonValue
1276
+ }
1277
+
1278
+ // Modify the extractFieldNamesFromTemplate function
1279
+ export function extractFieldNamesFromTemplate(templateContent: string): ParsedTemplateField[] {
1280
+ const [templateError, template] = parseSafeTemplate(templateContent)
1281
+ if (templateError) {
1282
+ return []
1283
+ }
1284
+ const fieldsMap = new Map<string, ParsedTemplateField>()
1285
+
1286
+ function traverse(value: unknown, path: (number | string)[], stepName: string, rName: string) {
1287
+ if (typeof value === 'string') {
1288
+ const matches = value.match(/\${fields\.([a-zA-Z0-9_]+)}/g)
1289
+ if (matches) {
1290
+ for (const match of matches) {
1291
+ const fieldName = match.slice(9, -1)
1292
+ let field = fieldsMap.get(fieldName)
1293
+ if (!field) {
1294
+ field = {
1295
+ fieldName,
1296
+ occurrences: [],
1297
+ mostCommonExampleValue: '',
1298
+ }
1299
+ fieldsMap.set(fieldName, field)
1300
+ }
1301
+
1302
+ const parts = value.split(match)
1303
+ const [leader, trailer] = parts
1304
+ const paramName = String(path[0])
1305
+
1306
+ field.occurrences.push({
1307
+ stepName,
1308
+ exampleValues: getExampleValueForField(rName, paramName),
1309
+ rName,
1310
+ paramName,
1311
+ leader,
1312
+ trailer,
1313
+ requiresDenoEval: false,
1314
+ errors: [],
1315
+ path: [stepName, ...path],
1316
+ })
1317
+ }
1318
+ }
1319
+ } else if (Array.isArray(value)) {
1320
+ for (const [index, item] of value.entries()) {
1321
+ traverse(item, [...path, index], stepName, rName)
1322
+ }
1323
+ } else if (typeof value === 'object' && value !== null) {
1324
+ for (const [key, childValue] of entries(value)) {
1325
+ traverse(childValue, [...path, key], stepName, rName)
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ for (const [stepName, step] of entries(template.steps || {})) {
1331
+ if (typeof step !== 'object' || step === null) {
1332
+ continue
1333
+ }
1334
+ if (!('robot' in step)) {
1335
+ continue
1336
+ }
1337
+ if (typeof step.robot !== 'string') {
1338
+ continue
1339
+ }
1340
+ const rName = step.robot || ''
1341
+ traverse(step, [], stepName, rName)
1342
+ }
1343
+
1344
+ for (const field of fieldsMap.values()) {
1345
+ field.mostCommonExampleValue = getMostCommonExampleValue(field.occurrences)
1346
+ }
1347
+
1348
+ return Array.from(fieldsMap.values())
1349
+ }
1350
+
1351
+ function getFinalType(schema: z.ZodTypeAny): z.ZodTypeAny {
1352
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
1353
+ return getFinalType(schema.unwrap())
1354
+ }
1355
+ if (schema instanceof z.ZodDefault) {
1356
+ return getFinalType(schema._def.innerType)
1357
+ }
1358
+ return schema
1359
+ }
1360
+
1361
+ export function interpolateFieldsInTemplate(
1362
+ templateContent: string,
1363
+ allFields: ParsedTemplateField[],
1364
+ opts?: { silent: boolean },
1365
+ ): AssemblyInstructionsInput {
1366
+ const [templateError, template] = parseSafeTemplate(templateContent, opts)
1367
+ if (templateError) {
1368
+ return { steps: {} }
1369
+ }
1370
+ const { steps } = template
1371
+ const newSteps: StepsInput = {}
1372
+
1373
+ for (const [stepName, step] of entries(steps)) {
1374
+ const newStep = { ...step }
1375
+
1376
+ for (const [paramName, paramValue] of entries(step)) {
1377
+ if (typeof paramValue === 'string') {
1378
+ let newValue: string | number | boolean = paramValue
1379
+
1380
+ for (const field of allFields) {
1381
+ for (const occurrence of field.occurrences) {
1382
+ if (occurrence.stepName === stepName && occurrence.paramName === paramName) {
1383
+ const compiledValue = `${occurrence.leader}${field.value}${occurrence.trailer}`
1384
+ newValue = compiledValue
1385
+ }
1386
+ }
1387
+ }
1388
+
1389
+ // Convert to number if expected
1390
+ const schema = getSchemaForRobot(step.robot)
1391
+ if (schema && isZodObject(schema)) {
1392
+ const paramSchema = schema.shape[paramName]
1393
+ const finalParamSchema = getFinalType(paramSchema)
1394
+
1395
+ if (finalParamSchema instanceof z.ZodNumber) {
1396
+ const num = Number(newValue)
1397
+ if (!Number.isNaN(num)) {
1398
+ newValue = num
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ Object.assign(newStep, { [paramName]: newValue })
1404
+ }
1405
+ }
1406
+
1407
+ newSteps[stepName] = newStep
1408
+ }
1409
+
1410
+ return { ...template, steps: newSteps }
1411
+ }
1412
+
1413
+ export function validateInterpolatedTemplate(
1414
+ template: AssemblyInstructionsInput,
1415
+ fieldsWithValues: ParsedTemplateField[],
1416
+ fieldNameToValidate?: string,
1417
+ ): ValidationError[] {
1418
+ const errors: ValidationError[] = []
1419
+
1420
+ for (const [stepName, step] of entries(template.steps)) {
1421
+ const schema = getSchemaForRobot(step.robot)
1422
+ if (!schema) {
1423
+ console.error('No schema linked up for', step.robot)
1424
+ continue
1425
+ }
1426
+
1427
+ const zodRes = schema.safeParse(step)
1428
+ if (!zodRes.success) {
1429
+ for (const err of zodRes.error.errors) {
1430
+ if (err.path.length !== 1) {
1431
+ continue
1432
+ }
1433
+ if (typeof err.path[0] !== 'string') {
1434
+ continue
1435
+ }
1436
+
1437
+ const fieldNames: string[] = []
1438
+ for (const field of fieldsWithValues) {
1439
+ if (field.fieldName === fieldNameToValidate || fieldNameToValidate === undefined) {
1440
+ for (const occurrence of field.occurrences) {
1441
+ if (occurrence.stepName === stepName && occurrence.paramName === err.path[0]) {
1442
+ fieldNames.push(field.fieldName)
1443
+ }
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ if (fieldNames.length > 0) {
1449
+ const paramName = err.path[0]
1450
+ const value = Object.entries(step).find(([key]) => key === paramName)?.[1]
1451
+ errors.push({
1452
+ stepName,
1453
+ robotName: step.robot,
1454
+ paramName,
1455
+ value,
1456
+ fieldNames,
1457
+ message: err.message,
1458
+ })
1459
+ }
1460
+ }
1461
+ }
1462
+ }
1463
+
1464
+ return errors
1465
+ }