diff --git a/src/routes/application-starter.index.tsx b/src/routes/application-starter.index.tsx index a5eb6619f..63d6ee7e2 100644 --- a/src/routes/application-starter.index.tsx +++ b/src/routes/application-starter.index.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { ClientOnly, createFileRoute } from '@tanstack/react-router' -import { z } from 'zod' +import * as v from 'valibot' import { seo } from '~/utils/seo' const LazyApplicationStarterPage = React.lazy(() => @@ -12,21 +12,26 @@ const LazyApplicationStarterPage = React.lazy(() => ) // Search params schema for shareable URLs -const applicationStarterSearchSchema = z - .object({ - name: z.string().optional(), - framework: z.string().optional(), - features: z.string().optional(), // comma-separated feature IDs - pm: z.enum(['pnpm', 'npm', 'yarn', 'bun']).optional(), - tailwind: z.enum(['false']).optional(), - tab: z.enum(['summary', 'code', 'preview']).optional(), - file: z.string().optional(), // selected file in files tab - addon: z.string().optional(), // selected addon in addons tab - addonFile: z.string().optional(), // selected file in addon view - template: z.string().optional(), +const applicationStarterSearchSchema = v.pipe( + v.unknown(), + v.check( + (input) => !Array.isArray(input), + 'Expected a search parameters object', + ), + v.looseObject({ + name: v.optional(v.string()), + framework: v.optional(v.string()), + features: v.optional(v.string()), // comma-separated feature IDs + pm: v.optional(v.picklist(['pnpm', 'npm', 'yarn', 'bun'])), + tailwind: v.optional(v.literal('false')), + tab: v.optional(v.picklist(['summary', 'code', 'preview'])), + file: v.optional(v.string()), // selected file in files tab + addon: v.optional(v.string()), // selected addon in addons tab + addonFile: v.optional(v.string()), // selected file in addon view + template: v.optional(v.string()), // Feature options as key.value params handled dynamically - }) - .passthrough() + }), +) export const Route = createFileRoute('/application-starter/')({ ssr: false, diff --git a/tests/application-starter-search.test.ts b/tests/application-starter-search.test.ts new file mode 100644 index 000000000..2b293fbe4 --- /dev/null +++ b/tests/application-starter-search.test.ts @@ -0,0 +1,63 @@ +/// + +import assert from 'node:assert/strict' +import test from 'node:test' +import { Route } from '../src/routes/application-starter.index' + +const schema = Route.options.validateSearch +assert.ok(schema && typeof schema === 'object' && '~standard' in schema) +const standardSchema = schema['~standard'] + +test('application starter preserves optional fields and custom feature options', async () => { + for (const input of [ + {}, + { name: undefined }, + { + name: '', + framework: 'react', + features: 'router,query', + pm: 'pnpm', + tailwind: 'false', + tab: 'preview', + file: 'src/main.tsx', + addon: 'query', + addonFile: 'package.json', + template: 'custom', + 'query.mode': 'offline', + custom: { nested: [1, false, null] }, + }, + ...['pnpm', 'npm', 'yarn', 'bun'].map((pm) => ({ pm })), + ...['summary', 'code', 'preview'].map((tab) => ({ tab })), + ]) { + const result = await standardSchema.validate(input) + assert.equal(result.issues, undefined) + assert.deepEqual(result.value, input) + } +}) + +test('application starter rejects invalid known fields instead of coercing them', async () => { + for (const input of [ + null, + [], + 'name=app', + { pm: 'deno' }, + { tailwind: true }, + { tailwind: 'true' }, + { tab: 'unknown' }, + ...[ + 'name', + 'framework', + 'features', + 'pm', + 'tailwind', + 'tab', + 'file', + 'addon', + 'addonFile', + 'template', + ].flatMap((key) => [null, 0, false, []].map((value) => ({ [key]: value }))), + ]) { + const result = await standardSchema.validate(input) + assert.ok(result.issues?.length, JSON.stringify(input)) + } +})