moonflower 1.4.3 → 1.4.5

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.
@@ -321,4 +321,275 @@ describe('useQueryParams', () => {
321
321
  expectTypeOf(params.numberParam).toEqualTypeOf<number | undefined>()
322
322
  })
323
323
  })
324
+
325
+ describe('legacy array validators', () => {
326
+ it('parses JSON string array with legacy validator', () => {
327
+ const ctx = mockContextQuery(mockContext(), {
328
+ nodes: JSON.stringify(['node1', 'node2']),
329
+ })
330
+
331
+ const params = useQueryParams(ctx, {
332
+ nodes: RequiredParam<string[]>({
333
+ parse: (v) => JSON.parse(String(v)),
334
+ }),
335
+ })
336
+
337
+ expect(params.nodes).toEqual(['node1', 'node2'])
338
+ })
339
+
340
+ it('parses comma-separated array with legacy validator', () => {
341
+ const ctx = mockContextQuery(mockContext(), {
342
+ nodes: 'node1,node2,node3',
343
+ })
344
+
345
+ const params = useQueryParams(ctx, {
346
+ nodes: RequiredParam<string[]>({
347
+ parse: (v) => String(v).split(','),
348
+ }),
349
+ })
350
+
351
+ expect(params.nodes).toEqual(['node1', 'node2', 'node3'])
352
+ })
353
+
354
+ it('fails validation on invalid legacy array param', () => {
355
+ const test = () => {
356
+ const ctx = mockContextQuery(mockContext(), {
357
+ nodes: 'not valid json array',
358
+ })
359
+
360
+ useQueryParams(ctx, {
361
+ nodes: RequiredParam<string[]>({
362
+ parse: (v) => JSON.parse(String(v)),
363
+ }),
364
+ })
365
+ }
366
+
367
+ expect(test).toThrow(ValidationError)
368
+ expect(test).toThrow("Failed query param validation: 'nodes'")
369
+ })
370
+
371
+ it('allows missing optional legacy array param', () => {
372
+ const ctx = mockContextQuery(mockContext(), {})
373
+
374
+ const params = useQueryParams(ctx, {
375
+ nodes: OptionalParam<string[]>({
376
+ parse: (v) => JSON.parse(String(v)),
377
+ }),
378
+ })
379
+
380
+ expect(params.nodes).toEqual(undefined)
381
+ })
382
+ })
383
+
384
+ describe('zod array validators', () => {
385
+ it('parses JSON string array', () => {
386
+ const ctx = mockContextQuery(mockContext(), {
387
+ nodes: JSON.stringify(['node1', 'node2']),
388
+ })
389
+
390
+ const params = useQueryParams(ctx, {
391
+ nodes: z.array(z.string()),
392
+ })
393
+
394
+ expect(params.nodes).toEqual(['node1', 'node2'])
395
+ })
396
+
397
+ it('parses JSON number array', () => {
398
+ const ctx = mockContextQuery(mockContext(), {
399
+ ids: JSON.stringify([1, 2, 3]),
400
+ })
401
+
402
+ const params = useQueryParams(ctx, {
403
+ ids: z.array(z.number()),
404
+ })
405
+
406
+ expect(params.ids).toEqual([1, 2, 3])
407
+ })
408
+
409
+ it('parses JSON object array', () => {
410
+ const ctx = mockContextQuery(mockContext(), {
411
+ items: JSON.stringify([
412
+ { name: 'first', value: 1 },
413
+ { name: 'second', value: 2 },
414
+ ]),
415
+ })
416
+
417
+ const params = useQueryParams(ctx, {
418
+ items: z.array(z.object({ name: z.string(), value: z.number() })),
419
+ })
420
+
421
+ expect(params.items).toEqual([
422
+ { name: 'first', value: 1 },
423
+ { name: 'second', value: 2 },
424
+ ])
425
+ })
426
+
427
+ it('parses comma-separated string array', () => {
428
+ const ctx = mockContextQuery(mockContext(), {
429
+ nodes: 'node1,node2,node3',
430
+ })
431
+
432
+ const params = useQueryParams(ctx, {
433
+ nodes: z.array(z.string()),
434
+ })
435
+
436
+ expect(params.nodes).toEqual(['node1', 'node2', 'node3'])
437
+ })
438
+
439
+ it('parses comma-separated number array', () => {
440
+ const ctx = mockContextQuery(mockContext(), {
441
+ ids: '1,2,3',
442
+ })
443
+
444
+ const params = useQueryParams(ctx, {
445
+ ids: z.array(z.number()),
446
+ })
447
+
448
+ expect(params.ids).toEqual([1, 2, 3])
449
+ })
450
+
451
+ it('parses comma-separated boolean array', () => {
452
+ const ctx = mockContextQuery(mockContext(), {
453
+ flags: 'true,false,true',
454
+ })
455
+
456
+ const params = useQueryParams(ctx, {
457
+ flags: z.array(z.boolean()),
458
+ })
459
+
460
+ expect(params.flags).toEqual([true, false, true])
461
+ })
462
+
463
+ it('parses comma-separated values with whitespace', () => {
464
+ const ctx = mockContextQuery(mockContext(), {
465
+ nodes: 'node1, node2 , node3',
466
+ })
467
+
468
+ const params = useQueryParams(ctx, {
469
+ nodes: z.array(z.string()),
470
+ })
471
+
472
+ expect(params.nodes).toEqual(['node1', 'node2', 'node3'])
473
+ })
474
+
475
+ it('parses single value as string array', () => {
476
+ const ctx = mockContextQuery(mockContext(), {
477
+ nodes: 'single',
478
+ })
479
+
480
+ const params = useQueryParams(ctx, {
481
+ nodes: z.array(z.string()),
482
+ })
483
+
484
+ expect(params.nodes).toEqual(['single'])
485
+ })
486
+
487
+ it('parses single value as number array', () => {
488
+ const ctx = mockContextQuery(mockContext(), {
489
+ ids: '42',
490
+ })
491
+
492
+ const params = useQueryParams(ctx, {
493
+ ids: z.array(z.number()),
494
+ })
495
+
496
+ expect(params.ids).toEqual([42])
497
+ })
498
+
499
+ it('fails validation on invalid array element', () => {
500
+ const test = () => {
501
+ const ctx = mockContextQuery(mockContext(), {
502
+ ids: 'abc,def',
503
+ })
504
+
505
+ useQueryParams(ctx, {
506
+ ids: z.array(z.number()),
507
+ })
508
+ }
509
+
510
+ expect(test).toThrow(ValidationError)
511
+ expect(test).toThrow("Failed query param validation: 'ids'")
512
+ })
513
+
514
+ it('fails when required array is missing', () => {
515
+ const test = () => {
516
+ const ctx = mockContextQuery(mockContext(), {})
517
+
518
+ useQueryParams(ctx, {
519
+ nodes: z.array(z.string()),
520
+ })
521
+ }
522
+
523
+ expect(test).toThrow(ValidationError)
524
+ expect(test).toThrow("Missing query params: 'nodes'")
525
+ })
526
+
527
+ it('parses optional array when missing', () => {
528
+ const ctx = mockContextQuery(mockContext(), {})
529
+
530
+ const params = useQueryParams(ctx, {
531
+ nodes: z.array(z.string()).optional(),
532
+ })
533
+
534
+ expect(params.nodes).toEqual(undefined)
535
+ })
536
+
537
+ it('parses optional array when present', () => {
538
+ const ctx = mockContextQuery(mockContext(), {
539
+ nodes: 'node1,node2',
540
+ })
541
+
542
+ const params = useQueryParams(ctx, {
543
+ nodes: z.array(z.string()).optional(),
544
+ })
545
+
546
+ expect(params.nodes).toEqual(['node1', 'node2'])
547
+ })
548
+
549
+ it('infers return type of string array', () => {
550
+ const ctx = mockContextQuery(mockContext(), {
551
+ nodes: 'a,b',
552
+ })
553
+
554
+ const params = useQueryParams(ctx, {
555
+ nodes: z.array(z.string()),
556
+ })
557
+
558
+ expectTypeOf(params.nodes).toEqualTypeOf<string[]>()
559
+ })
560
+
561
+ it('infers return type of number array', () => {
562
+ const ctx = mockContextQuery(mockContext(), {
563
+ ids: '1,2',
564
+ })
565
+
566
+ const params = useQueryParams(ctx, {
567
+ ids: z.array(z.number()),
568
+ })
569
+
570
+ expectTypeOf(params.ids).toEqualTypeOf<number[]>()
571
+ })
572
+
573
+ it('infers return type of object array', () => {
574
+ const ctx = mockContextQuery(mockContext(), {
575
+ items: JSON.stringify([{ name: 'a' }]),
576
+ })
577
+
578
+ const params = useQueryParams(ctx, {
579
+ items: z.array(z.object({ name: z.string() })),
580
+ })
581
+
582
+ expectTypeOf(params.items).toEqualTypeOf<{ name: string }[]>()
583
+ })
584
+
585
+ it('infers return type of optional array', () => {
586
+ const ctx = mockContextQuery(mockContext(), {})
587
+
588
+ const params = useQueryParams(ctx, {
589
+ nodes: z.array(z.string()).optional(),
590
+ })
591
+
592
+ expectTypeOf(params.nodes).toEqualTypeOf<string[] | undefined>()
593
+ })
594
+ })
324
595
  })
