diff --git a/documentation/docs/30-add-ons/05-drizzle.md b/documentation/docs/30-add-ons/05-drizzle.md index 7cf06ca99..97766fded 100644 --- a/documentation/docs/30-add-ons/05-drizzle.md +++ b/documentation/docs/30-add-ons/05-drizzle.md @@ -28,7 +28,7 @@ Which database variant to use: - `sqlite` — file-based database not requiring a database server ```bash -npx sv add --drizzle=postgresql +npx sv add drizzle=database:postgresql ``` ### client @@ -40,7 +40,7 @@ The SQL client to use, depends on `database`: - For `sqlite`: `better-sqlite3`, `libsql`, `turso` ```bash -npx sv add --drizzle=postgresql,postgres.js +npx sv add drizzle=database:postgresql+client:postgres.js ``` Drizzle is compatible with well over a dozen database drivers. We just offer a few of the most common ones here for simplicity, but if you'd like to use another one you can choose one as a placeholder and swap it out for another after setup by choosing from [Drizzle's full list of compatible drivers](https://orm.drizzle.team/docs/connect-overview#next-steps). @@ -49,9 +49,6 @@ Drizzle is compatible with well over a dozen database drivers. We just offer a f Whether to add Docker Compose configuration. Only available for [`database`](#Options-database) `postgresql` or `mysql` -- `docker` - generates `docker-compose.yml` -- `no-docker` - does not generate docker config - ```bash -npx sv add --drizzle=postgresql,postgres.js,docker +npx sv add drizzle=database:postgresql+client:postgres.js+docker:yes ``` diff --git a/documentation/docs/30-add-ons/15-lucia.md b/documentation/docs/30-add-ons/15-lucia.md index 2eb21f9e5..eb7b68e12 100644 --- a/documentation/docs/30-add-ons/15-lucia.md +++ b/documentation/docs/30-add-ons/15-lucia.md @@ -22,5 +22,5 @@ npx sv add lucia Whether to include demo registration and login pages. ```bash -npx sv add --lucia=demo +npx sv add lucia=demo:yes ``` diff --git a/documentation/docs/30-add-ons/25-paraglide.md b/documentation/docs/30-add-ons/25-paraglide.md index be006f275..e4658e0f8 100644 --- a/documentation/docs/30-add-ons/25-paraglide.md +++ b/documentation/docs/30-add-ons/25-paraglide.md @@ -21,12 +21,12 @@ npx sv add paraglide ## Options -### availableLanguageTags +### languageTags The languages you'd like to support specified as IETF BCP 47 language tags. ```bash -npx sv add --paraglide=en,es +npx sv add paraglide="languageTags:en,es" ``` ### demo @@ -34,5 +34,5 @@ npx sv add --paraglide=en,es Whether to generate an optional demo page showing how to use paraglide. ```bash -npx sv add --paraglide=demo +npx sv add paraglide="demo:yes" ``` diff --git a/documentation/docs/30-add-ons/45-sveltekit-adapter.md b/documentation/docs/30-add-ons/45-sveltekit-adapter.md index d9012752c..1bc77cc63 100644 --- a/documentation/docs/30-add-ons/45-sveltekit-adapter.md +++ b/documentation/docs/30-add-ons/45-sveltekit-adapter.md @@ -28,5 +28,5 @@ Which SvelteKit adapter to use: - `netlify` — [`@sveltejs/adapter-netlify`](/docs/kit/adapter-netlify) allows you to deploy to Netlify ```bash -npx sv add --sveltekit-adapter=node +npx sv add sveltekit-adapter=adapter:node ``` diff --git a/documentation/docs/30-add-ons/50-tailwind.md b/documentation/docs/30-add-ons/50-tailwind.md index 1b454a221..a0926b51c 100644 --- a/documentation/docs/30-add-ons/50-tailwind.md +++ b/documentation/docs/30-add-ons/50-tailwind.md @@ -27,5 +27,5 @@ Which plugin to use: - `forms` — [`@tailwindcss/forms`](https://github.com/tailwindlabs/tailwindcss-forms) ```bash -npx sv add --tailwindcss=typography +npx sv add tailwindcss="plugins:typography" ``` diff --git a/packages/addons/_tests/paraglide/test.ts b/packages/addons/_tests/paraglide/test.ts index e2d2ac01d..67c68eff9 100644 --- a/packages/addons/_tests/paraglide/test.ts +++ b/packages/addons/_tests/paraglide/test.ts @@ -6,7 +6,7 @@ const { test, variants, prepareServer } = setupTest({ paraglide }); const kitOnly = variants.filter((v) => v.includes('kit')); test.concurrent.for(kitOnly)('core - %s', async (variant, { page, ...ctx }) => { - const cwd = await ctx.run(variant, { paraglide: { demo: true, availableLanguageTags: 'en' } }); + const cwd = await ctx.run(variant, { paraglide: { demo: true, languageTags: 'en' } }); const { close } = await prepareServer({ cwd, page }); // kill server process when we're done diff --git a/packages/addons/paraglide/index.ts b/packages/addons/paraglide/index.ts index 822b30a7e..455ee24b4 100644 --- a/packages/addons/paraglide/index.ts +++ b/packages/addons/paraglide/index.ts @@ -26,7 +26,7 @@ const DEFAULT_INLANG_PROJECT = { }; const options = defineAddonOptions({ - availableLanguageTags: { + languageTags: { question: `Which languages would you like to support? ${colors.gray('(e.g. en,de-ch)')}`, type: 'string', default: 'en, es', @@ -78,7 +78,7 @@ export default defineAddon({ for (const key in DEFAULT_INLANG_PROJECT) { data[key] = DEFAULT_INLANG_PROJECT[key as keyof typeof DEFAULT_INLANG_PROJECT]; } - const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags); + const { validLanguageTags } = parseLanguageTagInput(options.languageTags); const baseLocale = validLanguageTags[0]; data.baseLocale = baseLocale; @@ -205,7 +205,7 @@ export default defineAddon({ // add links to other localized pages, the first one is the default // language, thus it does not require any localized route - const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags); + const { validLanguageTags } = parseLanguageTagInput(options.languageTags); const links = validLanguageTags .map( (x) => @@ -222,7 +222,7 @@ export default defineAddon({ }); } - const { validLanguageTags } = parseLanguageTagInput(options.availableLanguageTags); + const { validLanguageTags } = parseLanguageTagInput(options.languageTags); for (const languageTag of validLanguageTags) { sv.file(`messages/${languageTag}.json`, (content) => { const { data, generateCode } = parseJson(content); diff --git a/packages/cli/commands/add/index.ts b/packages/cli/commands/add/index.ts index a6795911c..d0670c895 100644 --- a/packages/cli/commands/add/index.ts +++ b/packages/cli/commands/add/index.ts @@ -5,7 +5,7 @@ import pc from 'picocolors'; import * as v from 'valibot'; import * as pkg from 'empathic/package'; import * as p from '@clack/prompts'; -import { Command, Option } from 'commander'; +import { Command } from 'commander'; import { officialAddons, getAddonDetails, @@ -29,41 +29,149 @@ import { getGlobalPreconditions } from './preconditions.ts'; import { type AddonMap, applyAddons, setupAddons } from '../../lib/install.ts'; const aliases = officialAddons.map((c) => c.alias).filter((v) => v !== undefined); -const addonsOptions = getAddonOptionFlags(); +const addonOptions = getAddonOptionFlags(); const communityDetails: AddonWithoutExplicitArgs[] = []; -const OptionFlagSchema = v.optional(v.array(v.string())); - -const addonOptionFlags = addonsOptions.reduce( - (flags, opt) => Object.assign(flags, { [opt.attributeName()]: OptionFlagSchema }), - {} -); - const AddonsSchema = v.array(v.string()); -const AddonOptionFlagsSchema = v.object(addonOptionFlags); const OptionsSchema = v.strictObject({ cwd: v.string(), install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]), preconditions: v.boolean(), community: v.optional(v.union([AddonsSchema, v.boolean()])), - ...AddonOptionFlagsSchema.entries + addons: v.record(v.string(), v.optional(v.array(v.string()))) }); type Options = v.InferOutput; +type AddonArgs = { id: string; options: string[] | undefined }; + // infers the workspace cwd if a `package.json` resides in a parent directory const defaultPkgPath = pkg.up(); const defaultCwd = defaultPkgPath ? path.dirname(defaultPkgPath) : undefined; - export const add = new Command('add') .description('applies specified add-ons into a project') - .argument('[add-on...]', 'add-ons to install') + .argument('[add-on...]', `add-ons to install`, (value, prev: AddonArgs[] = []) => { + const [addonId, optionFlags] = value.split('=', 2); + + // validates that there are no repeated add-ons (e.g. `sv add foo=demo:yes foo=demo:no`) + const repeatedAddons = prev.find(({ id }) => id === addonId); + if (repeatedAddons) { + console.error(`Malformed arguments: Add-on '${addonId}' is repeated multiple times.`); + process.exit(1); + } + + // occurs when an `=` isn't present (e.g. `sv add foo`) + if (optionFlags === undefined) { + prev.push({ id: addonId, options: undefined }); + return prev; + } + + // validates that the options are relatively well-formed. + // occurs when no or is specified (e.g. `sv add foo=demo`). + if (optionFlags.length > 0 && !/.+:.*/.test(optionFlags)) { + console.error( + `Malformed arguments: An add-on's option in '${value}' is missing it's option name or value (e.g. 'addon=option:value').` + ); + process.exit(1); + } + + // parses the option flags into a array of `:` strings + const options: string[] = optionFlags.match(/[^+]*:[^:]*(?=\+|$)/g) ?? []; + + prev.push({ id: addonId, options }); + return prev; + }) .option('-C, --cwd ', 'path to working directory', defaultCwd) .option('--no-preconditions', 'skip validating preconditions') .option('--no-install', 'skip installing dependencies') .addOption(installOption) //.option('--community [add-on...]', 'community addons to install') - .configureHelp(common.helpConfig) - .action((addonArgs, opts) => { + .configureHelp({ + ...common.helpConfig, + formatHelp(cmd, helper) { + const termWidth = helper.padWidth(cmd, helper); + const helpWidth = helper.helpWidth ?? 80; // in case prepareContext() was not called + + function callFormatItem(term: string, description: string) { + return helper.formatItem(term, termWidth, description, helper); + } + + // Usage + let output = [ + `${helper.styleTitle('Usage:')} ${helper.styleUsage(helper.commandUsage(cmd))}`, + '' + ]; + + // Description + const commandDescription = helper.commandDescription(cmd); + if (commandDescription.length > 0) { + output = output.concat([ + helper.boxWrap(helper.styleCommandDescription(commandDescription), helpWidth), + '' + ]); + } + + // Arguments + const argumentList = helper.visibleArguments(cmd).map((argument) => { + return callFormatItem( + helper.styleArgumentTerm(helper.argumentTerm(argument)), + helper.styleArgumentDescription(helper.argumentDescription(argument)) + ); + }); + if (argumentList.length > 0) { + output = output.concat([helper.styleTitle('Arguments:'), ...argumentList, '']); + } + + // Addon Options + const addonList = addonOptions.map((option) => { + // const description = `${pc.dim(`(preset: ${option.preset})`)}\n${option.choices}`; + const description = option.choices; + return callFormatItem( + helper.styleArgumentTerm(option.id), + helper.styleArgumentDescription(description) + ); + }); + if (addonList.length > 0) { + output = output.concat([helper.styleTitle('Add-On Options:'), ...addonList, '']); + } + + // Options + const optionList = helper.visibleOptions(cmd).map((option) => { + return callFormatItem( + helper.styleOptionTerm(helper.optionTerm(option)), + helper.styleOptionDescription(helper.optionDescription(option)) + ); + }); + if (optionList.length > 0) { + output = output.concat([helper.styleTitle('Options:'), ...optionList, '']); + } + + if (helper.showGlobalOptions) { + const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => { + return callFormatItem( + helper.styleOptionTerm(helper.optionTerm(option)), + helper.styleOptionDescription(helper.optionDescription(option)) + ); + }); + if (globalOptionList.length > 0) { + output = output.concat([helper.styleTitle('Global Options:'), ...globalOptionList, '']); + } + } + + // Commands + const commandList = helper.visibleCommands(cmd).map((cmd) => { + return callFormatItem( + helper.styleSubcommandTerm(helper.subcommandTerm(cmd)), + helper.styleSubcommandDescription(helper.subcommandDescription(cmd)) + ); + }); + if (commandList.length > 0) { + output = output.concat([helper.styleTitle('Commands:'), ...commandList, '']); + } + + return output.join('\n'); + } + }) + .action((addonArgs: AddonArgs[], opts) => { // validate workspace if (opts.cwd === undefined) { console.error( @@ -78,29 +186,26 @@ export const add = new Command('add') process.exit(1); } - const specifiedAddons = v.parse(AddonsSchema, addonArgs); - const options = v.parse(OptionsSchema, opts); const addonIds = officialAddons.map((addon) => addon.id); - const invalidAddons = specifiedAddons.filter( - (a) => !addonIds.includes(a) && !aliases.includes(a) - ); + const invalidAddons = addonArgs + .filter(({ id }) => !addonIds.includes(id) && !aliases.includes(id)) + .map(({ id }) => id); if (invalidAddons.length > 0) { console.error(`Invalid add-ons specified: ${invalidAddons.join(', ')}`); process.exit(1); } - const selectedAddons = transformAliases(specifiedAddons); + const options = v.parse(OptionsSchema, { ...opts, addons: {} }); + const selectedAddons = transformAliases(addonArgs); + selectedAddons.forEach((addon) => (options.addons[addon.id] = addon.options)); + common.runCommand(async () => { - const { nextSteps } = await runAddCommand(options, selectedAddons); + const selectedAddonIds = selectedAddons.map(({ id }) => id); + const { nextSteps } = await runAddCommand(options, selectedAddonIds); if (nextSteps) p.note(nextSteps, 'Next steps', { format: (line) => line }); }); }); -// adds addon specific option flags to the `add` command -for (const option of addonsOptions) { - add.addOption(option); -} - type SelectedAddon = { type: 'official' | 'community'; addon: AddonWithoutExplicitArgs }; export async function runAddCommand( options: Options, @@ -119,11 +224,9 @@ export async function runAddCommand( const community: AddonOption = {}; // apply specified options from flags - for (const addonOption of addonsOptions) { - const addonId = addonOption.name() as keyof Options; - // if the add-on flag contains a `-`, it'll be camelcased (e.g. `sveltekit-adapter` is `sveltekitAdapter`) - const aliased = addonOption.attributeName() as keyof Options; - const specifiedOptions = (options[addonId] || options[aliased]) as string[] | undefined; + for (const addonOption of addonOptions) { + const addonId = addonOption.id; + const specifiedOptions = options.addons[addonId]; if (!specifiedOptions) continue; const details = getAddonDetails(addonId); @@ -134,42 +237,44 @@ export async function runAddCommand( official[addonId] ??= {}; const optionEntries = Object.entries(details.options); - for (const specifiedOption of specifiedOptions) { - // we'll skip empty string and `none` options so that default values can be applied later - if (!specifiedOption || specifiedOption === 'none') continue; - - // figure out which option it belongs to - const optionEntry = optionEntries.find(([id, question]) => { - if (question.type === 'boolean') { - return id === specifiedOption || `no-${id}` === specifiedOption; - } - if (question.type === 'select' || question.type === 'multiselect') { - return question.options.some((o) => o.value === specifiedOption); - } - }); + for (const option of specifiedOptions) { + let [optionId, optionValue] = option.split(':', 2); + + // validates that the option exists + const optionEntry = optionEntries.find( + ([id, question]) => id === optionId || question.group === optionId + ); if (!optionEntry) { const { choices } = getOptionChoices(details); throw new Error( - `Invalid '--${addonId}' option: '${specifiedOption}'\nAvailable options: ${choices.join(', ')}` + `Invalid '${addonId}' option: '${option}'\nAvailable options: ${choices.join(', ')}` ); } const [questionId, question] = optionEntry; + // multiselect options can be specified with a `none` option, which equates to an empty string + if (question.type === 'multiselect' && optionValue === 'none') optionValue = ''; + // validate that there are no conflicts let existingOption = official[addonId][questionId]; if (existingOption !== undefined) { if (typeof existingOption === 'boolean') { - // need to transform the boolean back to `no-{id}` or `{id}` - existingOption = existingOption ? questionId : `no-${questionId}`; + // need to transform the boolean back to `yes` or `no` + existingOption = existingOption ? 'yes' : 'no'; } throw new Error( - `Conflicting '--${addonId}' option: '${specifiedOption}' conflicts with '${existingOption}'` + `Conflicting '${addonId}' option: '${option}' conflicts with '${questionId}:${existingOption}'` ); } - official[addonId][questionId] = - question.type === 'boolean' ? !specifiedOption.startsWith('no-') : specifiedOption; + if (question.type === 'boolean') { + official[addonId][questionId] = optionValue === 'yes'; + } else if (question.type === 'number') { + official[addonId][questionId] = Number(optionValue); + } else { + official[addonId][questionId] = optionValue; + } } // apply defaults to unspecified options @@ -178,12 +283,10 @@ export async function runAddCommand( if (question.condition?.(official[addonId]) !== false) { official[addonId][id] ??= question.default; } else { - // we'll also error out if they specified an option that is incompatible with other options. - // (e.g. the client isn't available for a given database `--drizzle sqlite mysql2`) + // we'll also error out if a specified option is incompatible with other options. + // (e.g. `libsql` isn't a valid client for a `mysql` database: `sv add drizzle=database:mysql2,client:libsql`) if (official[addonId][id] !== undefined) { - throw new Error( - `Incompatible '--${addonId}' option specified: '${official[addonId][id]}'` - ); + throw new Error(`Incompatible '${addonId}' option specified: '${official[addonId][id]}'`); } } } @@ -510,21 +613,22 @@ export async function runAddCommand( /** * Dedupes and transforms aliases into their respective addon id */ -function transformAliases(ids: string[]): string[] { - const set = new Set(); - for (const id of ids) { - if (aliases.includes(id)) { - const addon = officialAddons.find((a) => a.alias === id)!; - set.add(addon.id); +function transformAliases(addons: AddonArgs[]): AddonArgs[] { + const set = new Map(); + + for (const addon of addons) { + if (aliases.includes(addon.id)) { + const officialAddon = officialAddons.find((a) => a.alias === addon.id)!; + set.set(officialAddon.id, { id: officialAddon.id, options: addon.options }); } else { - set.add(id); + set.set(addon.id, addon); } } - return Array.from(set); + return Array.from(set.values()); } -function getAddonOptionFlags(): Option[] { - const options: Option[] = []; +function getAddonOptionFlags() { + const options: Array<{ id: string; choices: string; preset: string }> = []; for (const addon of officialAddons) { const id = addon.id; const details = getAddonDetails(id); @@ -535,19 +639,7 @@ function getAddonOptionFlags(): Option[] { .map(([group, choices]) => `${pc.dim(`${group}:`)} ${choices.join(', ')}`) .join('\n'); const preset = defaults.join(', ') || 'none'; - const option = new Option( - `--${id} [options...]`, - `${id} add-on options ${pc.dim(`(preset: ${preset})`)}\n${choices}` - ) - // presets are applied when `--{addonName}` is specified with no options - .preset(preset) - .argParser((value, prev: string[]) => { - prev ??= []; - prev = prev.concat(value.split(/\s|,/)); - return prev; - }); - - options.push(option); + options.push({ id, choices, preset }); } return options; } @@ -561,7 +653,7 @@ function getOptionChoices(details: AddonWithoutExplicitArgs) { let values: string[] = []; const applyDefault = question.condition?.(options) !== false; if (question.type === 'boolean') { - values = [id, `no-${id}`]; + values = ['yes', `no`]; if (applyDefault) { options[id] = question.default; defaults.push((question.default ? values[0] : values[1])!); @@ -581,9 +673,15 @@ function getOptionChoices(details: AddonWithoutExplicitArgs) { defaults.push(...question.default); } } + if (question.type === 'string' || question.type === 'number') { + values = ['']; + if (applyDefault) { + options[id] = question.default; + defaults.push(question.default.toString()); + } + } choices.push(...values); - // we'll fallback to the question's id const groupId = question.group ?? id; groups[groupId] ??= []; diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 6ab929482..5d259c316 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -177,7 +177,13 @@ async function createProject(cwd: ProjectPath, options: Options) { if (options.addOns) { // `runAddCommand` includes installing dependencies const { nextSteps, packageManager: pm } = await runAddCommand( - { cwd: projectPath, install: options.install, preconditions: false, community: [] }, + { + cwd: projectPath, + install: options.install, + preconditions: false, + community: [], + addons: {} + }, [] ); packageManager = pm;