@transloadit/node 4.1.9 → 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 (105) hide show
  1. package/README.md +81 -1
  2. package/dist/Transloadit.d.ts +36 -5
  3. package/dist/Transloadit.d.ts.map +1 -1
  4. package/dist/Transloadit.js +228 -39
  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/mcache.d.ts.map +1 -1
  15. package/dist/alphalib/mcache.js +22 -7
  16. package/dist/alphalib/mcache.js.map +1 -1
  17. package/dist/alphalib/object.d.ts +20 -0
  18. package/dist/alphalib/object.d.ts.map +1 -0
  19. package/dist/alphalib/object.js +23 -0
  20. package/dist/alphalib/object.js.map +1 -0
  21. package/dist/alphalib/stepParsing.d.ts +93 -0
  22. package/dist/alphalib/stepParsing.d.ts.map +1 -0
  23. package/dist/alphalib/stepParsing.js +1154 -0
  24. package/dist/alphalib/stepParsing.js.map +1 -0
  25. package/dist/alphalib/templateMerge.d.ts +4 -0
  26. package/dist/alphalib/templateMerge.d.ts.map +1 -0
  27. package/dist/alphalib/templateMerge.js +22 -0
  28. package/dist/alphalib/templateMerge.js.map +1 -0
  29. package/dist/alphalib/types/assemblyReplay.d.ts +56 -0
  30. package/dist/alphalib/types/assemblyReplay.d.ts.map +1 -1
  31. package/dist/alphalib/types/assemblyReplayNotification.d.ts +56 -0
  32. package/dist/alphalib/types/assemblyReplayNotification.d.ts.map +1 -1
  33. package/dist/alphalib/types/assemblyStatus.d.ts +63 -57
  34. package/dist/alphalib/types/assemblyStatus.d.ts.map +1 -1
  35. package/dist/alphalib/types/assemblyStatus.js +9 -1
  36. package/dist/alphalib/types/assemblyStatus.js.map +1 -1
  37. package/dist/alphalib/types/assemblyUrls.d.ts +1 -1
  38. package/dist/alphalib/types/assemblyUrls.d.ts.map +1 -1
  39. package/dist/alphalib/types/assemblyUrls.js.map +1 -1
  40. package/dist/alphalib/types/robots/_index.d.ts +608 -81
  41. package/dist/alphalib/types/robots/_index.d.ts.map +1 -1
  42. package/dist/alphalib/types/robots/_index.js +4 -0
  43. package/dist/alphalib/types/robots/_index.js.map +1 -1
  44. package/dist/alphalib/types/robots/_instructions-primitives.d.ts +4 -4
  45. package/dist/alphalib/types/robots/_instructions-primitives.d.ts.map +1 -1
  46. package/dist/alphalib/types/robots/_instructions-primitives.js +1 -0
  47. package/dist/alphalib/types/robots/_instructions-primitives.js.map +1 -1
  48. package/dist/alphalib/types/robots/document-optimize.d.ts +489 -0
  49. package/dist/alphalib/types/robots/document-optimize.d.ts.map +1 -0
  50. package/dist/alphalib/types/robots/document-optimize.js +151 -0
  51. package/dist/alphalib/types/robots/document-optimize.js.map +1 -0
  52. package/dist/alphalib/types/template.d.ts +1050 -174
  53. package/dist/alphalib/types/template.d.ts.map +1 -1
  54. package/dist/cli/commands/assemblies.d.ts +20 -1
  55. package/dist/cli/commands/assemblies.d.ts.map +1 -1
  56. package/dist/cli/commands/assemblies.js +137 -2
  57. package/dist/cli/commands/assemblies.js.map +1 -1
  58. package/dist/cli/commands/auth.d.ts.map +1 -1
  59. package/dist/cli/commands/auth.js +19 -19
  60. package/dist/cli/commands/auth.js.map +1 -1
  61. package/dist/cli/commands/index.d.ts.map +1 -1
  62. package/dist/cli/commands/index.js +2 -1
  63. package/dist/cli/commands/index.js.map +1 -1
  64. package/dist/cli/docs/assemblyLintingExamples.d.ts +2 -0
  65. package/dist/cli/docs/assemblyLintingExamples.d.ts.map +1 -0
  66. package/dist/cli/docs/assemblyLintingExamples.js +10 -0
  67. package/dist/cli/docs/assemblyLintingExamples.js.map +1 -0
  68. package/dist/cli/helpers.d.ts +11 -0
  69. package/dist/cli/helpers.d.ts.map +1 -1
  70. package/dist/cli/helpers.js +29 -0
  71. package/dist/cli/helpers.js.map +1 -1
  72. package/dist/lintAssemblyInput.d.ts +10 -0
  73. package/dist/lintAssemblyInput.d.ts.map +1 -0
  74. package/dist/lintAssemblyInput.js +73 -0
  75. package/dist/lintAssemblyInput.js.map +1 -0
  76. package/dist/lintAssemblyInstructions.d.ts +29 -0
  77. package/dist/lintAssemblyInstructions.d.ts.map +1 -0
  78. package/dist/lintAssemblyInstructions.js +33 -0
  79. package/dist/lintAssemblyInstructions.js.map +1 -0
  80. package/dist/tus.d.ts +2 -1
  81. package/dist/tus.d.ts.map +1 -1
  82. package/dist/tus.js +2 -1
  83. package/dist/tus.js.map +1 -1
  84. package/package.json +5 -2
  85. package/src/Transloadit.ts +318 -49
  86. package/src/alphalib/assembly-linter.lang.en.ts +393 -0
  87. package/src/alphalib/assembly-linter.ts +1475 -0
  88. package/src/alphalib/mcache.ts +26 -7
  89. package/src/alphalib/object.ts +27 -0
  90. package/src/alphalib/stepParsing.ts +1465 -0
  91. package/src/alphalib/templateMerge.ts +32 -0
  92. package/src/alphalib/types/assemblyStatus.ts +9 -1
  93. package/src/alphalib/types/assemblyUrls.ts +2 -5
  94. package/src/alphalib/types/robots/_index.ts +14 -0
  95. package/src/alphalib/types/robots/_instructions-primitives.ts +1 -0
  96. package/src/alphalib/types/robots/document-optimize.ts +180 -0
  97. package/src/alphalib/typings/json-to-ast.d.ts +34 -0
  98. package/src/cli/commands/assemblies.ts +161 -2
  99. package/src/cli/commands/auth.ts +19 -22
  100. package/src/cli/commands/index.ts +2 -0
  101. package/src/cli/docs/assemblyLintingExamples.ts +9 -0
  102. package/src/cli/helpers.ts +50 -0
  103. package/src/lintAssemblyInput.ts +89 -0
  104. package/src/lintAssemblyInstructions.ts +72 -0
  105. package/src/tus.ts +3 -0