@@ -308,19 +308,31 @@ const getZodCallShape = (node: Node): ShapeOfType['shape'] => {
308
308
 
309
309
  if (typeName === 'ZodArray') {
310
310
  const argNode = callExpression.getFirstChildByKind(SyntaxKind.SyntaxList)?.getFirstChild()
311
- if (!argNode) {
312
- return 'unknown_zod_array'
311
+ if (argNode) {
312
+ const elementShape = isZodCallExpression(argNode)
313
+ ? getZodCallShape(argNode)
314
+ : getValidatorPropertyShape(argNode)
315
+ return [
316
+ {
317
+ role: 'array' as const,
318
+ shape: elementShape,
319
+ optional: false,
320
+ },
321
+ ]
313
322
  }
314
- const elementShape = isZodCallExpression(argNode)
315
- ? getZodCallShape(argNode)
316
- : getValidatorPropertyShape(argNode)
317
- return [
318
- {
319
- role: 'array' as const,
320
- shape: elementShape,
321
- optional: false,
322
- },
323
- ]
323
+ // Handle chained form: z.string().array()
324
+ const propertyAccess = callExpression.getFirstChildByKind(SyntaxKind.PropertyAccessExpression)
325
+ const receiverCall = propertyAccess?.getFirstChildByKind(SyntaxKind.CallExpression)
326
+ if (receiverCall && isZodCallExpression(receiverCall)) {
327
+ return [
328
+ {
329
+ role: 'array' as const,
330
+ shape: getZodCallShape(receiverCall),
331
+ optional: false,
332
+ },
333
+ ]
334
+ }
335
+ return 'unknown_zod_array'
324
336
  }
325
337
 
326
338
  if (typeName === 'ZodEnum') {
@@ -7,6 +7,10 @@ export const TestCase = {
7
7
  parsesInlineZodEnum: 'parses-inline-zod-enum',
8
8
  parsesZodOptional: 'parses-zod-optional',
9
9
  parsesAliasedZodSchema: 'parses-aliased-zod-schema',
10
+ parsesZodQueryStringArray: 'parses-zod-query-string-array',
11
+ parsesZodQueryNumberArray: 'parses-zod-query-number-array',
12
+ parsesZodQueryObjectArray: 'parses-zod-query-object-array',
13
+ parsesZodQueryOptionalArray: 'parses-zod-query-optional-array',
10
14
  parsesReturnRecordStringUnknown: 'parses-return-record-string-unknown',
11
15
  parsesReturnObjectWithRecordProperty: 'parses-return-object-with-record-property',
12
16
  parsesBufferReturnedFromFunction: 'parses-buffer-returned-from-function',
@@ -3,6 +3,7 @@ import { z } from 'zod'
3
3
  import { z as valibot } from 'zod'
4
4
 
5
5
  import { usePathParams } from '../../../hooks/usePathParams'
6
+ import { useQueryParams } from '../../../hooks/useQueryParams'
6
7
  import { useRequestBody } from '../../../hooks/useRequestBody'
7
8
  import { Router } from '../../../router/Router'
8
9
  import { OptionalParam } from '../../../validators/ParamWrappers'
@@ -116,3 +117,33 @@ router.post(`/test/${TestCase.parsesZodOptional}`, (ctx) => {
116
117
  optionalNumber: z.number().optional(),
117
118
  })
118
119
  })
120
+
121
+ router.get(`/test/${TestCase.parsesZodQueryStringArray}`, (ctx) => {
122
+ useQueryParams(ctx, {
123
+ tags: z.array(z.string()),
124
+ otherTags: z.string().array(),
125
+ })
126
+ })
127
+
128
+ router.get(`/test/${TestCase.parsesZodQueryNumberArray}`, (ctx) => {
129
+ useQueryParams(ctx, {
130
+ ids: z.array(z.number()),
131
+ })
132
+ })
133
+
134
+ router.get(`/test/${TestCase.parsesZodQueryObjectArray}`, (ctx) => {
135
+ useQueryParams(ctx, {
136
+ items: z.array(
137
+ z.object({
138
+ name: z.string(),
139
+ value: z.number(),
140
+ }),
141
+ ),
142
+ })
143
+ })
144
+
145
+ router.get(`/test/${TestCase.parsesZodQueryOptionalArray}`, (ctx) => {
146
+ useQueryParams(ctx, {
147
+ tags: z.array(z.string()).optional(),
148
+ })
149
+ })
@@ -237,6 +237,84 @@ describe('OpenApi Analyzer (Zod Validator)', () => {
237
237
  ])
238
238
  expect(endpoint.objectBody[0].optional).toEqual(false)
239
239
  })
