diff --git a/packages/shared/src/commands/add.ts b/packages/shared/src/commands/add.ts index b24ad76..edd3cb3 100644 --- a/packages/shared/src/commands/add.ts +++ b/packages/shared/src/commands/add.ts @@ -1,9 +1,16 @@ -import { log, select } from '@clack/prompts' +import { intro, log, outro, select, spinner } from '@clack/prompts' import { defineCommand, runCommand } from 'citty' -import { addEslint } from '../functions' +import { REGISTRY_ORIGIN } from '../constants/registry' +import { addEslint, addFeature } from '../functions' +import { getIndex } from '../functions/registry' import { i18n } from '../i18n' import { mcp } from './mcp' +/** + * Integrations wire a tool into the project. Everything else typed after `add` + * is looked up in the registry, so `vuetify add dialog` writes a working, + * styled example instead of leaving the user to copy it out of the docs. + */ const choices = ['eslint'] export const add = defineCommand({ @@ -14,8 +21,32 @@ export const add = defineCommand({ args: { integration: { type: 'positional', + required: false, description: i18n.t('commands.add.integration.description', { choices: choices.join(', ') }), }, + example: { + type: 'string', + description: i18n.t('commands.add.args.example'), + }, + dir: { + type: 'string', + description: i18n.t('commands.add.args.dir'), + }, + registry: { + type: 'string', + default: REGISTRY_ORIGIN, + description: i18n.t('commands.add.args.registry'), + }, + overwrite: { + type: 'boolean', + default: false, + description: i18n.t('commands.add.args.overwrite'), + }, + yes: { + type: 'boolean', + default: false, + description: i18n.t('commands.add.args.yes'), + }, }, run: async ({ args }) => { let integration = args.integration @@ -26,26 +57,58 @@ export const add = defineCommand({ return } + // With no argument, offer integrations and registry items in one list so + // neither surface is hidden behind knowing its name up front. if (!integration) { + const loader = spinner() + loader.start(i18n.t('spinners.registry.fetching')) + const index = await getIndex(args.registry) + loader.stop(i18n.t('spinners.registry.fetched')) + const selected = await select({ - message: i18n.t('prompts.add.integration'), - options: choices.map(c => ({ label: c, value: c })), + message: i18n.t('prompts.add.feature'), + options: [ + ...choices.map(choice => ({ label: choice, value: choice, hint: 'integration' })), + ...index.items.map(item => ({ + label: item.name, + value: item.name, + hint: `${item.type === 'components' ? 'component' : 'composable'} · ${item.category}`, + })), + ], }) + if (typeof selected === 'symbol') { log.warning(i18n.t('commands.add.integration.available', { choices: choices.join(', ') })) return } + integration = String(selected) } - if (!choices.includes(integration)) { - log.error(i18n.t('commands.add.integration.invalid', { integration, choices: choices.join(', ') })) + + if (choices.includes(integration)) { + switch (integration) { + case 'eslint': { + await addEslint() + break + } + } return } - switch (integration) { - case 'eslint': { - await addEslint() - break - } + + intro(i18n.t('commands.add.intro', { name: integration })) + + const written = await addFeature({ + name: integration, + example: args.example, + dir: args.dir, + registry: args.registry, + overwrite: args.overwrite, + yes: args.yes, + }) + + // A failed resolution has already said why — don't follow it with "all done". + if (written.length > 0) { + outro(i18n.t('messages.all_done')) } }, }) diff --git a/packages/shared/src/constants/registry.ts b/packages/shared/src/constants/registry.ts new file mode 100644 index 0000000..f65c1d4 --- /dev/null +++ b/packages/shared/src/constants/registry.ts @@ -0,0 +1,21 @@ +/** Static origin publishing the `vuetify add` registry. */ +export const REGISTRY_ORIGIN = 'https://0.vuetifyjs.com' + +/** Registry payload version this CLI understands. */ +export const REGISTRY_VERSION = 1 + +export const V0 = '@vuetify/v0' + +/** Emits the `--v0-*` custom properties the semantic utilities resolve against. */ +export const THEME_PLUGIN = 'createThemePlugin' + +export const REGISTRY_TIMEOUT = 15_000 + +export const UNOCSS_CONFIGS = [ + 'uno.config.ts', + 'uno.config.js', + 'uno.config.mjs', + 'unocss.config.ts', + 'unocss.config.js', + 'unocss.config.mjs', +] diff --git a/packages/shared/src/functions/feature.ts b/packages/shared/src/functions/feature.ts new file mode 100644 index 0000000..8ce9fee --- /dev/null +++ b/packages/shared/src/functions/feature.ts @@ -0,0 +1,397 @@ +// Types +import type { RegistryExample, RegistryIndex, RegistryIndexEntry, RegistryItem, TokenContract } from './registry' +import { existsSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { cancel, confirm, isCancel, log, note, select, spinner } from '@clack/prompts' +import { dim, underline } from 'kolorist' +import { loadFile } from 'magicast' +import { getDefaultExportOptions } from 'magicast/helpers' +import { dirname, join, relative, resolve } from 'pathe' +import { REGISTRY_ORIGIN, THEME_PLUGIN, UNOCSS_CONFIGS, V0 } from '../constants/registry' +import { i18n } from '../i18n' +import { addDependency } from '../utils/installDependencies' +import { getProjectPackageJSON } from '../utils/package' +import { getContract, getIndex, getItem, match } from './registry' + +export interface FeatureOptions { + name?: string + example?: string + dir?: string + registry?: string + overwrite?: boolean + yes?: boolean + cwd?: string +} + +/** Styles entry points a Tailwind v4 project is likely to declare its theme in. */ +const STYLESHEETS = [ + 'src/tailwind.css', + 'src/style.css', + 'src/styles/main.css', + 'src/assets/main.css', + 'app/assets/css/tailwind.css', + 'app/assets/css/main.css', + 'assets/css/tailwind.css', +] + +/** + * Unwinds out of a nested prompt without tearing the process down mid-write. + * + * The user-facing message is logged where the problem is found, so this only + * carries the exit code back up to `addFeature`, which sets it and returns. + */ +class Bail extends Error { + constructor (readonly code: number) { + super('bail') + } +} + +function stop (): never { + cancel(i18n.t('prompts.cancel')) + throw new Bail(0) +} + +function unwrap (value: T | symbol): T { + if (isCancel(value)) { + stop() + } + return value as T +} + +function find (cwd: string, candidates: string[]) { + for (const candidate of candidates) { + const path = join(cwd, candidate) + if (existsSync(path)) { + return path + } + } + return null +} + +/** Resolve the user's argument to exactly one registry entry. */ +async function pick (index: RegistryIndex, options: FeatureOptions): Promise { + if (!options.name) { + const chosen = unwrap(await select({ + message: i18n.t('prompts.add.feature'), + options: index.items.map(item => ({ + label: item.title, + value: `${item.type}/${item.name}`, + hint: `${item.type === 'components' ? 'component' : 'composable'} · ${item.category}`, + })), + })) + + return index.items.find(item => `${item.type}/${item.name}` === chosen)! + } + + const found = match(index, options.name) + + if (found.length === 0) { + const close = index.items + .filter(item => item.name.startsWith(options.name!.slice(0, 3))) + .slice(0, 5) + .map(item => item.name) + + log.error(i18n.t('commands.add.unknown', { name: options.name })) + if (close.length > 0) { + log.info(i18n.t('commands.add.suggest', { names: close.join(', ') })) + } + + throw new Bail(1) + } + + if (found.length === 1) { + return found[0] + } + + // Guessing which of several features the user meant is worse than stopping, + // and a scripted run has nobody to answer the prompt. + if (options.yes) { + log.error(i18n.t('commands.add.ambiguous', { name: options.name })) + log.info(i18n.t('commands.add.suggest', { names: found.map(item => item.name).join(', ') })) + throw new Bail(1) + } + + const chosen = unwrap(await select({ + message: i18n.t('prompts.add.resolve'), + options: found.map(item => ({ + label: item.name, + value: `${item.type}/${item.name}`, + hint: `${item.type === 'components' ? 'component' : 'composable'} · ${item.category}`, + })), + })) + + return found.find(item => `${item.type}/${item.name}` === chosen)! +} + +async function choose (item: RegistryItem, options: FeatureOptions): Promise { + if (options.example) { + const found = item.examples.find(example => example.id === options.example) + if (found) { + return found + } + + log.error(i18n.t('commands.add.unknown', { name: options.example })) + log.info(i18n.t('commands.add.suggest', { names: item.examples.map(example => example.id).join(', ') })) + throw new Bail(1) + } + + if (item.examples.length === 1) { + return item.examples[0] + } + + // Unlike an ambiguous name, every example here belongs to the feature the + // user asked for, so a scripted run can take the canonical one and proceed. + if (options.yes) { + return item.examples.find(example => example.id === 'basic') ?? item.examples[0] + } + + const chosen = unwrap(await select({ + message: i18n.t('prompts.add.example'), + options: item.examples.map(example => ({ + label: example.title, + value: example.id, + hint: example.files.length > 1 ? `${example.files.length} files` : undefined, + })), + })) + + return item.examples.find(example => example.id === chosen)! +} + +/** Install any package the example imports that the project does not have. */ +async function depend (example: RegistryExample, options: FeatureOptions) { + const pkg = await getProjectPackageJSON(options.cwd).catch(() => null) + + const present = new Set([ + ...Object.keys(pkg?.dependencies ?? {}), + ...Object.keys(pkg?.devDependencies ?? {}), + // Always present in a Vue project, and never worth reinstalling. + 'vue', + ]) + + const missing = example.dependencies.filter(name => !present.has(name)) + if (missing.length === 0) { + return + } + + const install = options.yes || unwrap(await confirm({ + message: i18n.t('prompts.add.install', { pkgs: missing.join(', ') }), + })) + + if (!install) { + return + } + + const loader = spinner() + loader.start(i18n.t('commands.add.deps', { pkgs: missing.join(', ') })) + await addDependency(missing, { cwd: options.cwd, silent: true }) + loader.stop(i18n.t('spinners.dependencies.installed')) +} + +/** + * Make sure the semantic utility classes in the copied markup resolve. + * + * Two independent layers have to be present: `createThemePlugin` emits the + * `--v0-*` custom properties, and the UnoCSS/Tailwind config maps them onto + * `bg-primary`, `text-on-surface` and friends. Without both, the file lands + * looking broken and v0 takes the blame — so this warns loudly and offers the + * patch rather than writing silently. + */ +async function style (item: RegistryItem, example: RegistryExample, contract: TokenContract, options: FeatureOptions) { + const cwd = options.cwd ?? process.cwd() + + if (example.tokens.length === 0) { + return + } + + const pkg = await getProjectPackageJSON(cwd).catch(() => null) + const hasV0 = !!(pkg?.dependencies?.[V0] ?? pkg?.devDependencies?.[V0]) + + const uno = find(cwd, UNOCSS_CONFIGS) + const sheet = find(cwd, STYLESHEETS) + const target = uno ?? sheet + + if (!target) { + log.warn(i18n.t('commands.add.styling.none', { + name: item.name, + tokens: example.tokens.slice(0, 3).join(', '), + })) + // Neither engine is present, so there is nothing to infer from — show both + // rather than guessing at one and handing over syntax for the other. + note( + `${i18n.t('commands.add.styling.unocss')}\n${contract.unocss}\n\n${i18n.t('commands.add.styling.tailwind')}\n${contract.tailwind}`, + i18n.t('commands.add.styling.manual', { plugin: THEME_PLUGIN }), + ) + return + } + + const content = await readFile(target, 'utf8') + + // Both templates write the whole contract at once, so the presence of any + // mapping means the block is already there. + if (!content.includes(contract.prefix)) { + const file = underline(relative(cwd, target)) + + log.warn(i18n.t('commands.add.styling.missing', { file })) + + const patch = options.yes || unwrap(await confirm({ + message: i18n.t('prompts.add.tokens', { file }), + })) + + if (patch) { + const patched = target === uno + ? await map(target, contract) + : await append(target, content, contract.tailwind) + + if (patched) { + log.success(i18n.t('commands.add.styling.patched', { file })) + } else { + note(contract.unocss, i18n.t('commands.add.styling.manual', { plugin: THEME_PLUGIN })) + } + } else { + note(target === uno ? contract.unocss : contract.tailwind, i18n.t('commands.add.styling.manual', { plugin: THEME_PLUGIN })) + } + } + + if (hasV0 && !await themed(cwd)) { + log.warn(i18n.t('commands.add.styling.theme', { plugin: THEME_PLUGIN, prefix: contract.prefix })) + } +} + +/** + * Add the color map to an UnoCSS config's default export. + * + * The block has to land inside `defineConfig({ ... })` — appending the snippet + * would leave the file syntactically broken — so this edits the AST and leaves + * any colors the project already defined untouched. Returns false when the + * config shape is one magicast cannot read, so the caller can fall back to + * printing the snippet. + */ +async function map (path: string, contract: TokenContract) { + try { + const mod = await loadFile(path) + const options = getDefaultExportOptions(mod) + + if (!options) { + return false + } + + options.theme ||= {} + options.theme.colors ||= {} + + for (const token of contract.tokens) { + options.theme.colors[token] ??= `var(${contract.prefix}${token})` + } + + await writeFile(path, mod.generate().code) + + return true + } catch { + return false + } +} + +/** `@theme inline` is valid at the end of a stylesheet, so appending is safe. */ +async function append (path: string, content: string, snippet: string) { + await writeFile(path, `${content.trimEnd()}\n\n${snippet}\n`) + + return true +} + +/** Whether the project installs the plugin that emits the custom properties. */ +async function themed (cwd: string) { + const candidates = [ + 'src/plugins/vuetify.ts', + 'src/plugins/index.ts', + 'src/main.ts', + 'app/plugins/vuetify.ts', + 'plugins/vuetify.ts', + 'nuxt.config.ts', + ] + + for (const candidate of candidates) { + const content = await readFile(join(cwd, candidate), 'utf8').catch(() => '') + if (content.includes(THEME_PLUGIN)) { + return true + } + } + + return false +} + +async function write (example: RegistryExample, options: FeatureOptions) { + const cwd = options.cwd ?? process.cwd() + const nuxt = existsSync(join(cwd, 'nuxt.config.ts')) || existsSync(join(cwd, 'nuxt.config.js')) + const base = options.dir ?? (nuxt ? 'app/components' : 'src/components') + const written: string[] = [] + + for (const file of example.files) { + const path = resolve(cwd, base, example.dir, file.name) + const shown = relative(cwd, path) + + if (existsSync(path) && !options.overwrite) { + // `--yes` accepts prompts, but clobbering a user's file is not a default + // worth accepting — a scripted run skips and says so, and --overwrite is + // the way to ask for the replacement explicitly. + const replace = options.yes + ? false + : unwrap(await confirm({ + message: i18n.t('prompts.add.overwrite', { path: underline(shown) }), + initialValue: false, + })) + + if (!replace) { + log.info(i18n.t('commands.add.skipped', { path: shown })) + continue + } + } + + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, file.content) + + written.push(shown) + log.success(i18n.t('commands.add.wrote', { path: shown })) + } + + return written +} + +export async function addFeature (options: FeatureOptions = {}) { + try { + return await run(options) + } catch (error) { + // A cancel or a resolution failure has already told the user why; anything + // else is a real fault and belongs on the surface with its message. + if (!(error instanceof Bail)) { + throw error + } + + process.exitCode = error.code + + return [] + } +} + +async function run (options: FeatureOptions) { + const origin = options.registry ?? REGISTRY_ORIGIN + + const loader = spinner() + loader.start(i18n.t('spinners.registry.fetching')) + const index = await getIndex(origin) + loader.stop(i18n.t('spinners.registry.fetched')) + + const entry = await pick(index, options) + const item = await getItem(entry, origin) + const example = await choose(item, options) + const contract = await getContract(origin) + + await depend(example, options) + await style(item, example, contract, options) + + const written = await write(example, options) + + if (written.length > 0) { + log.message(dim(i18n.t('commands.add.docs', { url: item.docs }))) + } + + return written +} diff --git a/packages/shared/src/functions/index.ts b/packages/shared/src/functions/index.ts index 18e8ca3..41cbee3 100644 --- a/packages/shared/src/functions/index.ts +++ b/packages/shared/src/functions/index.ts @@ -2,5 +2,7 @@ export * from './analyze' export * from './create' export * from './docs' export * from './eslint' +export * from './feature' +export * from './registry' export * from './scaffold' export * from './upgrade' diff --git a/packages/shared/src/functions/registry.ts b/packages/shared/src/functions/registry.ts new file mode 100644 index 0000000..91d1183 --- /dev/null +++ b/packages/shared/src/functions/registry.ts @@ -0,0 +1,119 @@ +import { REGISTRY_ORIGIN, REGISTRY_TIMEOUT, REGISTRY_VERSION } from '../constants/registry' +import { i18n } from '../i18n' + +export type ItemType = 'components' | 'composables' + +export interface RegistryFile { + path: string + name: string + entry: boolean + content: string +} + +export interface RegistryExample { + id: string + title: string + description: string + dir: string + files: RegistryFile[] + dependencies: string[] + tokens: string[] +} + +export interface RegistryItem { + name: string + type: ItemType + category: string + level: string + title: string + description: string + docs: string + examples: RegistryExample[] +} + +export interface RegistryIndexEntry { + name: string + type: ItemType + category: string + level: string + title: string + description: string + docs: string + examples: string[] +} + +export interface RegistryIndex { + version: number + v0Version: string + tokens: string[] + items: RegistryIndexEntry[] +} + +export interface TokenContract { + version: number + tokens: string[] + prefix: string + unocss: string + tailwind: string +} + +const RE_TRAILING_SLASH = /\/$/ +const RE_SEPARATORS = /[\s_]+/g +const RE_FACTORY_PREFIX = /^(create|use)-/ + +async function get (origin: string, path: string): Promise { + const url = `${origin.replace(RE_TRAILING_SLASH, '')}/registry/${path}` + + const response = await fetch(url, { signal: AbortSignal.timeout(REGISTRY_TIMEOUT) }) + .catch((error: Error) => { + throw new Error(i18n.t('errors.registry.unreachable', { url, reason: error.message })) + }) + + if (!response.ok) { + throw new Error(i18n.t('errors.registry.status', { url, status: response.status })) + } + + return await response.json() as T +} + +export async function getIndex (origin = REGISTRY_ORIGIN) { + const index = await get(origin, 'index.json') + + // A newer registry may describe items in a shape this CLI cannot write. + if (index.version > REGISTRY_VERSION) { + throw new Error(i18n.t('errors.registry.version', { found: index.version, expected: REGISTRY_VERSION })) + } + + return index +} + +export async function getItem (entry: RegistryIndexEntry, origin = REGISTRY_ORIGIN) { + return await get(origin, `${entry.type}/${entry.name}.json`) +} + +export async function getContract (origin = REGISTRY_ORIGIN) { + return await get(origin, 'tokens.json') +} + +/** + * Candidate registry entries for a user-typed name. + * + * Exact matches win outright. Failing that, `add popover` should still find the + * `usePopover` composable and `add data table` the `createDataTable` one, so + * the fallback strips the `create`/`use` prefix and tolerates a loose match. + */ +export function match (index: RegistryIndex, query: string): RegistryIndexEntry[] { + const needle = query.trim().toLowerCase().replace(RE_SEPARATORS, '-') + + const exact = index.items.filter(item => item.name === needle) + if (exact.length > 0) { + return exact + } + + const bare = index.items.filter(item => item.name.replace(RE_FACTORY_PREFIX, '') === needle) + if (bare.length > 0) { + return bare + } + + return index.items.filter(item => item.name.includes(needle) || needle.includes(item.name)) +} diff --git a/packages/shared/src/i18n/locales/en.json b/packages/shared/src/i18n/locales/en.json index eb011d5..7257ecf 100644 --- a/packages/shared/src/i18n/locales/en.json +++ b/packages/shared/src/i18n/locales/en.json @@ -144,11 +144,35 @@ "opening": "Opening %{url}" }, "add": { - "description": "Add tools to the project", + "description": "Add an integration or a component to the project", "integration": { - "description": "Integration to add (choices: %{choices})", + "description": "Integration or component to add (integrations: %{choices})", "available": "Available integrations: %{choices}", "invalid": "Invalid integration: %{integration}. Available: %{choices}" + }, + "intro": "Add %{name} to your project", + "args": { + "example": "Example to add when a feature ships more than one", + "dir": "Directory to write the files into", + "registry": "Registry origin to read from", + "overwrite": "Overwrite existing files without asking", + "yes": "Accept every prompt with its default" + }, + "unknown": "Nothing in the registry matches \"%{name}\".", + "ambiguous": "\"%{name}\" matches more than one thing — name it exactly, or drop --yes to choose.", + "suggest": "Did you mean: %{names}?", + "wrote": "Wrote %{path}", + "skipped": "Skipped %{path}", + "deps": "Installing %{pkgs}", + "docs": "Docs: %{url}", + "styling": { + "missing": "Semantic colors are not mapped in %{file}, so the copied markup will render unstyled.", + "none": "No UnoCSS or Tailwind config found. %{name} styles itself with semantic utility classes (%{tokens}), which need one of them.", + "patched": "Mapped semantic colors in %{file}", + "manual": "Add this to your styles, then make sure %{plugin} is installed so the custom properties exist:", + "unocss": "UnoCSS — inside defineConfig in uno.config.ts:", + "tailwind": "Tailwind — in your CSS entry:", + "theme": "%{plugin} was not found. Without it the %{prefix} custom properties are never emitted and every semantic color falls back to nothing." } }, "update": { @@ -223,7 +247,13 @@ "prompts": { "proceed": "Do you want to proceed?", "add": { - "integration": "Choose an integration to add" + "integration": "Choose an integration to add", + "feature": "Choose what to add", + "resolve": "Several things match — which one?", + "example": "Which example?", + "install": "Install %{pkgs}?", + "tokens": "Map them in %{file}?", + "overwrite": "%{path} already exists. Overwrite?" }, "eslint": { "overwrite": "Found %{file}. Do you want to overwrite it?", @@ -347,7 +377,18 @@ }, "cancel": "Setup cancelled. You can run this command again anytime." }, + "errors": { + "registry": { + "unreachable": "Could not reach %{url} (%{reason}).", + "status": "%{url} responded with %{status}.", + "version": "The registry is version %{found}, but this CLI understands %{expected}. Update the CLI." + } + }, "spinners": { + "registry": { + "fetching": "Reading the registry...", + "fetched": "Registry read" + }, "template": { "downloading": "Downloading template %{template}...", "copied": "Template copied",