@@ -0,0 +1,1475 @@
1
+ import type { ValueNode } from 'json-to-ast'
2
+ import parse from 'json-to-ast'
3
+ import { z } from 'zod'
4
+
5
+ import { entries } from './object.ts'
6
+ import {
7
+ addUseReference,
8
+ botNeedsInput,
9
+ doesStepRobotSupportUse,
10
+ getFirstStepNameThatDoesNotNeedInput,
11
+ getIndentation,
12
+ hasRobot,
13
+ parseSafeTemplate,
14
+ } from './stepParsing.ts'
15
+ import { robotsMeta } from './types/robots/_index.ts'
16
+ import type { InterpolatableRobotHttpImportInstructionsWithHiddenFieldsInput } from './types/robots/http-import.ts'
17
+ import { stackVersions } from './types/stackVersions.ts'
18
+ import type { StepInput } from './types/template.ts'
19
+ import { assemblyInstructionsSchema } from './types/template.ts'
20
+ import { zodParseWithContext } from './zodParseWithContext.ts'
21
+
22
+ // Maximum number of steps allowed in a Smart CDN Assembly
23
+ // We set this ~unreasonably high for now as it could already avoid misuse/abuse
24
+ // until we have settled on a discussion about limits:
25
+ // See: https://github.com/transloadit/content/pull/4176
26
+ const MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY = 20
27
+
28
+ // Type for objects with AST metadata added by getASTValue
29
+ type WithASTMetadata<T extends object> = T & {
30
+ __line: Record<string, number>
31
+ __column: Record<string, number>
32
+ }
33
+
34
+ // Define all possible linter result codes
35
+ export type LinterResultCode =
36
+ | 'duplicate-key-in-step'
37
+ | 'empty-steps'
38
+ | 'empty-use-array'
39
+ | 'infinite-assembly'
40
+ | 'invalid-json'
41
+ | 'invalid-steps-type'
42
+ | 'missing-ffmpeg-stack'
43
+ | 'missing-imagemagick-stack'
44
+ | 'missing-input'
45
+ | 'missing-original-storage'
46
+ | 'missing-robot'
47
+ | 'missing-steps'
48
+ | 'missing-url'
49
+ | 'schema-violation'
50
+ | 'missing-use-steps'
51
+ | 'missing-use'
52
+ | 'no-storage'
53
+ | 'smart-cdn-input-field-missing'
54
+ | 'step-is-not-an-object'
55
+ | 'undefined-robot'
56
+ | 'undefined-step'
57
+ | 'unqualified-http-import-url'
58
+ | 'smart-cdn-max-steps-exceeded'
59
+ | 'smart-cdn-robot-not-allowed'
60
+ | 'wrong-ffmpeg-version'
61
+ | 'wrong-imagemagick-version'
62
+ | 'wrong-step-name'
63
+ | 'wrong-use-type'
64
+
65
+ type StepWithMetadata = StepInput & {
66
+ __line: Record<string, number>
67
+ __column: Record<string, number>
68
+ }
69
+
70
+ interface StepsWithMetadata {
71
+ [stepName: string]: StepWithMetadata | Record<string, number>
72
+ __line: Record<string, number>
73
+ __column: Record<string, number>
74
+ }
75
+
76
+ interface TemplateWithMetadata extends Record<string, unknown> {
77
+ steps?: StepsWithMetadata
78
+ __line?: Record<string, number>
79
+ __column?: Record<string, number>
80
+ }
81
+
82
+ const getStepLocation = (
83
+ steps: StepsWithMetadata,
84
+ stepName: string,
85
+ ): { row: number; column: number } => ({
86
+ row: steps.__line?.[stepName] ?? 0,
87
+ column: steps.__column?.[stepName] ?? 0,
88
+ })
89
+
90
+ const fixWrongStackVersionSchema = z.object({
91
+ stepName: z.string(),
92
+ paramName: z.string(),
93
+ recommendedVersion: z.string(),
94
+ })
95
+ type FixDataWrongStackVersion = z.infer<typeof fixWrongStackVersionSchema>
96
+ const fixMissingUseSchema = z.object({
97
+ stepName: z.string(),
98
+ })
99
+ type FixDataMissingUse = z.infer<typeof fixMissingUseSchema>
100
+ const fixDuplicateKeyInStepSchema = z.object({
101
+ stepName: z.string(),
102
+ duplicateKeys: z.array(z.string()),
103
+ })
104
+ type FixDataDuplicateKeyInStep = z.infer<typeof fixDuplicateKeyInStepSchema>
105
+ const fixSmartCdnInputFieldSchema = z.object({
106
+ stepName: z.string(),
107
+ })
108
+ type FixDataSmartCdnInputField = z.infer<typeof fixSmartCdnInputFieldSchema>
109
+ const fixMissingInputSchema = z.object({})
110
+ type FixDataMissingInput = z.infer<typeof fixMissingInputSchema>
111
+ const fixMissingStepsSchema = z.object({})
112
+ type FixDataMissingSteps = z.infer<typeof fixMissingStepsSchema>
113
+ const fixInvalidStepsTypeSchema = z.object({})
114
+ type FixDataInvalidStepsType = z.infer<typeof fixInvalidStepsTypeSchema>
115
+ const fixEmptyStepsSchema = z.object({})
116
+ type FixDataEmptySteps = z.infer<typeof fixEmptyStepsSchema>
117
+ const fixMissingOriginalStorageSchema = z.object({})
118
+ type FixDataMissingOriginalStorage = z.infer<typeof fixMissingOriginalStorageSchema>
119
+
120
+ export type FixData =
121
+ | { fixId: 'fix-wrong-stack-version'; fixData: FixDataWrongStackVersion }
122
+ | { fixId: 'fix-missing-use'; fixData: FixDataMissingUse }
123
+ | { fixId: 'fix-duplicate-key-in-step'; fixData: FixDataDuplicateKeyInStep }
124
+ | { fixId: 'fix-missing-input'; fixData: FixDataMissingInput }
125
+ | { fixId: 'fix-missing-steps'; fixData: FixDataMissingSteps }
126
+ | { fixId: 'fix-invalid-steps-type'; fixData: FixDataInvalidStepsType }
127
+ | { fixId: 'fix-empty-steps'; fixData: FixDataEmptySteps }
128
+ | { fixId: 'fix-missing-original-storage'; fixData: FixDataMissingOriginalStorage }
129
+ | { fixId: 'fix-smart-cdn-input-field'; fixData: FixDataSmartCdnInputField }
130
+
131
+ export type AssemblyLinterResult = {
132
+ code: LinterResultCode
133
+ type: 'error' | 'warning'
134
+ row: number
135
+ column: number
136
+ message?: string
137
+ stepName?: string
138
+ robot?: string
139
+ isAudioRobot?: boolean
140
+ stackVersion?: string
141
+ wrongStepName?: string
142
+ desc?: string | null
143
+ duplicateKeys?: string[]
144
+ // For URL Transform validation
145
+ maxStepCount?: number // Optional: Max steps allowed for Smart CDN
146
+ stepCount?: number // Optional: Actual number of steps found
147
+ } & Partial<FixData>
148
+
149
+ class ParseError extends SyntaxError {
150
+ line: number
151
+
152
+ column: number
153
+
154
+ rawMessage: string
155
+
156
+ source: null | string
157
+
158
+ constructor(
159
+ message: string,
160
+ line: number,
161
+ column: number,
162
+ rawMessage: string,
163
+ source: string | null,
164
+ ) {
165
+ super(message)
166
+ this.line = line
167
+ this.column = column
168
+ this.rawMessage = rawMessage
169
+ this.source = source
170
+ }
171
+ }
172
+
173
+ function isObject(obj: unknown): obj is object {
174
+ return typeof obj === 'object' && !Array.isArray(obj) && obj !== null
175
+ }
176
+ function has<K extends string>(object: object, key: K): object is Record<K, unknown> {
177
+ return Object.hasOwn(object, key)
178
+ }
179
+
180
+ function isParseError(e: unknown): e is ParseError {
181
+ return (
182
+ e instanceof Error &&
183
+ isObject(e) &&
184
+ 'line' in e &&
185
+ typeof e.line === 'number' &&
186
+ 'column' in e &&
187
+ typeof e.column === 'number' &&
188
+ 'rawMessage' in e &&
189
+ typeof e.rawMessage === 'string'
190
+ )
191
+ }
192
+
193
+ // getASTValue traverses through the provided AST and will return
194
+ // the JavaScript value described by it.
195
+ // Objects and arrays will also have the __line and __column
196
+ // properties containing line and column number for their
197
+ // child elements.
198
+ // See https://github.com/vtrushin/json-to-ast#node-types
199
+ function getASTValue(ast: ValueNode): unknown {
200
+ switch (ast.type) {
201
+ case 'Literal':
202
+ return ast.value
203
+ case 'Array': {
204
+ const value: unknown[] = []
205
+ const lines: number[] = []
206
+ const columns: number[] = []
207
+
208
+ for (const property of ast.children as ValueNode[]) {
209
+ if (property.loc) {
210
+ // json-to-ast starts the line and column numbers at 1 but the
211
+ // ace editor expects them to start at 0. To make up for that
212
+ // difference we subtract 1.
213
+ value.push(getASTValue(property))
214
+ lines.push(property.loc.start.line - 1)
215
+ columns.push(property.loc.start.column - 1)
216
+ }
217
+ }
218
+ Object.defineProperty(value, '__line', { value: lines })
219
+ Object.defineProperty(value, '__column', { value: columns })
220
+ return value
221
+ }
222
+ case 'Object': {
223
+ const value: Record<string, unknown> = {}
224
+ const lines: Record<string, number> = {}
225
+ const columns: Record<string, number> = {}
226
+
227
+ for (const property of ast.children) {
228
+ if (property.key && property.value && property.value.loc) {
229
+ // json-to-ast starts the line and column numbers at 1 but the
230
+ // ace editor expects them to start at 0. To make up for that
231
+ // difference we subtract 1.
232
+ value[property.key.value] = getASTValue(property.value)
233
+ lines[property.key.value] = property.value.loc.start.line - 1
234
+ columns[property.key.value] = property.value.loc.start.column - 1
235
+ }
236
+ }
237
+ Object.defineProperty(value, '__line', { value: lines })
238
+ Object.defineProperty(value, '__column', { value: columns })
239
+ return value
240
+ }
241
+ default:
242
+ // Should not happen for valid ValueNode types
243
+ return undefined
244
+ }
245
+ }
246
+
247
+ // getRobotsUsingTool returns an array of the robots names
248
+ // which have a specific tool. This can be used to
249
+ // get all robots supporting the ffmpeg_stack setting, for example.
250
+ function getRobotsUsingTool(tool?: 'ffmpeg' | 'imagemagick') {
251
+ return Object.entries(robotsMeta)
252
+ .filter(([, meta]) => !tool || meta.uses_tools?.includes(tool))
253
+ .map(([varName]) => {
254
+ // turn: audioArtworkMeta -> /audio/artwork
255
+ // turn: s3StoreMeta -> /s3/store
256
+ const rName = `/${varName
257
+ .replace(/([a-z0-9])([A-Z])/g, '$1/$2')
258
+ .toLowerCase()
259
+ .replace(/\/meta$/, '')}`
260
+
261
+ return rName
262
+ })
263
+ }
264
+
265
+ const STORE_ROBOT_NAME = /^\/[a-z0-9]+\/store$/i
266
+ function isStoreRobot(name: string) {
267
+ return STORE_ROBOT_NAME.test(name)
268
+ }
269
+
270
+ const IMPORT_ROBOT_NAME = /^\/[a-z0-9]+\/import$/i
271
+ function isImportRobot(name: string) {
272
+ return IMPORT_ROBOT_NAME.test(name)
273
+ }
274
+
275
+ const FFMPEG_ROBOT_NAMES = getRobotsUsingTool('ffmpeg')
276
+ function isFfmpegRobot(name: string) {
277
+ return FFMPEG_ROBOT_NAMES.some((x) => x === name)
278
+ }
279
+
280
+ const IMAGICK_ROBOT_NAMES = getRobotsUsingTool('imagemagick')
281
+ function isImagickRobot(name: string) {
282
+ return IMAGICK_ROBOT_NAMES.some((x) => x === name)
283
+ }
284
+
285
+ const ALL_ROBOT_NAMES = getRobotsUsingTool()
286
+ function isRobot(name: string) {
287
+ return ALL_ROBOT_NAMES.includes(name)
288
+ }
289
+
290
+ function isHttpImportRobot(name: string) {
291
+ return name === '/http/import'
292
+ }
293
+
294
+ // lintStackParameter validates whether a given step has a proper
295
+ // ffmpeg_stack or imagemagick_stack paramater with an existing version.
296
+ // Which parameter is expected is controlled by the stackName
297
+ // argument which should either by 'ffmpeg' or 'imagemagick'.
298
+ // If a linting issue is found, the corresponding message is added
299
+ // to the result array.
300
+ function lintStackParameter(
301
+ step: StepWithMetadata,
302
+ stepName: string,
303
+ steps: StepsWithMetadata,
304
+ stackName: keyof typeof stackVersions,
305
+ result: AssemblyLinterResult[],
306
+ ) {
307
+ const paramName = `${stackName}_stack` as 'ffmpeg_stack' | 'imagemagick_stack'
308
+
309
+ // Stack parameters are optional; when omitted, Transloadit defaults apply.
310
+ if (has(step, paramName)) {
311
+ const stackVersionValue = step[paramName]
312
+ if (typeof stackVersionValue === 'string') {
313
+ if (!stackVersions[stackName].test.test(stackVersionValue)) {
314
+ result.push({
315
+ code: `wrong-${stackName}-version` as LinterResultCode,
316
+ stepName,
317
+ robot: step.robot,
318
+ isAudioRobot: step.robot?.indexOf('/audio/') === 0,
319
+ stackVersion: stackVersionValue,
320
+ type: 'error',
321
+ row: steps.__line[stepName],
322
+ column: steps.__column[stepName],
323
+ fixId: 'fix-wrong-stack-version',
324
+ fixData: {
325
+ stepName,
326
+ paramName,
327
+ recommendedVersion: stackVersions[stackName].recommendedVersion,
328
+ },
329
+ })
330
+ }
331
+ } else {
332
+ // Handle cases where the stack parameter is present but not a string (though schema should catch this)
333
+ // Or, decide if this case is impossible due to schema validation and remove this else.
334
+ // For now, let's assume schema validation makes this path unlikely for a 'wrong-version' error,
335
+ // but a general 'schema-violation' might be more appropriate if this state is reached.
336
+ }
337
+ }
338
+ }
339
+
340
+ function lintUseArray(
341
+ use: unknown,
342
+ stepName: string,
343
+ stepNames: string[],
344
+ result: AssemblyLinterResult[],
345
+ row: number | undefined,
346
+ column: number | undefined,
347
+ ) {
348
+ if (!Array.isArray(use)) return
349
+ if (use.length === 0) {
350
+ result.push({
351
+ code: 'empty-use-array',
352
+ stepName,
353
+ type: 'warning',
354
+ row: row ?? 0,
355
+ column: column ?? 0,
356
+ })
357
+ return
358
+ }
359
+
360
+ use.forEach(
361
+ (obj: string | { name: string; __line?: number[]; __column?: number[] }, index: number) => {
362
+ let name: string | undefined
363
+ if (typeof obj === 'object' && obj !== null) {
364
+ name = obj.name
365
+ } else if (typeof obj === 'string') {
366
+ name = obj
367
+ }
368
+
369
+ if (name && stepNames.indexOf(name) === -1) {
370
+ result.push({
371
+ code: 'undefined-step',
372
+ stepName,
373
+ wrongStepName: name,
374
+ type: 'error',
375
+ row:
376
+ typeof obj === 'object' && obj !== null && obj.__line?.[index]
377
+ ? obj.__line[index]
378
+ : (row ?? 0),
379
+ column:
380
+ typeof obj === 'object' && obj !== null && obj.__column?.[index]
381
+ ? obj.__column[index]
382
+ : (column ?? 0),
383
+ })
384
+ }
385
+ },
386
+ )
387
+ }
388
+
389
+ function lintHttpImportUrl(
390
+ step: StepWithMetadata,
391
+ stepName: string,
392
+ result: AssemblyLinterResult[],
393
+ ) {
394
+ if (!has(step, 'url')) {
395
+ return
396
+ }
397
+
398
+ const { url } = step
399
+ if (typeof url !== 'string') {
400
+ return
401
+ }
402
+
403
+ // Check if the URL contains a field variable without a protocol or domain
404
+ // Only warn when the URL is exactly an interpolation to avoid false positives.
405
+ const fieldVariableRegex = /^\$\{fields\.[^}]+\}$/
406
+ const protocolDomainRegex = /^(https?:\/\/|\/\/)[^/]+/i
407
+
408
+ if (fieldVariableRegex.test(url) && !protocolDomainRegex.test(url)) {
409
+ result.push({
410
+ code: 'unqualified-http-import-url',
411
+ stepName,
412
+ type: 'warning',
413
+ row: step.__line.url,
414
+ column: step.__column.url,
415
+ message:
416
+ 'The /http/import url should include a protocol and domain name for security reasons.',
417
+ })
418
+ }
419
+ }
420
+
421
+ export function lint(assembly: TemplateWithMetadata): AssemblyLinterResult[] {
422
+ const result: AssemblyLinterResult[] = []
423
+
424
+ if (!isObject(assembly) || !('steps' in assembly)) {
425
+ result.push({
426
+ code: 'missing-steps',
427
+ type: 'error',
428
+ row: 0,
429
+ column: 0,
430
+ message: "The 'steps' property is missing",
431
+ fixId: 'fix-missing-steps',
432
+ fixData: {},
433
+ })
434
+ return result
435
+ }
436
+
437
+ if (!isObject(assembly.steps)) {
438
+ result.push({
439
+ code: 'invalid-steps-type',
440
+ type: 'error',
441
+ row: assembly.__line?.steps ?? 0,
442
+ column: assembly.__column?.steps ?? 0,
443
+ message: "The 'steps' property must be an object",
444
+ fixId: 'fix-invalid-steps-type',
445
+ fixData: {},
446
+ })
447
+ return result
448
+ }
449
+
450
+ if (Object.keys(assembly.steps).length === 0) {
451
+ result.push({
452
+ code: 'empty-steps',
453
+ type: 'warning',
454
+ row: assembly.__line?.steps ?? 0,
455
+ column: assembly.__column?.steps ?? 0,
456
+ message: "The 'steps' object is empty",
457
+ fixId: 'fix-empty-steps',
458
+ fixData: {},
459
+ })
460
+ return result // Return here to avoid additional checks for empty steps
461
+ }
462
+
463
+ if (!isObject(assembly.steps)) {
464
+ return result
465
+ }
466
+
467
+ const steps = assembly.steps as StepsWithMetadata
468
+ const stepNames = Object.keys(steps).filter(
469
+ (key) => key !== '__line' && key !== '__column',
470
+ )
471
+ if (!stepNames.includes(':original')) {
472
+ // The step :original always exists automatically
473
+ stepNames.push(':original')
474
+ }
475
+
476
+ let hasFileServe = false
477
+ let hasFieldsInput = false
478
+ let importStepName = ''
479
+
480
+ // First pass - check for /file/serve and ${fields.input}
481
+ for (const [stepName, step] of Object.entries(steps)) {
482
+ if (stepName === '__line' || stepName === '__column') continue
483
+ if (!isObject(step)) continue
484
+
485
+ // Ensure 'step' is actually a StepWithMetadata-like object, not just Record<string, number>
486
+ // A simple check for 'robot' or other StepInput fields can make the cast safer.
487
+ // StepInput is { robot?: string; use?: unknown; ... } & Record<string, unknown>
488
+ // StepWithMetadata adds __line and __column to StepInput.
489
+ // A Record<string, number> would not typically have these specific fields like 'robot'.
490
+ if (!('robot' in step || 'use' in step)) {
491
+ // This object doesn't look like a step, skip or handle as an error.
492
+ // For now, let's assume it might be an invalid structure caught by schema validation later
493
+ // or it's a case not expected here if it passed earlier checks.
494
+ continue
495
+ }
496
+
497
+ const typedStep = step as StepWithMetadata
498
+ if (!typedStep.robot) continue
499
+
500
+ // Check if we have a /file/serve robot anywhere
501
+ if (typedStep.robot === '/file/serve') {
502
+ hasFileServe = true
503
+ }
504
+
505
+ // Check if we use ${fields.input} in any import step
506
+ if (isImportRobot(typedStep.robot)) {
507
+ importStepName = stepName
508
+ const stepStr = JSON.stringify(step)
509
+ if (stepStr.includes('${fields.input}')) {
510
+ hasFieldsInput = true
511
+ }
512
+ }
513
+ }
514
+
515
+ // If we have /file/serve but don't use ${fields.input} in the import step, add warning
516
+ if (hasFileServe && !hasFieldsInput && importStepName) {
517
+ const { row, column } = getStepLocation(steps, importStepName)
518
+ result.push({
519
+ code: 'smart-cdn-input-field-missing',
520
+ type: 'warning',
521
+ row,
522
+ column,
523
+ message: 'Smart CDN path component available as `${fields.input}`',
524
+ stepName: importStepName,
525
+ fixId: 'fix-smart-cdn-input-field',
526
+ fixData: { stepName: importStepName },
527
+ })
528
+ }
529
+
530
+ let usesOriginalFiles = false
531
+ let storesOriginalFiles = false
532
+ let hasInputStep = false
533
+
534
+ for (const [stepName, step] of Object.entries(steps)) {
535
+ if (stepName === '__line' || stepName === '__column') continue
536
+ const { row, column } = getStepLocation(steps, stepName)
537
+
538
+ if (!step || typeof step !== 'object' || Array.isArray(step)) {
539
+ result.push({
540
+ code: 'step-is-not-an-object',
541
+ stepName,
542
+ type: 'error',
543
+ row,
544
+ column,
545
+ })
546
+ continue
547
+ }
548
+
549
+ const stepKeys = Object.keys(step).filter((key) => key !== '__line' && key !== '__column')
550
+ if (!('robot' in step || 'use' in step)) {
551
+ if (stepKeys.length > 0) {
552
+ result.push({
553
+ code: 'missing-robot',
554
+ stepName,
555
+ type: 'error',
556
+ row,
557
+ column,
558
+ })
559
+ }
560
+ continue
561
+ }
562
+
563
+ const typedStep = step as StepWithMetadata
564
+
565
+ if (!typedStep.robot) {
566
+ result.push({
567
+ code: 'missing-robot',
568
+ stepName,
569
+ type: 'error',
570
+ row,
571
+ column,
572
+ })
573
+ continue
574
+ } else if (!isRobot(typedStep.robot)) {
575
+ result.push({
576
+ code: 'undefined-robot',
577
+ stepName,
578
+ robot: typedStep.robot,
579
+ type: 'error',
580
+ row: typedStep.__line.robot,
581
+ column: typedStep.__column.robot,
582
+ })
583
+ } else if (typedStep.robot === '/file/serve') {
584
+ hasFileServe = true
585
+ if ('url' in typedStep && !('use' in typedStep)) {
586
+ const stepStr = JSON.stringify(step)
587
+ if (!stepStr.includes('${fields.input}')) {
588
+ result.push({
589
+ code: 'smart-cdn-input-field-missing',
590
+ type: 'warning',
591
+ row,
592
+ column,
593
+ message: 'Smart CDN path component available as `${fields.input}`',
594
+ stepName,
595
+ })
596
+ }
597
+ }
598
+ } else if (isFfmpegRobot(typedStep.robot)) {
599
+ lintStackParameter(typedStep, stepName, steps, 'ffmpeg', result)
600
+ } else if (isImagickRobot(typedStep.robot)) {
601
+ lintStackParameter(typedStep, stepName, steps, 'imagemagick', result)
602
+ } else if (typedStep.robot === '/upload/handle') {
603
+ if (stepName !== ':original') {
604
+ result.push({
605
+ code: 'wrong-step-name',
606
+ type: 'error',
607
+ row,
608
+ column,
609
+ })
610
+ }
611
+ } else if (isHttpImportRobot(typedStep.robot)) {
612
+ lintHttpImportUrl(typedStep, stepName, result)
613
+ }
614
+
615
+ if (!has(typedStep, 'use')) {
616
+ if (typedStep.robot === '/html/convert') {
617
+ // The /html/convert robot can either act as a import robot when
618
+ // the `url` parameter is defined. Or it can be a conversion robot
619
+ // if `use` is available. If neither of those parameters is given,
620
+ // we emit a warning.
621
+ if (has(typedStep, 'url')) {
622
+ hasInputStep = true
623
+ } else {
624
+ result.push({
625
+ code: 'missing-url',
626
+ stepName,
627
+ type: 'warning',
628
+ row,
629
+ column,
630
+ })
631
+ }
632
+ } else if (
633
+ // Check if this robot doesn't need input (like import robots, /upload/handle,
634
+ // file-generating robots like /image/generate, /text/speak with prompt, etc.)
635
+ !botNeedsInput(typedStep.robot, stepName, typedStep)
636
+ ) {
637
+ hasInputStep = true
638
+ } else {
639
+ // Import robots and /upload/handle do not need a use parameter. For
640
+ // all others we emit a warning.
641
+ result.push({
642
+ code: 'missing-use',
643
+ stepName,
644
+ type: 'warning',
645
+ row,
646
+ column,
647
+ fixId: 'fix-missing-use',
648
+ fixData: { stepName },
649
+ })
650
+ }
651
+ } else {
652
+ if (Array.isArray(typedStep.use)) {
653
+ const referencesOriginal = typedStep.use.some((item) => {
654
+ if (typeof item === 'string') {
655
+ return item === ':original'
656
+ }
657
+ return (
658
+ typeof item === 'object' && item !== null && 'name' in item && item.name === ':original'
659
+ )
660
+ })
661
+ if (referencesOriginal) {
662
+ hasInputStep = true
663
+ }
664
+
665
+ // Situation 1: use parameter is an array, for example
666
+ // "use": [ "hello", { name: ":original" } ]
667
+ lintUseArray(
668
+ typedStep.use,
669
+ stepName,
670
+ stepNames,
671
+ result,
672
+ typedStep.__line.use,
673
+ typedStep.__column.use,
674
+ )
675
+ } else if (typeof typedStep.use === 'object' && typedStep.use !== null) {
676
+ // Situation 2: use parameter is an object, for example
677
+ // "use": { steps: [ "hello", "hi" ], "bundle_steps": true }
678
+ // typedStep.use here is inferred as StepUse, which can be StepUseObject
679
+ // StepUseObject has a MANDATORY 'steps' property of type StepUseArrayItemSchema[]
680
+ const useObject = typedStep.use // No immediate cast
681
+
682
+ if ('steps' in useObject) {
683
+ // Access metadata for the 'steps' key within the useObject, if available.
684
+ // The useObject itself, being a product of getASTValue for an object, should have __line/__column.
685
+ const useStepsLine = (useObject as WithASTMetadata<typeof useObject>)?.__line?.steps
686
+ const useStepsColumn = (useObject as WithASTMetadata<typeof useObject>)?.__column?.steps
687
+
688
+ if (Array.isArray(useObject.steps)) {
689
+ // Now useObject.steps is known to be an array.
690
+ // We still need to ensure elements match StepUseArrayItemSchema if processing them.
691
+ // The existing lintUseArray function takes 'unknown[]' for its first arg's 'steps' property if it's an object, so this is compatible.
692
+ if (
693
+ useObject.steps.some((step) => {
694
+ if (typeof step === 'string') {
695
+ return step === ':original'
696
+ }
697
+ return (
698
+ typeof step === 'object' &&
699
+ step !== null &&
700
+ 'name' in step &&
701
+ step.name === ':original'
702
+ )
703
+ })
704
+ ) {
705
+ hasInputStep = true
706
+ }
707
+
708
+ lintUseArray(
709
+ useObject.steps,
710
+ stepName,
711
+ stepNames,
712
+ result,
713
+ useStepsLine ?? typedStep.__line.use, // Fallback to the line of the 'use' key itself
714
+ useStepsColumn ?? typedStep.__column.use, // Fallback to the column of the 'use' key itself
715
+ )
716
+ } else if (typeof useObject.steps === 'string') {
717
+ if (useObject.steps === ':original') {
718
+ hasInputStep = true
719
+ }
720
+
721
+ lintUseArray(
722
+ [useObject.steps],
723
+ stepName,
724
+ stepNames,
725
+ result,
726
+ useStepsLine ?? typedStep.__line.use,
727
+ useStepsColumn ?? typedStep.__column.use,
728
+ )
729
+ } else if (typeof useObject.steps !== 'string') {
730
+ // If 'steps' is not an array or not present, it's an invalid use object structure.
731
+ result.push({
732
+ code: 'missing-use-steps', // Or a more specific error like 'invalid-use-object-structure'
733
+ stepName,
734
+ type: 'error',
735
+ row: typedStep.__line.use, // Point to the start of the use object
736
+ column: typedStep.__column.use,
737
+ })
738
+ }
739
+ }
740
+ } else if (typeof typedStep.use === 'string') {
741
+ if (typedStep.use === ':original') {
742
+ hasInputStep = true
743
+ }
744
+
745
+ // Situation 3: use parameter is a string, for example
746
+ // "use": "import"
747
+ if (stepNames.indexOf(typedStep.use) === -1) {
748
+ result.push({
749
+ code: 'undefined-step',
750
+ stepName,
751
+ wrongStepName: typedStep.use,
752
+ type: 'error',
753
+ row: typedStep.__line.use,
754
+ column: typedStep.__column.use,
755
+ })
756
+ }
757
+ } else {
758
+ // Situation 4: use parameter has some other invalid type
759
+ result.push({
760
+ code: 'wrong-use-type',
761
+ stepName,
762
+ type: 'error',
763
+ row: typedStep.__line.use,
764
+ column: typedStep.__column.use,
765
+ })
766
+ }
767
+
768
+ const referencesOriginalFiles = JSON.stringify(typedStep.use).includes(':original')
769
+ if (referencesOriginalFiles) {
770
+ if (typedStep.robot && isStoreRobot(typedStep.robot)) {
771
+ storesOriginalFiles = true
772
+ } else {
773
+ usesOriginalFiles = true
774
+ }
775
+ }
776
+ }
777
+ }
778
+
779
+ // When the /file/serve robot is used for the UrlProxy, customers should not use a
780
+ // storage robot, so we should not warn them about it.
781
+ if (!hasFileServe) {
782
+ const hasStorageRobot = hasRobot(JSON.stringify(assembly), /\/store$/, true)
783
+
784
+ if (!hasStorageRobot) {
785
+ result.push({
786
+ code: 'no-storage',
787
+ type: 'warning',
788
+ row: assembly.__line?.steps ?? 0,
789
+ column: assembly.__column?.steps ?? 0,
790
+ })
791
+ }
792
+
793
+ if (usesOriginalFiles && !storesOriginalFiles && hasStorageRobot) {
794
+ // Keep only the missing-original-storage warning
795
+ result.push({
796
+ code: 'missing-original-storage',
797
+ type: 'warning',
798
+ row: assembly.__line?.steps ?? 0,
799
+ column: assembly.__column?.steps ?? 0,
800
+ fixId: 'fix-missing-original-storage',
801
+ fixData: {},
802
+ })
803
+ }
804
+ }
805
+
806
+ if (!hasInputStep) {
807
+ result.push({
808
+ code: 'missing-input',
809
+ type: 'error',
810
+ row: assembly.__line?.steps ?? 0,
811
+ column: assembly.__column?.steps ?? 0,
812
+ fixId: 'fix-missing-input',
813
+ fixData: {}, // Add an empty object as fixData
814
+ })
815
+ }
816
+
817
+ // Add schema violations as linting issues, only if we don't have any
818
+ // serious linting issues yet. Otherwise we risk having duplicate
819
+ // issues, for example, for ffmpeg_stack. Both the linter and the schema cover it.
820
+ // @TODO: In the future we should delete Linter issues that are covered by the Schema.
821
+ // It could result in just having only a few Linter issues left.
822
+ const cntErrors = result.filter((r) => r.type === 'error').length
823
+ // const cntWarnings = result.filter((r) => r.type === 'warning').length
824
+
825
+ if (!cntErrors) {
826
+ const parsed = zodParseWithContext(assemblyInstructionsSchema, assembly)
827
+ if (!parsed.success) {
828
+ for (const zodIssue of parsed.errors) {
829
+ // Start with default values at the steps object level
830
+ let row = assembly.__line?.steps ?? 1
831
+ let column = assembly.__column?.steps ?? 1
832
+ const { path } = zodIssue
833
+
834
+ // Find the row and column of this path in the JSON string:
835
+ if (path.length > 0) {
836
+ let current: Record<string, unknown> = assembly
837
+ let metadata: Record<string, unknown> = assembly
838
+
839
+ // Walk the path to find the deepest available line/column info
840
+ for (const segment of path) {
841
+ if (typeof segment === 'string' && current && typeof current === 'object') {
842
+ // Keep track of both the actual value and its metadata
843
+ current = current[segment] as Record<string, unknown>
844
+
845
+ // The metadata contains __line and __column info
846
+ if (metadata && '__line' in metadata && '__column' in metadata) {
847
+ const lines = metadata.__line as Record<string, number>
848
+ const columns = metadata.__column as Record<string, number>
849
+
850
+ if (segment in lines && segment in columns) {
851
+ row = lines[segment]
852
+ column = columns[segment]
853
+ }
854
+ }
855
+
856
+ // Update metadata pointer for next iteration
857
+ metadata = current
858
+ }
859
+ }
860
+ }
861
+
862
+ result.push({
863
+ code: 'schema-violation',
864
+ type: 'error',
865
+ row,
866
+ column,
867
+ message: zodIssue.humanReadable,
868
+ })
869
+ }
870
+ }
871
+ }
872
+
873
+ return result
874
+ }
875
+
876
+ function isInfiniteAssembly(
877
+ template: TemplateWithMetadata,
878
+ ): [
879
+ isInfiniteAssembly: boolean,
880
+ positionalInfo?: { line: number; column: number; stepName: string },
881
+ ] {
882
+ if (!template.steps) return [false]
883
+
884
+ const graph = new Map<string, string[]>()
885
+ for (const [stepName, stepValue] of Object.entries(template.steps)) {
886
+ if (stepName === '__line' || stepName === '__column') continue
887
+
888
+ if (
889
+ typeof stepValue !== 'object' ||
890
+ stepValue === null ||
891
+ !('use' in stepValue) ||
892
+ !stepValue.use
893
+ ) {
894
+ continue
895
+ }
896
+
897
+ const stepUseValue = stepValue.use
898
+
899
+ if (typeof stepUseValue === 'string') {
900
+ graph.set(stepName, [stepUseValue])
901
+ continue
902
+ }
903
+
904
+ if (Array.isArray(stepUseValue)) {
905
+ // Filter out non-string/non-object-with-name items to satisfy .every checks
906
+ const filteredUseArray = stepUseValue.filter(
907
+ (u): u is string | { name: string } =>
908
+ typeof u === 'string' ||
909
+ (typeof u === 'object' && u !== null && 'name' in u && typeof u.name === 'string'),
910
+ )
911
+
912
+ if (filteredUseArray.every((u): u is string => typeof u === 'string')) {
913
+ graph.set(stepName, filteredUseArray)
914
+ continue
915
+ }
916
+ if (
917
+ filteredUseArray.every(
918
+ (u): u is { name: string } => typeof u === 'object' && u !== null && 'name' in u,
919
+ )
920
+ ) {
921
+ graph.set(
922
+ stepName,
923
+ filteredUseArray.map((u) => (u as { name: string }).name),
924
+ )
925
+ continue
926
+ }
927
+ }
928
+
929
+ if (
930
+ typeof stepUseValue === 'object' &&
931
+ stepUseValue !== null &&
932
+ 'steps' in stepUseValue &&
933
+ Array.isArray((stepUseValue as { steps?: unknown }).steps)
934
+ ) {
935
+ const useSteps = (stepUseValue as { steps: (string | { name: string })[] }).steps
936
+ // Similar filtering as above for useSteps elements
937
+ const filteredUseSteps = useSteps.filter(
938
+ (s): s is string | { name: string } =>
939
+ typeof s === 'string' ||
940
+ (typeof s === 'object' && s !== null && 'name' in s && typeof s.name === 'string'),
941
+ )
942
+
943
+ if (filteredUseSteps.every((s): s is string => typeof s === 'string')) {
944
+ graph.set(stepName, filteredUseSteps)
945
+ } else if (
946
+ filteredUseSteps.every(
947
+ (s): s is { name: string } => typeof s === 'object' && s !== null && 'name' in s,
948
+ )
949
+ ) {
950
+ graph.set(
951
+ stepName,
952
+ filteredUseSteps.map((s) => (s as { name: string }).name),
953
+ )
954
+ }
955
+ }
956
+ }
957
+
958
+ const visited = new Set<string>()
959
+ const recursionStack = new Set<string>()
960
+
961
+ function dfs(node: string): boolean {
962
+ if (recursionStack.has(node)) return true // Cycle detected
963
+ if (visited.has(node)) return false // Already visited and no cycle from this node
964
+
965
+ visited.add(node)
966
+ recursionStack.add(node)
967
+
968
+ const neighbors = graph.get(node) || []
969
+ for (const neighbor of neighbors) {
970
+ // One of the pitfalls of our normalization is that an :original step
971
+ // references itself in its own use property after normalization.
972
+ // This is an "accepted" circular dependency.
973
+ if (node === ':original' && neighbor !== ':original') {
974
+ continue
975
+ }
976
+ if (dfs(neighbor)) {
977
+ return true // Cycle detected in recursion
978
+ }
979
+ }
980
+ recursionStack.delete(node)
981
+ return false
982
+ }
983
+
984
+ for (const [stepName, stepValue] of Object.entries(template.steps)) {
985
+ if (stepName === '__line' || stepName === '__column') continue
986
+ if (!graph.has(stepName)) continue
987
+
988
+ if (!visited.has(stepName) && dfs(stepName)) {
989
+ const offendingStep = stepValue as StepWithMetadata // Cast for __line/__column access
990
+ return [
991
+ true,
992
+ {
993
+ stepName,
994
+ line: offendingStep.__line?.use ?? 0, // Assumes 'use' is a key in __line for the property itself
995
+ column: offendingStep.__column?.use ?? 0,
996
+ },
997
+ ] // Circular dependency found
998
+ }
999
+ }
1000
+
1001
+ return [false] // No circular dependencies detected
1002
+ }
1003
+
1004
+ function findDuplicateKeysInAST(
1005
+ node: ValueNode,
1006
+ path = '',
1007
+ annotations: AssemblyLinterResult[] = [],
1008
+ ) {
1009
+ if (node.type === 'Object') {
1010
+ const keysSeen = new Map<string, ValueNode>()
1011
+
1012
+ for (const property of node.children) {
1013
+ const key = property.key.value
1014
+ const keyLocation = property.key.loc
1015
+ const fullPath = path ? `${path}.${key}` : key
1016
+
1017
+ if (keysSeen.has(key) && keyLocation) {
1018
+ const stepName = path.includes('steps.') ? path.split('steps.')[1] : undefined
1019
+ // Duplicate key found
1020
+ annotations.push({
1021
+ code: 'duplicate-key-in-step',
1022
+ type: 'warning',
1023
+ row: keyLocation.start.line - 1,
1024
+ column: keyLocation.start.column - 1,
1025
+ message: `Duplicate key '${key}' found`,
1026
+ stepName,
1027
+ duplicateKeys: [key],
1028
+ fixId: 'fix-duplicate-key-in-step',
1029
+ fixData: {
1030
+ stepName: stepName ?? '',
1031
+ duplicateKeys: [key],
1032
+ },
1033
+ } satisfies AssemblyLinterResult)
1034
+ } else {
1035
+ keysSeen.set(key, property.value)
1036
+ }
1037
+
1038
+ // Recurse into the property value
1039
+ findDuplicateKeysInAST(property.value, fullPath, annotations)
1040
+ }
1041
+ } else if (node.type === 'Array') {
1042
+ for (const item of node.children) {
1043
+ findDuplicateKeysInAST(item, path, annotations)
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Checks if an assembly is a Smart CDN Assembly by looking for the `/file/serve` robot
1050
+ */
1051
+ export function isSmartCdnAssembly(assembly: TemplateWithMetadata): boolean {
1052
+ if (!isObject(assembly) || !isObject(assembly.steps)) {
1053
+ return false
1054
+ }
1055
+
1056
+ for (const [stepName, step] of Object.entries(assembly.steps)) {
1057
+ if (stepName === '__line' || stepName === '__column') continue
1058
+ if (!isObject(step)) continue
1059
+
1060
+ const typedStep = step as StepWithMetadata
1061
+ if (typedStep.robot === '/file/serve') {
1062
+ return true
1063
+ }
1064
+ }
1065
+
1066
+ return false
1067
+ }
1068
+
1069
+ // This function counts the steps in an assembly
1070
+ function countSteps(steps: Record<string, unknown>): number {
1071
+ // Filter out metadata properties
1072
+ return Object.keys(steps).filter((key) => key !== '__line' && key !== '__column').length
1073
+ }
1074
+
1075
+ // Checks if a robot is allowed for Smart CDN
1076
+ function isRobotAllowedForSmartCdn(robotName: string): boolean {
1077
+ if (!robotName || typeof robotName !== 'string') {
1078
+ return false
1079
+ }
1080
+
1081
+ // Convert robotName like /http/import to httpImportMeta
1082
+ const parts = robotName.substring(1).split('/')
1083
+ const keyBase = parts
1084
+ .map((part, index) => {
1085
+ if (index === 0) return part
1086
+ return part.charAt(0).toUpperCase() + part.slice(1)
1087
+ })
1088
+ .join('')
1089
+ const robotMetaKey = `${keyBase}Meta`
1090
+
1091
+ const meta = robotsMeta[robotMetaKey as keyof typeof robotsMeta]
1092
+
1093
+ // Check if this robot exists and is allowed for Smart CDN
1094
+ return meta?.allowed_for_url_transform === true
1095
+ }
1096
+
1097
+ // This function lints Smart CDN Assemblies
1098
+ function lintSmartCdn(assembly: Record<string, unknown>): AssemblyLinterResult[] {
1099
+ const results: AssemblyLinterResult[] = []
1100
+
1101
+ if (!assembly.steps || typeof assembly.steps !== 'object') {
1102
+ return results
1103
+ }
1104
+
1105
+ const steps = assembly.steps as Record<string, unknown> & {
1106
+ __line?: Record<string, number>
1107
+ __column?: Record<string, number>
1108
+ }
1109
+
1110
+ // Check step count against limit
1111
+ const stepCount = countSteps(steps)
1112
+ if (stepCount > MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY) {
1113
+ results.push({
1114
+ code: 'smart-cdn-max-steps-exceeded',
1115
+ type: 'error',
1116
+ row: (assembly.__line as Record<string, number> | undefined)?.steps ?? 0,
1117
+ column: (assembly.__column as Record<string, number> | undefined)?.steps ?? 0,
1118
+ message: `Smart CDN Assemblies are limited to ${MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY} steps, but found ${stepCount} steps`,
1119
+ maxStepCount: MAX_STEPS_PER_URLTRANSFORM_ASSEMBLY,
1120
+ stepCount,
1121
+ })
1122
+ }
1123
+
1124
+ // Check for disallowed robots
1125
+ for (const [stepName, step] of Object.entries(steps)) {
1126
+ if (stepName === '__line' || stepName === '__column' || typeof step !== 'object' || !step) {
1127
+ continue
1128
+ }
1129
+
1130
+ const typedStep = step as { robot?: string } & Record<string, unknown>
1131
+ const robotNameValue = typedStep.robot
1132
+
1133
+ if (robotNameValue && !isRobotAllowedForSmartCdn(robotNameValue)) {
1134
+ const { row, column } = getStepLocation(steps as StepsWithMetadata, stepName)
1135
+ results.push({
1136
+ code: 'smart-cdn-robot-not-allowed',
1137
+ type: 'error',
1138
+ row,
1139
+ column,
1140
+ message: `Robot "${robotNameValue}" is not allowed in Smart CDN Assemblies`,
1141
+ stepName,
1142
+ robot: robotNameValue,
1143
+ })
1144
+ }
1145
+ }
1146
+
1147
+ return results
1148
+ }
1149
+
1150
+ export async function parseAndLint(json: string): Promise<AssemblyLinterResult[]> {
1151
+ let ast: ValueNode
1152
+ try {
1153
+ ast = parse(json, { loc: true })
1154
+ } catch (e) {
1155
+ if (!(e instanceof Error)) {
1156
+ throw e
1157
+ }
1158
+
1159
+ if (e.name !== 'SyntaxError') {
1160
+ throw e
1161
+ }
1162
+
1163
+ if (!isParseError(e)) {
1164
+ throw e
1165
+ }
1166
+
1167
+ return [
1168
+ {
1169
+ code: 'invalid-json',
1170
+ type: 'error',
1171
+ row: e.line - 1,
1172
+ column: e.column - 1,
1173
+ message: e.rawMessage,
1174
+ },
1175
+ ]
1176
+ }
1177
+
1178
+ const obj = getASTValue(ast)
1179
+ const templateMeta = obj as TemplateWithMetadata
1180
+
1181
+ const annotations = lint(templateMeta)
1182
+
1183
+ // Additional checks for Smart CDN assemblies
1184
+ if (isSmartCdnAssembly(templateMeta)) {
1185
+ annotations.push(...lintSmartCdn(templateMeta))
1186
+ }
1187
+
1188
+ findDuplicateKeysInAST(ast, undefined, annotations)
1189
+
1190
+ const [isInfinite, positionalInfo] = isInfiniteAssembly(templateMeta)
1191
+ if (isInfinite && positionalInfo) {
1192
+ annotations.push({
1193
+ code: 'infinite-assembly',
1194
+ type: 'error',
1195
+ row: positionalInfo.line,
1196
+ column: positionalInfo.column,
1197
+ stepName: positionalInfo.stepName,
1198
+ })
1199
+ }
1200
+
1201
+ // Sort the annotations by row numbers descending
1202
+ annotations.sort((a, b) => a.row - b.row)
1203
+
1204
+ return annotations
1205
+ }
1206
+
1207
+ function fixWrongStackVersion(content: string, fixData: FixDataWrongStackVersion): string {
1208
+ // A wrong stack version is a violation of our schema so we cannot use parseSafeTemplate
1209
+ // here.
1210
+ let parsed: unknown
1211
+ let indent = ' '
1212
+ try {
1213
+ parsed = JSON.parse(content)
1214
+ indent = getIndentation(content)
1215
+ } catch (_e) {
1216
+ return content
1217
+ }
1218
+
1219
+ if (!isObject(parsed)) {
1220
+ return content
1221
+ }
1222
+
1223
+ const parsedRecord = parsed as Record<string, unknown>
1224
+ const stepsValue = parsedRecord.steps
1225
+ if (!isObject(stepsValue)) {
1226
+ return content
1227
+ }
1228
+
1229
+ const stepsRecord = stepsValue as Record<string, unknown>
1230
+ const newStepsEntries: [string, unknown][] = []
1231
+
1232
+ for (const [stepName2, step2] of Object.entries(stepsRecord)) {
1233
+ if (typeof step2 !== 'object' || step2 === null) {
1234
+ newStepsEntries.push([stepName2, step2])
1235
+ continue
1236
+ }
1237
+
1238
+ let newStep = { ...step2 }
1239
+ if (fixData.stepName === stepName2) {
1240
+ newStep = { ...step2, [fixData.paramName]: fixData.recommendedVersion }
1241
+ }
1242
+
1243
+ newStepsEntries.push([stepName2, newStep])
1244
+ }
1245
+
1246
+ return JSON.stringify(
1247
+ { ...parsedRecord, steps: Object.fromEntries(newStepsEntries) },
1248
+ null,
1249
+ indent,
1250
+ )
1251
+ }
1252
+
1253
+ function fixMissingUse(content: string, fixData: FixDataMissingUse): string {
1254
+ // A missing use is a violation of our schema so we cannot use parseSafeTemplate
1255
+ // here.
1256
+ let parsed: unknown
1257
+ let indent = ' '
1258
+ try {
1259
+ parsed = JSON.parse(content)
1260
+ indent = getIndentation(content)
1261
+ } catch (_e) {
1262
+ return content
1263
+ }
1264
+
1265
+ if (!isObject(parsed)) {
1266
+ return content
1267
+ }
1268
+
1269
+ const parsedRecord = parsed as Record<string, unknown>
1270
+ const stepsValue = parsedRecord.steps
1271
+ if (!isObject(stepsValue)) {
1272
+ return content
1273
+ }
1274
+
1275
+ const stepsRecord = stepsValue as Record<string, unknown>
1276
+
1277
+ // Get the step that needs fixing
1278
+ const stepValue = stepsRecord[fixData.stepName]
1279
+ if (!isObject(stepValue) || !('robot' in stepValue)) {
1280
+ return content
1281
+ }
1282
+
1283
+ const step = stepValue as StepInput
1284
+
1285
+ // Get the first upload or import step:
1286
+ const firstInputStepName = getFirstStepNameThatDoesNotNeedInput(content)
1287
+ if (!firstInputStepName) {
1288
+ return content
1289
+ }
1290
+
1291
+ // Add the use parameter pointing to :original only if the robot supports it:
1292
+ if (doesStepRobotSupportUse(step)) {
1293
+ step.use = firstInputStepName
1294
+ }
1295
+
1296
+ parsedRecord.steps = stepsRecord
1297
+
1298
+ return JSON.stringify(parsedRecord, null, indent)
1299
+ }
1300
+
1301
+ function fixDuplicateKeyInStep(content: string, _fixData: FixDataDuplicateKeyInStep): string {
1302
+ const [templateError, template, indent] = parseSafeTemplate(content)
1303
+ if (templateError) {
1304
+ // If parsing fails, return the original content
1305
+ return content
1306
+ }
1307
+
1308
+ return JSON.stringify(template, null, indent)
1309
+ }
1310
+
1311
+ function fixMissingSteps(content: string): string {
1312
+ const [templateError, template, indent] = parseSafeTemplate(content)
1313
+ if (templateError) {
1314
+ return JSON.stringify({ steps: {} }, null, ' ')
1315
+ }
1316
+ return JSON.stringify({ ...template, steps: {} }, null, indent)
1317
+ }
1318
+
1319
+ function fixMissingInput(content: string): string {
1320
+ // A missing input is a violation of our schema so we cannot use parseSafeTemplate
1321
+ // here.
1322
+ let parsed: unknown
1323
+ let indent = ' '
1324
+ try {
1325
+ parsed = JSON.parse(content)
1326
+ indent = getIndentation(content)
1327
+ } catch (_e) {
1328
+ return content
1329
+ }
1330
+
1331
+ if (!isObject(parsed)) {
1332
+ return content
1333
+ }
1334
+
1335
+ const parsedRecord = parsed as Record<string, unknown>
1336
+ const stepsValue = parsedRecord.steps
1337
+ if (!isObject(stepsValue)) {
1338
+ return content
1339
+ }
1340
+
1341
+ const stepsRecord = stepsValue as Record<string, unknown>
1342
+
1343
+ // Add the :original step with /upload/handle robot
1344
+ stepsRecord[':original'] = {
1345
+ robot: '/upload/handle',
1346
+ }
1347
+
1348
+ // Update other steps to use :original if they don't have a 'use' property
1349
+ for (const [stepName, step] of Object.entries(stepsRecord)) {
1350
+ if (stepName !== ':original' && isObject(step) && !('use' in step) && 'robot' in step) {
1351
+ // Use addUseReference instead of direct assignment
1352
+ // @ts-expect-error: robot should be good here
1353
+ const updatedStep = addUseReference(step, ':original')
1354
+ stepsRecord[stepName] = updatedStep
1355
+ }
1356
+ }
1357
+
1358
+ parsedRecord.steps = stepsRecord
1359
+
1360
+ return JSON.stringify(parsedRecord, null, indent)
1361
+ }
1362
+
1363
+ function fixInvalidStepsType(content: string): string {
1364
+ let parsed: unknown
1365
+ let indent = ' '
1366
+ try {
1367
+ parsed = JSON.parse(content)
1368
+ indent = getIndentation(content)
1369
+ } catch (_err) {
1370
+ return content
1371
+ }
1372
+
1373
+ if (!isObject(parsed)) {
1374
+ return content
1375
+ }
1376
+
1377
+ const parsedRecord = parsed as Record<string, unknown>
1378
+ if (!isObject(parsedRecord.steps)) {
1379
+ parsedRecord.steps = {}
1380
+ }
1381
+
1382
+ return JSON.stringify(parsedRecord, null, indent)
1383
+ }
1384
+
1385
+ function fixEmptySteps(content: string): string {
1386
+ const [templateError, template, indent] = parseSafeTemplate(content)
1387
+ if (templateError) {
1388
+ return content
1389
+ }
1390
+
1391
+ if (Object.keys(template.steps ?? {}).length === 0) {
1392
+ template.steps = {
1393
+ ':original': {
1394
+ robot: '/upload/handle',
1395
+ },
1396
+ }
1397
+ }
1398
+
1399
+ return JSON.stringify(template, null, indent)
1400
+ }
1401
+
1402
+ function fixMissingOriginalStorage(content: string): string {
1403
+ const [templateError, template, indent] = parseSafeTemplate(content)
1404
+ if (templateError) {
1405
+ return content
1406
+ }
1407
+
1408
+ // Find the storage step
1409
+ for (const [, step] of entries(template.steps)) {
1410
+ if (step.robot.endsWith('/store')) {
1411
+ // Add :original to the use array if it's not already there
1412
+ const updatedStep = addUseReference(step, ':original', { leading: true })
1413
+ Object.assign(step, updatedStep)
1414
+ }
1415
+ }
1416
+
1417
+ return JSON.stringify(template, null, indent)
1418
+ }
1419
+
1420
+ // Add new fix function
1421
+ function fixSmartCdnInputField(content: string, fixData: FixDataSmartCdnInputField): string {
1422
+ const [templateError, template, indent] = parseSafeTemplate(content)
1423
+ if (templateError) {
1424
+ return content
1425
+ }
1426
+
1427
+ const step = template.steps?.[fixData.stepName]
1428
+
1429
+ if (!step || step.robot !== '/http/import') {
1430
+ return content
1431
+ }
1432
+
1433
+ // Type assertion since we know this is an http-import step
1434
+ const httpImportStep = step as InterpolatableRobotHttpImportInstructionsWithHiddenFieldsInput
1435
+
1436
+ // Only modify the url field in the specified step
1437
+ httpImportStep.url = 'https://demos.transloadit.com/${fields.input}'
1438
+
1439
+ // Stringify back with the same indentation
1440
+ return JSON.stringify(template, null, indent)
1441
+ }
1442
+
1443
+ export function applyFix<T extends FixData['fixId']>(
1444
+ content: string,
1445
+ fixId: T,
1446
+ fixData?: Extract<FixData, { fixId: T }>['fixData'],
1447
+ ): string {
1448
+ switch (fixId) {
1449
+ case 'fix-wrong-stack-version':
1450
+ return fixWrongStackVersion(content, fixWrongStackVersionSchema.parse(fixData))
1451
+ case 'fix-missing-use':
1452
+ return fixMissingUse(content, fixMissingUseSchema.parse(fixData))
1453
+ case 'fix-duplicate-key-in-step':
1454
+ return fixDuplicateKeyInStep(content, fixDuplicateKeyInStepSchema.parse(fixData))
1455
+ case 'fix-missing-input':
1456
+ fixMissingInputSchema.parse(fixData)
1457
+ return fixMissingInput(content)
1458
+ case 'fix-missing-steps':
1459
+ fixMissingStepsSchema.parse(fixData)
1460
+ return fixMissingSteps(content)
1461
+ case 'fix-invalid-steps-type':
1462
+ fixInvalidStepsTypeSchema.parse(fixData)
1463
+ return fixInvalidStepsType(content)
1464
+ case 'fix-empty-steps':
1465
+ fixEmptyStepsSchema.parse(fixData)
1466
+ return fixEmptySteps(content)
1467
+ case 'fix-missing-original-storage':
1468
+ fixMissingOriginalStorageSchema.parse(fixData)
1469
+ return fixMissingOriginalStorage(content)
1470
+ case 'fix-smart-cdn-input-field':
1471
+ return fixSmartCdnInputField(content, fixSmartCdnInputFieldSchema.parse(fixData))
1472
+ default:
1473
+ throw new Error(`Unknown fixId: ${fixId satisfies never}`)
1474
+ }
1475
+ }