240
+
241
+ it('parses zod query string array validators', () => {
242
+ const endpoint = analyzeEndpointById(TestCase.parsesZodQueryStringArray)
243
+
244
+ expect(endpoint.requestQuery[0].identifier).toEqual('tags')
245
+ expect(endpoint.requestQuery[0].signature).toEqual([
246
+ {
247
+ role: 'array',
248
+ shape: 'string',
249
+ optional: false,
250
+ },
251
+ ])
252
+ expect(endpoint.requestQuery[0].optional).toEqual(false)
253
+ expect(endpoint.requestQuery[1].identifier).toEqual('otherTags')
254
+ expect(endpoint.requestQuery[1].signature).toEqual([
255
+ {
256
+ role: 'array',
257
+ shape: 'string',
258
+ optional: false,
259
+ },
260
+ ])
261
+ expect(endpoint.requestQuery[1].optional).toEqual(false)
262
+ })
263
+
264
+ it('parses zod query number array validators', () => {
265
+ const endpoint = analyzeEndpointById(TestCase.parsesZodQueryNumberArray)
266
+
267
+ expect(endpoint.requestQuery[0].identifier).toEqual('ids')
268
+ expect(endpoint.requestQuery[0].signature).toEqual([
269
+ {
270
+ role: 'array',
271
+ shape: 'number',
272
+ optional: false,
273
+ },
274
+ ])
275
+ expect(endpoint.requestQuery[0].optional).toEqual(false)
276
+ })
277
+
278
+ it('parses zod query object array validators', () => {
279
+ const endpoint = analyzeEndpointById(TestCase.parsesZodQueryObjectArray)
280
+
281
+ expect(endpoint.requestQuery[0].identifier).toEqual('items')
282
+ expect(endpoint.requestQuery[0].signature).toEqual([
283
+ {
284
+ role: 'array',
285
+ shape: [
286
+ {
287
+ identifier: 'name',
288
+ optional: false,
289
+ role: 'property',
290
+ shape: 'string',
291
+ },
292
+ {
293
+ identifier: 'value',
294
+ optional: false,
295
+ role: 'property',
296
+ shape: 'number',
297
+ },
298
+ ],
299
+ optional: false,
300
+ },
301
+ ])
302
+ expect(endpoint.requestQuery[0].optional).toEqual(false)
303
+ })
304
+
305
+ it('parses zod query optional array validators', () => {
306
+ const endpoint = analyzeEndpointById(TestCase.parsesZodQueryOptionalArray)
307
+
308
+ expect(endpoint.requestQuery[0].identifier).toEqual('tags')
309
+ expect(endpoint.requestQuery[0].signature).toEqual([
310
+ {
311
+ role: 'array',
312
+ shape: 'string',
313
+ optional: false,
314
+ },
315
+ ])
316
+ expect(endpoint.requestQuery[0].optional).toEqual(true)
317
+ })
240
318
  })
