diff --git a/.changeset/early-trees-go.md b/.changeset/early-trees-go.md new file mode 100644 index 000000000..9001a55d1 --- /dev/null +++ b/.changeset/early-trees-go.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +chore(core): change `defineAddonOptions({ /*config */ })` to `defineAddonOptions().add('key', { /*config */ }).build()` in order to provide better type safety. diff --git a/community-addon-template/src/index.js b/community-addon-template/src/index.js index 1190d7e39..61dc312ea 100644 --- a/community-addon-template/src/index.js +++ b/community-addon-template/src/index.js @@ -2,13 +2,13 @@ import { defineAddon, defineAddonOptions } from '@sveltejs/cli-core'; import { imports } from '@sveltejs/cli-core/js'; import { parseSvelte } from '@sveltejs/cli-core/parsers'; -export const options = defineAddonOptions({ - demo: { +export const options = defineAddonOptions() + .add('demo', { question: 'Do you want to use a demo?', type: 'boolean', default: false - } -}); + }) + .build(); export default defineAddon({ id: 'community-addon', diff --git a/packages/addons/drizzle/index.ts b/packages/addons/drizzle/index.ts index f5fb27f18..0f3b8344a 100644 --- a/packages/addons/drizzle/index.ts +++ b/packages/addons/drizzle/index.ts @@ -6,46 +6,47 @@ import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; import { resolveCommand } from 'package-manager-detector/commands'; import { getNodeTypesVersion } from '../common.ts'; -const PORTS = { +type Database = 'mysql' | 'postgresql' | 'sqlite'; +const PORTS: Record = { mysql: '3306', postgresql: '5432', sqlite: '' -} as const; +}; -const options = defineAddonOptions({ - database: { +const options = defineAddonOptions() + .add('database', { question: 'Which database would you like to use?', type: 'select', - default: 'sqlite', + default: 'sqlite' as Database, options: [ { value: 'postgresql', label: 'PostgreSQL' }, { value: 'mysql', label: 'MySQL' }, { value: 'sqlite', label: 'SQLite' } ] - }, - postgresql: { + }) + .add('postgresql', { question: 'Which PostgreSQL client would you like to use?', type: 'select', group: 'client', - default: 'postgres.js', + default: 'postgres.js' as 'postgres.js' | 'neon', options: [ { value: 'postgres.js', label: 'Postgres.JS', hint: 'recommended for most users' }, { value: 'neon', label: 'Neon', hint: 'popular hosted platform' } ], condition: ({ database }) => database === 'postgresql' - }, - mysql: { + }) + .add('mysql', { question: 'Which MySQL client would you like to use?', type: 'select', group: 'client', - default: 'mysql2', + default: 'mysql2' as 'mysql2' | 'planetscale', options: [ { value: 'mysql2', hint: 'recommended for most users' }, { value: 'planetscale', label: 'PlanetScale', hint: 'popular hosted platform' } ], condition: ({ database }) => database === 'mysql' - }, - sqlite: { + }) + .add('sqlite', { question: 'Which SQLite client would you like to use?', type: 'select', group: 'client', @@ -56,16 +57,16 @@ const options = defineAddonOptions({ { value: 'turso', label: 'Turso', hint: 'popular hosted platform' } ], condition: ({ database }) => database === 'sqlite' - }, - docker: { + }) + .add('docker', { question: 'Do you want to run the database locally with docker-compose?', default: false, type: 'boolean', condition: ({ database, mysql, postgresql }) => (database === 'mysql' && mysql === 'mysql2') || (database === 'postgresql' && postgresql === 'postgres.js') - } -}); + }) + .build(); export default defineAddon({ id: 'drizzle', diff --git a/packages/addons/lucia/index.ts b/packages/addons/lucia/index.ts index 739564ecb..491226dbc 100644 --- a/packages/addons/lucia/index.ts +++ b/packages/addons/lucia/index.ts @@ -26,13 +26,13 @@ type Dialect = 'mysql' | 'postgresql' | 'sqlite' | 'turso'; let drizzleDialect: Dialect; let schemaPath: string; -const options = defineAddonOptions({ - demo: { +const options = defineAddonOptions() + .add('demo', { type: 'boolean', default: true, question: `Do you want to include a demo? ${colors.dim('(includes a login/register page)')}` - } -}); + }) + .build(); export default defineAddon({ id: 'lucia', diff --git a/packages/addons/paraglide/index.ts b/packages/addons/paraglide/index.ts index 0809a0a36..0679d0d4e 100644 --- a/packages/addons/paraglide/index.ts +++ b/packages/addons/paraglide/index.ts @@ -16,8 +16,8 @@ const DEFAULT_INLANG_PROJECT = { } }; -const options = defineAddonOptions({ - languageTags: { +const options = defineAddonOptions() + .add('languageTags', { question: `Which languages would you like to support? ${colors.gray('(e.g. en,de-ch)')}`, type: 'string', default: 'en, es', @@ -39,13 +39,13 @@ const options = defineAddonOptions({ return undefined; } - }, - demo: { + }) + .add('demo', { type: 'boolean', default: true, question: 'Do you want to include a demo?' - } -}); + }) + .build(); export default defineAddon({ id: 'paraglide', diff --git a/packages/addons/sveltekit-adapter/index.ts b/packages/addons/sveltekit-adapter/index.ts index dab76511b..66f40d053 100644 --- a/packages/addons/sveltekit-adapter/index.ts +++ b/packages/addons/sveltekit-adapter/index.ts @@ -17,14 +17,14 @@ const adapters: Adapter[] = [ { id: 'netlify', package: '@sveltejs/adapter-netlify', version: '^5.0.0' } ]; -const options = defineAddonOptions({ - adapter: { +const options = defineAddonOptions() + .add('adapter', { type: 'select', question: 'Which SvelteKit adapter would you like to use?', options: adapters.map((p) => ({ value: p.id, label: p.id, hint: p.package })), default: 'auto' - } -}); + }) + .build(); export default defineAddon({ id: 'sveltekit-adapter', diff --git a/packages/addons/tailwindcss/index.ts b/packages/addons/tailwindcss/index.ts index 614a15b65..b0d17eeb1 100644 --- a/packages/addons/tailwindcss/index.ts +++ b/packages/addons/tailwindcss/index.ts @@ -3,37 +3,30 @@ import { imports, vite } from '@sveltejs/cli-core/js'; import { parseCss, parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import { addSlot } from '@sveltejs/cli-core/html'; -type Plugin = { - id: string; - package: string; - version: string; - identifier: string; -}; - -const plugins: Plugin[] = [ - { - id: 'typography', +function typedEntries>(obj: T): Array<[keyof T, T[keyof T]]> { + return Object.entries(obj) as Array<[keyof T, T[keyof T]]>; +} + +const plugins = { + typography: { package: '@tailwindcss/typography', - version: '^0.5.15', - identifier: 'typography' + version: '^0.5.15' }, - { - id: 'forms', + forms: { package: '@tailwindcss/forms', - version: '^0.5.9', - identifier: 'forms' + version: '^0.5.9' } -]; +} as const; -const options = defineAddonOptions({ - plugins: { +const options = defineAddonOptions() + .add('plugins', { type: 'multiselect', question: 'Which plugins would you like to add?', - options: plugins.map((p) => ({ value: p.id, label: p.id, hint: p.package })), - default: [], + options: typedEntries(plugins).map(([id, p]) => ({ value: id, label: id, hint: p.package })), + default: [] as Array, required: false - } -}); + }) + .build(); export default defineAddon({ id: 'tailwindcss', @@ -49,8 +42,8 @@ export default defineAddon({ if (prettierInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.6.11'); - for (const plugin of plugins) { - if (!options.plugins.includes(plugin.id)) continue; + for (const [id, plugin] of typedEntries(plugins)) { + if (!options.plugins.includes(id)) continue; sv.devDependency(plugin.package, plugin.version); } @@ -88,8 +81,8 @@ export default defineAddon({ const lastAtRule = atRules.findLast((rule) => ['plugin', 'import'].includes(rule.name)); const pluginPos = lastAtRule!.source!.end!.offset; - for (const plugin of plugins) { - if (!options.plugins.includes(plugin.id)) continue; + for (const [id, plugin] of typedEntries(plugins)) { + if (!options.plugins.includes(id)) continue; const pluginRule = findAtRule('plugin', plugin.package); if (!pluginRule) { diff --git a/packages/addons/vitest-addon/index.ts b/packages/addons/vitest-addon/index.ts index 895d4e051..07180a677 100644 --- a/packages/addons/vitest-addon/index.ts +++ b/packages/addons/vitest-addon/index.ts @@ -2,8 +2,8 @@ import { dedent, defineAddon, defineAddonOptions, log } from '@sveltejs/cli-core import { array, exports, functions, object } from '@sveltejs/cli-core/js'; import { parseJson, parseScript } from '@sveltejs/cli-core/parsers'; -const options = defineAddonOptions({ - usages: { +const options = defineAddonOptions() + .add('usages', { question: 'What do you want to use vitest for?', type: 'multiselect', default: ['unit', 'component'], @@ -12,8 +12,8 @@ const options = defineAddonOptions({ { value: 'component', label: 'component testing' } ], required: true - } -}); + }) + .build(); export default defineAddon({ id: 'vitest', diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index e92e6d666..f7449f588 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -637,7 +637,7 @@ function getOptionChoices(details: AddonWithoutExplicitArgs) { const options: Record = {}; for (const [id, question] of Object.entries(details.options)) { let values: string[] = []; - const applyDefault = question.condition?.(options) !== false; + const applyDefault = question.condition?.(options as any) !== false; if (question.type === 'boolean') { values = ['yes', `no`]; if (applyDefault) { diff --git a/packages/core/addon/config.ts b/packages/core/addon/config.ts index 54bb03a9d..58a55ed20 100644 --- a/packages/core/addon/config.ts +++ b/packages/core/addon/config.ts @@ -78,13 +78,37 @@ export type TestDefinition = { condition?: (options: OptionValues) => boolean; }; -export function defineAddonOptions(options: Args): Args { - return options; -} - type MaybePromise = Promise | T; export type Verification = { name: string; run: () => MaybePromise<{ success: boolean; message: string | undefined }>; }; + +// Builder pattern for addon options +export type OptionBuilder> = { + add>>( + key: K, + question: Q + ): OptionBuilder>; + build(): T; +}; + +export function defineAddonOptions(): OptionBuilder> { + return createOptionBuilder({} as Record); +} + +function createOptionBuilder(options: T = {} as T): OptionBuilder { + return { + add>>( + key: K, + question: Q + ): OptionBuilder> { + const newOptions = { ...options, [key]: question } as T & Record; + return createOptionBuilder(newOptions); + }, + build(): T { + return options; + } + }; +} diff --git a/packages/core/addon/options.ts b/packages/core/addon/options.ts index 0510804a6..cb1c4134b 100644 --- a/packages/core/addon/options.ts +++ b/packages/core/addon/options.ts @@ -20,31 +20,30 @@ export type NumberQuestion = { export type SelectQuestion = { type: 'select'; default: Value; - options: Array<{ value: Value; label?: string; hint?: string }>; + options: Array<{ value: string; label?: string; hint?: string }>; }; -export type MultiSelectQuestion = { +export type MultiSelectQuestion = { type: 'multiselect'; - default: Value[]; - options: Array<{ value: Value; label?: string; hint?: string }>; + default: Value; + options: Array<{ value: string; label?: string; hint?: string }>; required: boolean; }; -export type BaseQuestion = { +export type BaseQuestion = { question: string; group?: string; /** * When this condition explicitly returns `false`, the question's value will * always be `undefined` and will not fallback to the specified `default` value. */ - condition?: (options: any) => boolean; - // TODO: we want to type `options` similar to OptionValues so that its option values can be inferred + condition?: (options: OptionValues) => boolean; }; -export type Question = BaseQuestion & +export type Question = BaseQuestion & (BooleanQuestion | StringQuestion | NumberQuestion | SelectQuestion | MultiSelectQuestion); -export type OptionDefinition = Record; +export type OptionDefinition = Record>; export type OptionValues = { [K in keyof Args]: Args[K] extends StringQuestion ? string @@ -55,6 +54,6 @@ export type OptionValues = { : Args[K] extends SelectQuestion ? Value : Args[K] extends MultiSelectQuestion - ? Value[] + ? Value // as the type of Value should already be an array (default: [] or default: ['foo', 'bar']) : never; };