241
319
  })
242
320
  })
@@ -36,13 +36,29 @@ function runPrevalidator(validator: ValidatorUnion, value: string | number | boo
36
36
  return true
37
37
  }
38
38
 
39
+ function isZodArrayValidator(validator: z.ZodType): boolean {
40
+ if (validator instanceof z.ZodArray) return true
41
+ if (validator instanceof z.ZodOptional) return validator.unwrap() instanceof z.ZodArray
42
+ return false
43
+ }
44
+
39
45
  function runParser(validator: ValidatorUnion, value: string | number | boolean | object | null) {
40
46
  // Zod validator
41
47
  if (validator instanceof z.ZodType) {
48
+ const isArrayValidator = isZodArrayValidator(validator)
42
49
  const coercedValue = (() => {
50
+ if (typeof value !== 'string') return value
51
+
43
52
  try {
44
- return typeof value === 'string' ? JSON.parse(value) : value
53
+ const parsed = JSON.parse(value)
54
+ if (isArrayValidator && !Array.isArray(parsed)) {
55
+ return coerceCommaSeparatedToArray(value)
56
+ }
57
+ return parsed
45
58
  } catch {
59
+ if (isArrayValidator) {
60
+ return coerceCommaSeparatedToArray(value)
61
+ }
46
62
  return value
47
63
  }
48
64
  })()
@@ -52,6 +68,17 @@ function runParser(validator: ValidatorUnion, value: string | number | boolean |
52
68
  return validator.parse(getValueAsNullableString(value))
53
69
  }
54
70
 
71
+ function coerceCommaSeparatedToArray(value: string): unknown[] {
72
+ return value.split(',').map((element) => {
73
+ const trimmed = element.trim()
74
+ try {
75
+ return JSON.parse(trimmed)
76
+ } catch {
77
+ return trimmed
78
+ }
79
+ })
80
+ }
81
+
55
82
  function runValidator(validator: ValidatorUnion, parsedValue: unknown) {
56
83
  // Legacy validator
57
84
  if ('validate' in validator && typeof validator.validate === 'function') {