diff --git a/.changeset/sixty-lizards-end.md b/.changeset/sixty-lizards-end.md new file mode 100644 index 0000000000..589c36aad8 --- /dev/null +++ b/.changeset/sixty-lizards-end.md @@ -0,0 +1,5 @@ +--- +"shadcn-svelte": patch +--- + +breaking: svelte 5 + tailwindcss v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc737215c8..cdc9affd09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,5 +58,8 @@ jobs: - name: Install dependencies run: pnpm install + - name: Build packages + run: pnpm build:cli + - name: Run tests run: pnpm -F shadcn-svelte test diff --git a/.github/workflows/deploy-tailwind-3.yml b/.github/workflows/deploy-tailwind-3.yml new file mode 100644 index 0000000000..df387a60a4 --- /dev/null +++ b/.github/workflows/deploy-tailwind-3.yml @@ -0,0 +1,50 @@ +name: Tailwind 3 Deployment +on: + push: + branches: + - next-tailwind-3 + paths: + - sites/docs/** + workflow_dispatch: + + +jobs: + deploy-production: + runs-on: macos-latest + permissions: + contents: read + deployments: write + name: Deploy Production Site to Cloudflare Pages + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + # Image cache setup + - name: Setup Image cache + uses: actions/cache@v4 + with: + path: "**/node_modules/.cache/imagetools" + key: ${{ runner.os }}-image-cache-${{ hashFiles('**/src/lib/img') }} + + - name: Install dependencies + run: pnpm install + + - name: Build site + env: + NODE_OPTIONS: --max-old-space-size=8192 + run: pnpm build + + - name: Deploy to Cloudflare Pages + uses: AdrianGonz97/refined-cf-pages-action@v1 + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} + githubToken: ${{ secrets.GITHUB_TOKEN }} + projectName: shadcn-svelte-tw-3 + directory: ./.svelte-kit/cloudflare + workingDirectory: sites/docs + deploymentName: Production Tailwind 3 diff --git a/.gitignore b/.gitignore index cc5613eee1..ef66d7d859 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store node_modules +dist /build /.svelte-kit /package @@ -103,9 +104,6 @@ web_modules/ .yarn/build-state.yml .yarn/install-state.gz .pnp.* -packages/cli/dist -dist/ -dist generated-assets # JetBrains IDEs @@ -113,6 +111,16 @@ generated-assets .velite sites/docs/static/registry -sites/docs/src/lib/registry-json/**/*.json -sites/docs/src/lib/registry-json/**/*.css -sites/docs/static/themes.css \ No newline at end of file +sites/docs/static/themes.css +sites/docs/static/schema/registry.json +sites/docs/static/schema/registry-item.json +sites/docs/src/__registry__/json + +v4/static/registry +v4/static/themes.css +v4/src/__registry__ +v4/static/schema/registry.json +v4/static/schema/registry-item.json + + +registry-template/static/r \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index 78a9495914..d2c5c8a013 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v20.15.1 \ No newline at end of file +v22.15.0 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 292b490a73..f49ec4fb59 100644 --- a/.prettierignore +++ b/.prettierignore @@ -25,6 +25,7 @@ sites/docs/other/themes/light.json sites/docs/static sites/docs/.velite sites/docs/src/__registry__ +v4/src/__registry__ packages/cli/test/fixtures playgrounds .svelte-kit \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 494fa21609..33282761f3 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,15 @@ +import { fileURLToPath } from "node:url"; import js from "@eslint/js"; +import { includeIgnoreFile } from "@eslint/compat"; import prettier from "eslint-config-prettier"; import svelte from "eslint-plugin-svelte"; import globals from "globals"; import ts from "typescript-eslint"; +const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); + export default ts.config( + includeIgnoreFile(gitignorePath), js.configs.recommended, ...ts.configs.recommended, ...svelte.configs["flat/recommended"], diff --git a/package.json b/package.json index 4f1fd873fe..9f72273d04 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "shadcn-svelte", + "name": "@shadcn-svelte/monorepo", "version": "0.0.1", "description": "monorepo for shadcn-svelte", "author": { @@ -9,17 +9,20 @@ "private": true, "scripts": { "build": "pnpm build:docs", - "build:cli": "pnpm -F shadcn-svelte build", + "build:cli": "pnpm -r -F \"./packages/**\" build", "build:docs": "pnpm -F docs build", + "build:v4": "pnpm -F v4 build", "dev": "pnpm -F docs dev", - "dev:cli": "pnpm -F shadcn-svelte start:dev", + "dev:cli": "pnpm -F \"./packages/**\" svelte-kit sync && pnpm --parallel --reporter append-only --color dev", + "dev:v4": "pnpm -F v4 dev", "preview": "pnpm -F docs preview", "test": "pnpm -F shadcn-svelte test", - "check": "pnpm -F docs check", + "build:registry-template": "pnpm build:cli && pnpm -F registry-template build:registry", + "check": "pnpm -F docs check && pnpm -F \"./packages/**\" check", "lint": "prettier --check . && eslint .", "format": "prettier --write .", "preinstall": "npx only-allow pnpm", - "postinstall": "pnpm -r sync", + "postinstall": "pnpm -r sync && pnpm build:cli", "ci:publish": "changeset publish", "ci:build": "pnpm build:cli", "ci:release": "pnpm ci:build && pnpm ci:publish" @@ -37,7 +40,9 @@ "type": "module", "devDependencies": { "@changesets/cli": "^2.28.1", + "@eslint/compat": "^1.2.7", "@eslint/js": "^9.22.0", + "@types/node": "^22.15.15", "@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/scope-manager": "^8.26.1", "@typescript-eslint/utils": "^8.26.1", @@ -45,12 +50,18 @@ "eslint-config-prettier": "^10.1.1", "eslint-plugin-svelte": "^3.2.1", "globals": "^16.0.0", + "minimatch": "^10.0.1", "prettier": "^3.5.3", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", "pretty-quick": "^4.1.1", "svelte": "^5.23.1", - "typescript": "^5.8.2", + "typescript": "^5.8.3", "typescript-eslint": "^8.26.1" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] } } diff --git a/packages/cli/README.md b/packages/cli/README.md index 4397e6ca2e..3e754e7342 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -6,7 +6,7 @@ A CLI for adding shadcn components to your project. Use the `init` command to initialize dependencies for a new project. -The `init` command installs dependencies, adds the `cn` util, configures `tailwind.config.cjs`, and sets up CSS variables for the project. +The `init` command installs dependencies, adds the `cn` util, configures, and sets up CSS variables for the project. ```bash npx shadcn-svelte init diff --git a/packages/cli/__mocks__/fs.cjs b/packages/cli/__mocks__/fs.cjs new file mode 100644 index 0000000000..bb707dfbd4 --- /dev/null +++ b/packages/cli/__mocks__/fs.cjs @@ -0,0 +1,5 @@ +// we can also use `import`, but then +// every export should be explicitly defined +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { fs } = require("memfs"); +module.exports = fs; diff --git a/packages/cli/__mocks__/fs/promises.cjs b/packages/cli/__mocks__/fs/promises.cjs new file mode 100644 index 0000000000..0786df6d28 --- /dev/null +++ b/packages/cli/__mocks__/fs/promises.cjs @@ -0,0 +1,5 @@ +// we can also use `import`, but then +// every export should be explicitly defined +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { fs } = require("memfs"); +module.exports = fs.promises; diff --git a/packages/cli/package.json b/packages/cli/package.json index ec7de92e44..632f83a47b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,30 +34,36 @@ "start": "node dist/index.js", "start:dev": "cross-env COMPONENTS_REGISTRY_URL=http://localhost:5173/registry node dist/index.js", "start:proxy": "pnpm dlx straightforward@latest --port 9000", - "release": "changeset version", - "test": "vitest" + "test": "pnpm -w build:registry-template && vitest" }, "dependencies": { - "@clack/core": "^0.3.4", - "chalk": "5.2.0", - "commander": "^10.0.1", - "execa": "^7.2.0", - "is-unicode-supported": "^2.0.0", + "commander": "^13.1.0", "node-fetch-native": "^1.6.4", - "semver": "^7.7.1" + "postcss": "^8.4.39" }, "devDependencies": { + "@clack/prompts": "^1.0.0-alpha.0", + "@shadcn-svelte/registry": "workspace:*", + "@svecosystem/strip-types": "^0.0.2", + "@sveltejs/acorn-typescript": "^1.0.5", "@types/node": "^18.19.22", "@types/semver": "^7.5.8", + "acorn": "^8.13.0", + "chalk": "^5.4.0", "cross-env": "^7.0.3", + "deepmerge": "^4.3.1", + "estree-walker": "^3.0.3", "get-tsconfig": "^4.7.3", - "ignore": "^5.3.1", - "package-manager-detector": "^0.2.2", - "sisteransi": "^1.0.5", - "tsup": "^8.0.0", + "ignore": "^7.0.4", + "memfs": "^4.17.2", + "package-manager-detector": "^1.2.0", + "semver": "^7.7.1", + "sucrase": "^3.35.0", + "tinyexec": "^1.0.1", + "tsup": "^8.4.0", "type-fest": "^3.13.1", - "typescript": "^5.0.0", - "valibot": "^0.36.0", - "vitest": "^0.34.6" + "typescript": "^5.8.3", + "vitest": "^3.1.3", + "zod": "3.25.0-beta.20250516T005923" } } diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts deleted file mode 100644 index f42ceedde1..0000000000 --- a/packages/cli/src/commands/add.ts +++ /dev/null @@ -1,16 +0,0 @@ -import color from "chalk"; -import { Command } from "commander"; -import { handleError } from "../utils/errors.js"; -import { intro } from "../utils/prompt-helpers.js"; - -const highlight = (...args: unknown[]) => color.bold.cyan(...args); - -export const add = new Command() - .command("add", { hidden: true }) - .description("add components to your project") - .action(() => { - intro(); - handleError( - `The ${highlight("add")} command is no longer available in this version of the CLI.\n\nUse ${highlight("npx shadcn-svelte@latest add")} instead to access this command.` - ); - }); diff --git a/packages/cli/src/commands/add/index.ts b/packages/cli/src/commands/add/index.ts new file mode 100644 index 0000000000..e20254e8fc --- /dev/null +++ b/packages/cli/src/commands/add/index.ts @@ -0,0 +1,146 @@ +import path from "node:path"; +import process from "node:process"; +import { existsSync } from "node:fs"; +import color from "chalk"; +import { z } from "zod/v4"; +import { Command } from "commander"; +import { ConfigError, error, handleError } from "../../utils/errors.js"; +import * as cliConfig from "../../utils/get-config.js"; +import { getEnvProxy } from "../../utils/get-env-proxy.js"; +import { cancel, intro, prettifyList } from "../../utils/prompt-helpers.js"; +import * as p from "@clack/prompts"; +import * as registry from "../../utils/registry/index.js"; +import { preflightAdd } from "./preflight.js"; +import { addRegistryItems } from "../../utils/add-registry-items.js"; +import { highlight } from "../../utils/utils.js"; +import { installDependencies } from "../../utils/install-deps.js"; + +const addOptionsSchema = z.object({ + components: z.string().array().optional(), + yes: z.boolean(), + all: z.boolean(), + overwrite: z.boolean(), + cwd: z.string(), + path: z.string().optional(), + deps: z.boolean(), + proxy: z.string().optional(), +}); + +type AddOptions = z.infer; + +export const add = new Command() + .command("add") + .description("add components to your project") + .argument("[components...]", "the components to add or a url to the component") + .option("-c, --cwd ", "the working directory", process.cwd()) + .option("--no-deps", "skips adding & installing package dependencies") + .option("-a, --all", "install all components to your project", false) + .option("-y, --yes", "skip confirmation prompt", false) + .option("-o, --overwrite", "overwrite existing files", false) + .option("--proxy ", "fetch components from registry using a proxy", getEnvProxy()) + .option("-p, --path ", "the path to add the component to") + .action(async (components, opts) => { + try { + intro(); + const options = addOptionsSchema.parse({ components, ...opts }); + + const cwd = path.resolve(options.cwd); + + if (!existsSync(cwd)) { + throw error(`The path ${color.cyan(cwd)} does not exist. Please try again.`); + } + + await preflightAdd(cwd); + + const config = await cliConfig.getConfig(cwd); + if (!config) { + throw new ConfigError( + `Configuration file is missing. Please run ${color.green("init")} to create a ${highlight("components.json")} file.` + ); + } + + await runAdd(cwd, config, options); + + p.outro(`${color.green("Success!")} Components added.`); + } catch (error) { + handleError(error); + } + }); + +async function runAdd(cwd: string, config: cliConfig.Config, options: AddOptions) { + if (options.proxy !== undefined) { + process.env.HTTP_PROXY = options.proxy; + p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); + } + + const registryUrl = registry.getRegistryUrl(config); + const shadcnIndex = await registry.getRegistryIndex(registryUrl); + const uiOnly = shadcnIndex.filter((f) => f.type === "registry:ui"); + + let selectedComponents = new Set( + options.all ? uiOnly.map(({ name }) => name) : options.components + ); + + // if the user hasn't passed any components prompt them to select components + if (selectedComponents.size === 0) { + const components = await p.multiselect({ + message: `Which ${highlight("components")} would you like to add?`, + maxItems: 10, + options: uiOnly.map((item) => { + let deps: string[] = [...(item.registryDependencies ?? [])]; + if (options.deps) { + deps = deps.concat(item.dependencies ?? []); + deps = deps.concat(item.devDependencies ?? []); + } + return { + label: item.name, + value: item.name, + hint: deps.length ? `also adds: ${deps.join(", ")}` : undefined, + }; + }), + }); + + if (p.isCancel(components)) cancel(); + selectedComponents = new Set(components); + } else { + const prettyList = prettifyList(Array.from(selectedComponents)); + p.log.step(`Components to install:\n${color.gray(prettyList)}`); + } + + if (options.yes === false) { + const proceed = await p.confirm({ + message: `Ready to install ${highlight("components")}${options.deps ? ` and ${highlight("dependencies")}?` : "?"}`, + initialValue: true, + }); + + if (p.isCancel(proceed) || proceed === false) cancel(); + } + + const tasks: p.Task[] = []; + + const result = await addRegistryItems({ + config, + deps: options.deps, + overwrite: options.overwrite, + selectedItems: Array.from(selectedComponents), + }); + + tasks.push(...result.tasks); + + const installTask = await installDependencies({ + cwd, + prompt: options.deps, + dependencies: Array.from(result.dependencies), + devDependencies: Array.from(result.devDependencies), + }); + if (installTask) tasks.push(installTask); + + await p.tasks(tasks); + + if (!options.deps) { + const prettyList = prettifyList([...result.skippedDeps], 7); + p.log.warn( + `Components have been installed ${color.bold.red("without")} the following ${highlight("dependencies")}:\n${color.gray(prettyList)}` + ); + } +} diff --git a/packages/cli/src/commands/add/preflight.ts b/packages/cli/src/commands/add/preflight.ts new file mode 100644 index 0000000000..bd73814aad --- /dev/null +++ b/packages/cli/src/commands/add/preflight.ts @@ -0,0 +1,73 @@ +import color from "chalk"; +import * as semver from "semver"; +import { loadProjectPackageInfo } from "../../utils/get-package-info.js"; +import { ConfigError, error } from "../../utils/errors.js"; +import * as cliConfig from "../../utils/get-config.js"; +import { TW3_SITE_BASE_URL } from "../../constants.js"; +import { highlight } from "../../utils/utils.js"; + +/** + * Runs preflight checks for the `add` command. + * `add` in this CLI version should work for both Tailwind CSS v3 and v4 and Svelte 5, + * but fail for Svelte 4. + * + * @param cwd - The current working directory. + */ +export async function preflightAdd(cwd: string) { + const pkg = loadProjectPackageInfo(cwd); + const config = await cliConfig.getConfig(cwd); + if (!config) { + throw new ConfigError( + `Configuration file is missing. Please run ${color.green("init")} to create a ${highlight("components.json")} file.` + ); + } + + const dependencies = { ...pkg.dependencies, ...pkg.devDependencies }; + + checkAddDependencies(dependencies, cwd, config); +} + +/** + * Checks the dependencies of the user's project to ensure that the project is compatible + * with the expected dependencies. + */ +function checkAddDependencies( + dependencies: Partial>, + cwd: string, + config: cliConfig.RawConfig +) { + if (!dependencies.tailwindcss || !dependencies.svelte) { + throw error(`This CLI version requires Tailwind CSS (v3 or v4) and Svelte v5.\n`); + } + + const isSvelte5 = semver.satisfies(semver.coerce(dependencies.svelte) || "", "^5.0.0"); + const isTailwind4 = semver.satisfies(semver.coerce(dependencies.tailwindcss) || "", "^4.0.0"); + const isTailwind3 = semver.satisfies(semver.coerce(dependencies.tailwindcss) || "", "^3.0.0"); + + // this is the happy path + if (isTailwind4 && isSvelte5) return; + + if (isTailwind3 && isSvelte5) { + // if no `style` field, then we can assume their components.json is already updated + if (!config.style) return; + + updateLegacyConfig(cwd, config); + return; + } + + // if incompatible, throw error + throw error(`This CLI version requires Tailwind CSS (v3 or v4) and Svelte v5.\n`); +} + +/** + * Updates a legacy config (Tailwind v3 / Svelte v5) to be compatible with the new CLI. + * It does so by updating the `registry` field to point to the Tailwind v3/Svelte v5 registry + * based on their `style` field and removes the `style` field before writing the config back. + */ +function updateLegacyConfig(cwd: string, config: cliConfig.RawConfig) { + // should we just write the config back here or prompt them to confirm? + cliConfig.writeConfig(cwd, { + ...config, + registry: `${TW3_SITE_BASE_URL}/registry/${config.style}`, + }); +} diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 37db0838c0..841cbae111 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,3 +1,4 @@ -export * from "./add.js"; -export * from "./init.js"; -export * from "./update.js"; +export { add } from "./add/index.js"; +export { init } from "./init/index.js"; +export { update } from "./update/index.js"; +export { registry } from "./registry/index.js"; diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts deleted file mode 100644 index 1d8db80fed..0000000000 --- a/packages/cli/src/commands/init.ts +++ /dev/null @@ -1,420 +0,0 @@ -import color from "chalk"; -import { Command, Option } from "commander"; -import { execa } from "execa"; -import { existsSync, promises as fs } from "node:fs"; -import path from "node:path"; -import process from "node:process"; -import * as v from "valibot"; -import { - type DetectLanguageResult, - detectConfigs, - detectLanguage, - detectPM, -} from "../utils/auto-detect.js"; -import { error, handleError } from "../utils/errors.js"; -import type { Config } from "../utils/get-config.js"; -import * as cliConfig from "../utils/get-config.js"; -import { cancel, intro, prettifyList } from "../utils/prompt-helpers.js"; -import * as p from "../utils/prompts.js"; -import * as registry from "../utils/registry/index.js"; -import { resolveImport } from "../utils/resolve-imports.js"; -import { syncSvelteKit } from "../utils/sveltekit.js"; -import * as templates from "../utils/templates.js"; -import { resolveCommand } from "package-manager-detector/commands"; -import { SITE_BASE_URL } from "../constants.js"; -import { checkPreconditions } from "../utils/preconditions.js"; - -const PROJECT_DEPENDENCIES = [ - "tailwind-variants", - "clsx", - "tailwind-merge", - "tailwindcss-animate", -] as const; -const highlight = (...args: unknown[]) => color.bold.cyan(...args); - -const baseColors = registry.getBaseColors(); -const styles = registry.getStyles(); - -const initOptionsSchema = v.object({ - cwd: v.string(), - style: v.optional(v.string()), - baseColor: v.optional(v.string()), - css: v.optional(v.string()), - tailwindConfig: v.optional(v.string()), - componentsAlias: v.optional(v.string()), - utilsAlias: v.optional(v.string()), - hooksAlias: v.optional(v.string()), - uiAlias: v.optional(v.string()), - deps: v.boolean(), -}); - -type InitOptions = v.InferOutput; - -export const init = new Command() - .command("init") - .description("initialize your project and install dependencies") - .option("-c, --cwd ", "the working directory", process.cwd()) - .option("--no-deps", "disable adding & installing dependencies") - .addOption( - new Option("--style ", "the style for the components").choices( - styles.map((style) => style.name) - ) - ) - .addOption( - new Option("--base-color ", "the base color for the components").choices( - baseColors.map((color) => color.name) - ) - ) - .option("--css ", "path to the global CSS file") - .option("--tailwind-config ", "path to the tailwind config file") - .option("--components-alias ", "import alias for components") - .option("--utils-alias ", "import alias for utils") - .option("--hooks-alias ", "import alias for hooks") - .option("--ui-alias ", "import alias for ui") - .action(async (opts) => { - intro(); - const options = v.parse(initOptionsSchema, opts); - const cwd = path.resolve(options.cwd); - - try { - // Ensure target directory exists. - if (!existsSync(cwd)) { - throw error(`The path ${color.cyan(cwd)} does not exist. Please try again.`); - } - - checkPreconditions(cwd); - - // Read config. - const existingConfig = await cliConfig.getConfig(cwd); - const config = await promptForConfig(cwd, existingConfig, options); - - await runInit(cwd, config, options); - - p.outro(`${color.green("Success!")} Project initialization completed.`); - } catch (e) { - handleError(e); - } - }); - -function validateOptions(cwd: string, options: InitOptions, langConfig: DetectLanguageResult) { - if (options.css) { - if (!existsSync(path.resolve(cwd, options.css))) { - throw error( - `The provided global CSS file path ${color.cyan(options.css)} does not exist. Please enter a valid path.` - ); - } - } - - if (options.tailwindConfig) { - if (!existsSync(path.resolve(cwd, options.tailwindConfig))) { - throw error( - `The provided tailwind config file path ${color.cyan(options.tailwindConfig)} does not exist. Please enter a valid path.` - ); - } - } - - if (options.componentsAlias) { - const validationResult = validateImportAlias(options.componentsAlias, langConfig); - if (validationResult) { - throw error(validationResult); - } - } - - if (options.utilsAlias) { - const validationResult = validateImportAlias(options.utilsAlias, langConfig); - if (validationResult) { - throw error(validationResult); - } - } -} - -async function promptForConfig(cwd: string, defaultConfig: Config | null, options: InitOptions) { - // if it's a SvelteKit project, run sync so that the aliases are always up to date - await syncSvelteKit(cwd); - - const detectedConfigs = detectConfigs(cwd, { relative: true }); - - const langConfig = detectLanguage(cwd); - if (langConfig === undefined) { - throw error( - `Failed to find a ${highlight("tsconfig.json")} or ${highlight("jsconfig.json")} file. See: ${color.underline(`${SITE_BASE_URL}/docs/installation#opt-out-of-typescript`)}` - ); - } - - // Validation for any paths provided by flags - validateOptions(cwd, options, langConfig); - - // Styles - let style = styles.find((style) => style.name === options.style)?.name; - if (style === undefined) { - const input = await p.select({ - message: `Which ${highlight("style")} would you like to use?`, - initialValue: defaultConfig?.style ?? cliConfig.DEFAULT_STYLE, - options: styles.map((style) => ({ - label: style.label, - value: style.name, - })), - }); - - if (p.isCancel(input)) cancel(); - - style = input; - } - - // Base Color - let tailwindBaseColor = baseColors.find((color) => color.name === options.baseColor)?.name; - if (tailwindBaseColor === undefined) { - const input = await p.select({ - message: `Which ${highlight("base color")} would you like to use?`, - initialValue: - defaultConfig?.tailwind.baseColor ?? cliConfig.DEFAULT_TAILWIND_BASE_COLOR, - options: baseColors.map((color) => ({ - label: color.label, - value: color.name, - })), - }); - - if (p.isCancel(input)) cancel(); - - tailwindBaseColor = input; - } - - // Global CSS File - let globalCss = options.css; - if (globalCss === undefined) { - const input = await p.text({ - message: `Where is your ${highlight("global CSS")} file? ${color.gray("(this file will be overwritten)")}`, - initialValue: - defaultConfig?.tailwind.css ?? - detectedConfigs.cssPath ?? - cliConfig.DEFAULT_TAILWIND_CSS, - placeholder: detectedConfigs.cssPath ?? cliConfig.DEFAULT_TAILWIND_CSS, - validate: (value) => { - if (value && existsSync(path.resolve(cwd, value))) { - return; - } - return `"${color.bold(value)}" does not exist. Please enter a valid path.`; - }, - }); - - if (p.isCancel(input)) cancel(); - - globalCss = input; - } - - // Tailwind Config - let tailwindConfig = options.tailwindConfig; - if (tailwindConfig === undefined) { - const input = await p.text({ - message: `Where is your ${highlight("Tailwind config")} located? ${color.gray("(this file will be overwritten)")}`, - initialValue: - defaultConfig?.tailwind.config ?? - detectedConfigs.tailwindPath ?? - cliConfig.DEFAULT_TAILWIND_CONFIG, - placeholder: detectedConfigs.tailwindPath ?? cliConfig.DEFAULT_TAILWIND_CONFIG, - validate: (value) => { - if (value && existsSync(path.resolve(cwd, value))) { - return; - } - return `"${color.bold(value)}" does not exist. Please enter a valid path.`; - }, - }); - - if (p.isCancel(input)) cancel(); - - tailwindConfig = input; - } - - // Components Alias - let componentAlias = options.componentsAlias; - if (componentAlias === undefined) { - const promptResult = await p.text({ - message: `Configure the import alias for ${highlight("components")}:`, - initialValue: defaultConfig?.aliases.components ?? cliConfig.DEFAULT_COMPONENTS, - placeholder: cliConfig.DEFAULT_COMPONENTS, - validate: (value) => validateImportAlias(value, langConfig), - }); - - if (p.isCancel(promptResult)) cancel(); - - componentAlias = promptResult; - } - - // infers the alias from `components`. if `components = $lib/components` then suggest `alias = $lib/alias` - const inferAlias = (alias: string) => - `${componentAlias.split("/").slice(0, -1).join("/")}/${alias}`; - - // Utils Alias - let utilsAlias = options.utilsAlias; - if (utilsAlias === undefined) { - const input = await p.text({ - message: `Configure the import alias for ${highlight("utils")}:`, - initialValue: defaultConfig?.aliases.utils ?? inferAlias("utils"), - placeholder: cliConfig.DEFAULT_UTILS, - validate: (value) => validateImportAlias(value, langConfig), - }); - - if (p.isCancel(input)) cancel(); - - utilsAlias = input; - } - - // Hooks Alias - let hooksAlias = options.hooksAlias; - if (hooksAlias === undefined) { - const input = await p.text({ - message: `Configure the import alias for ${highlight("hooks")}:`, - initialValue: defaultConfig?.aliases.hooks ?? inferAlias("hooks"), - placeholder: cliConfig.DEFAULT_HOOKS, - validate: (value) => validateImportAlias(value, langConfig), - }); - - if (p.isCancel(input)) cancel(); - - hooksAlias = input; - } - - // UI Alias - let uiAlias = options.uiAlias; - if (uiAlias === undefined) { - const input = await p.text({ - message: `Configure the import alias for ${highlight("ui")}:`, - initialValue: defaultConfig?.aliases.ui ?? `${componentAlias}/ui`, - placeholder: cliConfig.DEFAULT_UI, - validate: (value) => validateImportAlias(value, langConfig), - }); - - if (p.isCancel(input)) cancel(); - - uiAlias = input; - } - - const config = v.parse(cliConfig.rawConfigSchema, { - $schema: `${SITE_BASE_URL}/schema.json`, - style, - typescript: langConfig.type === "tsconfig.json", - registry: defaultConfig?.registry, - tailwind: { - config: tailwindConfig, - css: globalCss, - baseColor: tailwindBaseColor, - }, - aliases: { - utils: utilsAlias, - components: componentAlias, - hooks: hooksAlias, - ui: uiAlias, - }, - }); - - // Delete `tailwind.config.cjs` and rename to `.js` - if (config.tailwind.config.endsWith(".cjs")) { - p.log.info(`Your tailwind config has been renamed to ${highlight("tailwind.config.js")}.`); - await fs.unlink(config.tailwind.config).catch(() => null); - const renamedTailwindConfigPath = config.tailwind.config.replace(".cjs", ".js"); - config.tailwind.config = renamedTailwindConfigPath; - } - - const configPaths = await cliConfig.resolveConfigPaths(cwd, config); - return configPaths; -} - -function validateImportAlias(alias: string, langConfig: DetectLanguageResult) { - const resolvedPath = resolveImport(alias, langConfig.config); - if (resolvedPath !== undefined) { - return; - } - return `"${color.bold(alias)}" does not use an existing path alias defined in your ${color.bold(langConfig.type)}. See: ${color.underline(`${SITE_BASE_URL}/docs/installation/manual#configure-path-aliases`)}`; -} - -export async function runInit(cwd: string, config: Config, options: InitOptions) { - const tasks: p.Task[] = []; - - // Write to file. - tasks.push({ - title: "Creating config file", - async task() { - cliConfig.writeConfig(cwd, config); - return `Config file ${highlight("components.json")} created`; - }, - }); - - // Initialize project - tasks.push({ - title: "Initializing project", - async task() { - // Ensure all resolved paths directories exist. - for (const [key, resolvedPath] of Object.entries(config.resolvedPaths)) { - // Determine if the path is a file or directory. - let dirname = path.extname(resolvedPath) - ? path.dirname(resolvedPath) - : resolvedPath; - - // If the utils alias is set to something like "@/lib/utils", - // assume this is a file and remove the "utils" file name. - // TODO: In future releases we should add support for individual utils. - if (key === "utils" && resolvedPath.endsWith("/utils")) { - // Remove /utils at the end. - dirname = dirname.replace(/\/utils$/, ""); - } - - if (!existsSync(dirname) && key !== "utils") { - await fs.mkdir(dirname, { recursive: true }); - } - } - - // Write tailwind config. - const { TS, JS } = templates.TAILWIND_CONFIG_WITH_VARIABLES; - const tailwindConfigContent = config.resolvedPaths.tailwindConfig.endsWith(".ts") - ? TS - : JS; - await fs.writeFile(config.resolvedPaths.tailwindConfig, tailwindConfigContent, "utf8"); - - // Write css file. - const baseColor = await registry.getRegistryBaseColor( - config.tailwind.baseColor, - config.style - ); - if (baseColor) { - await fs.writeFile( - config.resolvedPaths.tailwindCss, - baseColor.cssVarsTemplate, - "utf8" - ); - } - - const utilsPath = config.resolvedPaths.utils + (config.typescript ? ".ts" : ".js"); - const utilsTemplate = config.typescript ? templates.UTILS : templates.UTILS_JS; - // Write cn file. - await fs.writeFile(utilsPath, utilsTemplate, "utf8"); - - return "Project initialized"; - }, - }); - - // Install dependencies. - const pm = await detectPM(cwd, options.deps); - if (pm) { - const add = resolveCommand(pm, "add", ["-D", ...PROJECT_DEPENDENCIES]); - if (!add) throw error(`Could not detect a package manager in ${cwd}.`); - tasks.push({ - title: `${highlight(pm)}: Installing dependencies`, - enabled: options.deps, - async task() { - await execa(add.command, [...add.args], { - cwd, - }); - return `Dependencies installed with ${highlight(pm)}`; - }, - }); - } - - await p.tasks(tasks); - - if (!options.deps) { - const prettyList = prettifyList([...PROJECT_DEPENDENCIES], 7); - p.log.warn( - `shadcn-svelte has been initialized ${color.bold.red("without")} the following ${highlight("dependencies")}:\n${color.gray(prettyList)}` - ); - } -} diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts new file mode 100644 index 0000000000..16cc4f81fd --- /dev/null +++ b/packages/cli/src/commands/init/index.ts @@ -0,0 +1,380 @@ +import color from "chalk"; +import { Command, Option } from "commander"; +import { existsSync, promises as fs } from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { z } from "zod/v4"; +import * as p from "@clack/prompts"; +import { + type DetectLanguageResult, + detectConfigs, + detectLanguage, +} from "../../utils/auto-detect.js"; +import { error, handleError } from "../../utils/errors.js"; +import type { Config } from "../../utils/get-config.js"; +import * as cliConfig from "../../utils/get-config.js"; +import { cancel, intro, prettifyList } from "../../utils/prompt-helpers.js"; +import * as registry from "../../utils/registry/index.js"; +import { resolveImport } from "../../utils/resolve-imports.js"; +import { syncSvelteKit } from "../../utils/sveltekit.js"; +import { SITE_BASE_URL } from "../../constants.js"; +import { preflightInit } from "./preflight.js"; +import { addRegistryItems } from "../../utils/add-registry-items.js"; +import { getEnvProxy } from "../../utils/get-env-proxy.js"; +import { highlight } from "../../utils/utils.js"; +import { installDependencies } from "../../utils/install-deps.js"; + +const baseColors = registry.getBaseColors(); + +const initOptionsSchema = z.object({ + cwd: z.string(), + baseColor: z.string().optional(), + css: z.string().optional(), + componentsAlias: z.string().optional(), + utilsAlias: z.string().optional(), + libAlias: z.string().optional(), + hooksAlias: z.string().optional(), + uiAlias: z.string().optional(), + deps: z.boolean(), + overwrite: z.boolean(), + proxy: z.string().optional(), +}); + +type InitOptions = z.infer; + +export const init = new Command() + .command("init") + .description("initialize your project and install dependencies") + .option("-c, --cwd ", "the working directory", process.cwd()) + .option("-o, --overwrite", "overwrite existing files", false) + .option("--no-deps", "disable adding & installing dependencies") + .addOption( + new Option("--base-color ", "the base color for the components").choices( + baseColors.map((color) => color.name) + ) + ) + .option("--css ", "path to the global CSS file") + .option("--components-alias ", "import alias for components") + .option("--lib-alias ", "import alias for lib") + .option("--utils-alias ", "import alias for utils") + .option("--hooks-alias ", "import alias for hooks") + .option("--ui-alias ", "import alias for ui") + .option("--proxy ", "fetch items from registry using a proxy", getEnvProxy()) + .action(async (opts) => { + intro(); + const options = initOptionsSchema.parse(opts); + const cwd = path.resolve(options.cwd); + + try { + // Ensure target directory exists. + if (!existsSync(cwd)) { + throw error(`The path ${color.cyan(cwd)} does not exist. Please try again.`); + } + + preflightInit(cwd); + + // Read config. + const existingConfig = await cliConfig.getConfig(cwd); + const config = await promptForConfig(cwd, existingConfig, options); + + await runInit(cwd, config, options); + + p.outro(`${color.green("Success!")} Project initialization completed.`); + } catch (e) { + handleError(e); + } + }); + +function validateOptions(cwd: string, options: InitOptions, langConfig: DetectLanguageResult) { + if (options.css) { + if (!existsSync(path.resolve(cwd, options.css))) { + throw error( + `The provided global CSS file path ${color.cyan(options.css)} does not exist. Please enter a valid path.` + ); + } + } + + if (options.libAlias) { + const validationResult = validateImportAlias(options.libAlias, langConfig); + if (validationResult) { + throw error(validationResult); + } + } + + if (options.componentsAlias) { + const validationResult = validateImportAlias(options.componentsAlias, langConfig); + if (validationResult) { + throw error(validationResult); + } + } + + if (options.uiAlias) { + const validationResult = validateImportAlias(options.uiAlias, langConfig); + if (validationResult) { + throw error(validationResult); + } + } + + if (options.utilsAlias) { + const validationResult = validateImportAlias(options.utilsAlias, langConfig); + if (validationResult) { + throw error(validationResult); + } + } + + if (options.hooksAlias) { + const validationResult = validateImportAlias(options.hooksAlias, langConfig); + if (validationResult) { + throw error(validationResult); + } + } +} + +async function promptForConfig(cwd: string, defaultConfig: Config | null, options: InitOptions) { + // if it's a SvelteKit project, run sync so that the aliases are always up to date + await syncSvelteKit(cwd); + + const detectedConfigs = detectConfigs(cwd, { relative: true }); + + const langConfig = detectLanguage(cwd); + if (langConfig === undefined) { + throw error( + `Failed to find a ${highlight("tsconfig.json")} or ${highlight("jsconfig.json")} file. See: ${color.underline(`${SITE_BASE_URL}/docs/installation#opt-out-of-typescript`)}` + ); + } + + // Validation for any paths provided by flags + validateOptions(cwd, options, langConfig); + + // Base Color + let tailwindBaseColor = baseColors.find((color) => color.name === options.baseColor)?.name; + if (tailwindBaseColor === undefined) { + const input = await p.select({ + message: `Which ${highlight("base color")} would you like to use?`, + initialValue: + defaultConfig?.tailwind.baseColor ?? cliConfig.DEFAULT_TAILWIND_BASE_COLOR, + options: baseColors.map((color) => ({ + label: color.label, + value: color.name, + })), + }); + + if (p.isCancel(input)) cancel(); + + tailwindBaseColor = input; + } + + // Global CSS File + let globalCss = options.css; + if (globalCss === undefined) { + const input = await p.text({ + message: `Where is your ${highlight("global CSS")} file? ${color.gray("(this file will be overwritten)")}`, + initialValue: + defaultConfig?.tailwind.css ?? + detectedConfigs.cssPath ?? + cliConfig.DEFAULT_TAILWIND_CSS, + placeholder: detectedConfigs.cssPath ?? cliConfig.DEFAULT_TAILWIND_CSS, + validate: (value) => { + if (value && existsSync(path.resolve(cwd, value))) { + return; + } + return `"${color.bold(value)}" does not exist. Please enter a valid path.`; + }, + }); + + if (p.isCancel(input)) cancel(); + + globalCss = input; + } + + // Lib Alias + let libAlias = options.libAlias; + if (libAlias === undefined) { + const input = await p.text({ + message: `Configure the import alias for ${highlight("lib")}:`, + initialValue: defaultConfig?.aliases.lib ?? "$lib", + placeholder: cliConfig.DEFAULT_LIB, + validate: (value) => validateImportAlias(value, langConfig), + }); + + if (p.isCancel(input)) cancel(); + + libAlias = input; + } + + // infers the alias from `lib`. if `lib = $lib` then suggest `alias = $lib/alias` + const inferAlias = (alias: string) => `${libAlias}/${alias}`; + + // Components Alias + let componentAlias = options.componentsAlias; + if (componentAlias === undefined) { + const promptResult = await p.text({ + message: `Configure the import alias for ${highlight("components")}:`, + initialValue: defaultConfig?.aliases.components ?? inferAlias("components"), + placeholder: cliConfig.DEFAULT_COMPONENTS, + validate: (value) => validateImportAlias(value, langConfig), + }); + + if (p.isCancel(promptResult)) cancel(); + + componentAlias = promptResult; + } + + // UI Alias + let uiAlias = options.uiAlias; + if (uiAlias === undefined) { + const input = await p.text({ + message: `Configure the import alias for ${highlight("ui")}:`, + initialValue: defaultConfig?.aliases.ui ?? `${componentAlias}/ui`, + placeholder: cliConfig.DEFAULT_UI, + validate: (value) => validateImportAlias(value, langConfig), + }); + + if (p.isCancel(input)) cancel(); + + uiAlias = input; + } + + // Utils Alias + let utilsAlias = options.utilsAlias; + if (utilsAlias === undefined) { + const input = await p.text({ + message: `Configure the import alias for ${highlight("utils")}:`, + initialValue: defaultConfig?.aliases.utils ?? inferAlias("utils"), + placeholder: cliConfig.DEFAULT_UTILS, + validate: (value) => validateImportAlias(value, langConfig), + }); + + if (p.isCancel(input)) cancel(); + + utilsAlias = input; + } + + // Hooks Alias + let hooksAlias = options.hooksAlias; + if (hooksAlias === undefined) { + const input = await p.text({ + message: `Configure the import alias for ${highlight("hooks")}:`, + initialValue: defaultConfig?.aliases.hooks ?? inferAlias("hooks"), + placeholder: cliConfig.DEFAULT_HOOKS, + validate: (value) => validateImportAlias(value, langConfig), + }); + + if (p.isCancel(input)) cancel(); + + hooksAlias = input; + } + + const config = cliConfig.rawConfigSchema.parse({ + $schema: `${SITE_BASE_URL}/schema.json`, + typescript: langConfig.type === "tsconfig.json", + registry: defaultConfig?.registry, + tailwind: { + css: globalCss, + baseColor: tailwindBaseColor, + }, + aliases: { + utils: utilsAlias, + lib: libAlias, + components: componentAlias, + hooks: hooksAlias, + ui: uiAlias, + }, + }); + + const configPaths = await cliConfig.resolveConfigPaths(cwd, config); + return configPaths; +} + +function validateImportAlias(alias: string, langConfig: DetectLanguageResult) { + const resolvedPath = resolveImport(alias, langConfig.config); + if (resolvedPath !== undefined) { + return; + } + return `"${color.bold(alias)}" does not use an existing path alias defined in your ${color.bold(langConfig.type)}. See: ${color.underline(`${SITE_BASE_URL}/docs/installation/manual#configure-path-aliases`)}`; +} + +export async function runInit(cwd: string, config: Config, options: InitOptions) { + if (options.proxy !== undefined) { + process.env.HTTP_PROXY = options.proxy; + p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); + } + const registryUrl = registry.getRegistryUrl(config); + + const tasks: p.Task[] = []; + + // Write to file. + tasks.push({ + title: "Creating config file", + async task() { + cliConfig.writeConfig(cwd, config); + return `Config file ${highlight("components.json")} created`; + }, + }); + + tasks.push({ + title: "Validating alias paths", + async task() { + // Ensure all resolved paths directories exist. + for (const [key, resolvedPath] of Object.entries(config.resolvedPaths)) { + // Determine if the path is a file or directory. + let dirname = path.extname(resolvedPath) + ? path.dirname(resolvedPath) + : resolvedPath; + + // If the utils alias is set to something like "@/lib/utils", + // assume this is a file and remove the "utils" file name. + // TODO: In future releases we should add support for individual utils. + if (key === "utils" && resolvedPath.endsWith("/utils")) { + // Remove /utils at the end. + dirname = dirname.replace(/\/utils$/, ""); + } + + if (!existsSync(dirname) && key !== "utils") { + await fs.mkdir(dirname, { recursive: true }); + } + } + return `Alias paths validated`; + }, + }); + + // update stylesheet + tasks.push({ + title: "Updating stylesheet", + async task() { + const baseColor = await registry.getRegistryBaseColor( + registryUrl, + config.tailwind.baseColor + ); + const relative = path.relative(cwd, config.resolvedPaths.tailwindCss); + await fs.writeFile(config.resolvedPaths.tailwindCss, baseColor.cssVarsTemplate, "utf8"); + return `${highlight("Stylesheet")} updated at ${color.dim(relative)}`; + }, + }); + + const result = await addRegistryItems({ + selectedItems: ["init"], + config, + deps: options.deps, + overwrite: options.overwrite, + }); + + tasks.push(...result.tasks); + + const installTask = await installDependencies({ + cwd, + prompt: options.deps, + dependencies: Array.from(result.dependencies), + devDependencies: Array.from(result.devDependencies), + }); + if (installTask) tasks.push(installTask); + + await p.tasks(tasks); + + if (!options.deps) { + const prettyList = prettifyList([...result.skippedDeps], 7); + p.log.warn( + `shadcn-svelte has been initialized ${color.bold.red("without")} the following ${highlight("dependencies")}:\n${color.gray(prettyList)}` + ); + } +} diff --git a/packages/cli/src/commands/init/preflight.ts b/packages/cli/src/commands/init/preflight.ts new file mode 100644 index 0000000000..055a4274de --- /dev/null +++ b/packages/cli/src/commands/init/preflight.ts @@ -0,0 +1,73 @@ +import { TW3_SITE_BASE_URL, SITE_BASE_URL } from "../../constants.js"; +import * as semver from "semver"; +import { loadProjectPackageInfo } from "../../utils/get-package-info.js"; +import { error } from "../../utils/errors.js"; +import color from "chalk"; +import { highlight } from "../../utils/utils.js"; + +/** + * Runs preflight checks for the `init` command. + * `init` in this CLI version should only run if the user has a project that + * is using Tailwind CSS v4 and Svelte v5. + * + * If the user is using Tailwind CSS v3 and/or Svelte v4, we need to let them + * know that they need to upgrade their project in order to use this CLI. + * + * @param cwd - The current working directory. + */ +export function preflightInit(cwd: string) { + const pkg = loadProjectPackageInfo(cwd); + + const dependencies = { ...pkg.dependencies, ...pkg.devDependencies }; + + checkInitDependencies(dependencies); +} + +function checkInitDependencies(dependencies: Partial>) { + if (!dependencies.tailwindcss || !dependencies.svelte) { + throw error( + `This CLI version requires Tailwind CSS v4 and Svelte v5 to initialize a project.\n` + ); + } + + const isTailwind3 = semver.satisfies(semver.coerce(dependencies.tailwindcss) || "", "^3.0.0"); + const isTailwind4 = semver.satisfies(semver.coerce(dependencies.tailwindcss) || "", "^4.0.0"); + const isSvelte4 = semver.satisfies(semver.coerce(dependencies.svelte) || "", "^4.0.0"); + const isSvelte5 = semver.satisfies(semver.coerce(dependencies.svelte) || "", "^5.0.0"); + + // if running Tailwind v3 and Svelte v5, we throw an error with a helpful message because + // `init` is only supported for Tailwind v4 and Svelte v5 + if (isTailwind3 && isSvelte5) { + throw error( + `Initializing a project with Tailwind v3 is not supported.\n\n` + + `This CLI version requires Tailwind v4 and Svelte v5 for the ` + + `${highlight("init")} command.\n\n` + + `You have two options:\n` + + `1. Update Tailwind CSS to v4 and try again.\n` + + `2. Use ${highlight("shadcn-svelte@1.0.0-next.10")} that supports initializing projects with Tailwind v3.\n\n` + + `References:\n` + + `Tailwind v4 Guide: ${color.underline(`${SITE_BASE_URL}/docs/migration/tailwind-v4`)}\n` + + `Legacy Tailwind v3 Docs: ${color.underline(`${TW3_SITE_BASE_URL}/docs`)}\n\n` + ); + } + + // if running Tailwind v3 and Svelte v4, we throw a different message. This will be useful when + // we move this branch into `main` to point people in the right direction. + // TODO: add link to upgrade guide? + if (isTailwind3 && isSvelte4) { + throw error( + `Initializing a project with Tailwind v3 and Svelte v4 is not supported.\n\n` + + `This CLI version requires Tailwind v4 and Svelte v5 for the ` + + `${highlight("init")} command.\n\n` + + `Please use ${highlight("shadcn-svelte@0.14.2")} that supports Tailwind v3 + Svelte v4.\n\n` + ); + } + + // if not Tailwind v4 and Svelte v5 by this point, they are using Tailwind v4 and Svelte v4 + // which is kinda cursed + if (!isTailwind4 || !isSvelte5) { + throw error( + `This CLI version requires Tailwind CSS v4 and Svelte v5 to initialize a project.\n` + ); + } +} diff --git a/packages/cli/src/commands/registry/build.ts b/packages/cli/src/commands/registry/build.ts new file mode 100644 index 0000000000..13cf11cf80 --- /dev/null +++ b/packages/cli/src/commands/registry/build.ts @@ -0,0 +1,235 @@ +import path from "node:path"; +import process from "node:process"; +import { existsSync, promises as fs } from "node:fs"; +import color from "chalk"; +import { z } from "zod/v4"; +import { Command } from "commander"; +import * as schema from "@shadcn-svelte/registry"; +import * as p from "@clack/prompts"; +import { intro } from "../../utils/prompt-helpers.js"; +import { error, handleError } from "../../utils/errors.js"; +import { parseDependency, toArray } from "../../utils/utils.js"; +import { ALIAS_DEFAULTS, ALIASES, SITE_BASE_URL } from "../../constants.js"; +import { getFileDependencies, resolveProjectDeps } from "./deps-resolver.js"; + +// TODO: perhaps a `--mini` flag to remove spacing? +const SPACER = "\t"; + +const buildOptionsSchema = z.object({ + registry: z.string(), + cwd: z.string(), + output: z.string(), +}); + +type BuildOptions = z.infer; + +export const build = new Command() + .command("build") + .description("build components for a shadcn-svelte registry") + .argument("[registry]", "path to registry.json file", "./registry.json") + .option("-c, --cwd ", "the working directory", process.cwd()) + .option("-o, --output ", "destination directory for json files", "./static/r") + .action(async (registryPath, opts) => { + try { + intro(); + const options = buildOptionsSchema.parse({ registry: registryPath, ...opts }); + + // resolve paths + const cwd = path.resolve(options.cwd); + const output = path.resolve(options.cwd, options.output); + const registry = path.resolve(options.cwd, options.registry); + + // validate options + for (const [option, path] of Object.entries({ cwd, registry })) { + if (!existsSync(path)) { + throw error(`The '${option}' path ${color.cyan(path)} does not exist.`); + } + } + + await runBuild({ cwd, output, registry }); + + p.outro(`${color.green("Success!")} Registry build completed.`); + } catch (error) { + handleError(error); + } + }); + +async function runBuild(options: BuildOptions) { + const spinner = p.spinner(); + + spinner.start(`Parsing registry schema`); + const registryJson = await fs.readFile(options.registry, "utf8"); + const registry = schema.registrySchema.parse(JSON.parse(registryJson)); + spinner.stop( + `Parsed registry schema at ${color.dim(path.relative(options.cwd, options.registry))}` + ); + + const registryIndex: schema.RegistryIndex = registry.items.map((item) => { + return { ...item, relativeUrl: `${item.name}.json` }; + }); + + // write to output + if (!existsSync(options.output)) { + await fs.mkdir(options.output, { recursive: true }); + } + + const tasks: p.Task[] = []; + + // Write registry index: `registry/index.json` + tasks.push({ + title: "Building registry index", + async task() { + const indexPath = path.resolve(options.output, "index.json"); + const parsedIndex = schema.registryIndexSchema.parse(registryIndex); + + await fs.writeFile(indexPath, JSON.stringify(parsedIndex, null, SPACER), "utf8"); + + const relative = path.relative(options.cwd, indexPath); + return `Registry index written to ${color.dim(relative)}`; + }, + }); + + // Write registry items: `registry/[item].json` + tasks.push({ + title: "Building registry items", + async task(message) { + const projectDeps = resolveProjectDeps(options.cwd); + + // apply overrides + if (registry.overrideDependencies) { + type Dependencies = (typeof projectDeps)["dependencies"]; + const overrideDep = (override: string, deps: Dependencies) => { + const { name } = parseDependency(override); + const versioned = deps.versions[name]; + if (versioned) { + const peers = deps.deps[versioned]; + delete deps.deps[versioned]; + + deps.versions[name] = override; + deps.deps[override] = peers ?? []; + } + }; + for (const override of registry.overrideDependencies) { + overrideDep(override, projectDeps.dependencies); + overrideDep(override, projectDeps.devDependencies); + } + } + + for (const item of registry.items) { + message(`Building item ${color.cyan(item.name)}`); + const singleFile = item.files.length === 1; + const nested: schema.RegistryItemFileType[] = [ + "registry:page", + "registry:ui", + "registry:file", + ]; + const toResolve = item.files.map(async (file) => { + let content = await fs.readFile(file.path, "utf8"); + content = transformAliases((registry.aliases ??= {}), content); + + const name = path.basename(file.path); + + let target; + if (singleFile) target = name; + else if (nested.includes(file.type)) target = `${item.name}/${name}`; + else target = name; + + return { content, name, target, ...file }; + }); + const files = await Promise.all(toResolve); + + const dependencies = new Set(item.dependencies); + const devDependencies = new Set(item.devDependencies); + + const registryDependencies = new Set(item.registryDependencies.map(transformLocal)); + + const predefinedDeps = dependencies.size > 0 && devDependencies.size > 0; + if (!predefinedDeps) { + for (const file of files) { + const fileDeps = await getFileDependencies({ + ...projectDeps, + filename: file.name, + source: file.content, + }); + + // don't add detected deps if they're already predefined + if (!item.dependencies) + fileDeps.dependencies?.forEach((dep) => { + // type def packages should be inserted into dev deps + if (dep.startsWith("@types/")) devDependencies.add(dep); + else dependencies.add(dep); + }); + if (!item.devDependencies) + fileDeps.devDependencies?.forEach((dep) => devDependencies.add(dep)); + } + } + + const parsedItem = schema.registryItemSchema.parse( + { + ...item, + $schema: `${SITE_BASE_URL}/schema/registry-item.json`, + registryDependencies: toArray(registryDependencies), + dependencies: toArray(dependencies), + devDependencies: toArray(devDependencies), + files, + }, + // maintains the schema defined property order + { jitless: true } + ); + + const outputPath = path.resolve(options.output, `${item.name}.json`); + + await fs.writeFile(outputPath, JSON.stringify(parsedItem, null, SPACER), "utf8"); + } + + const relative = path.relative(options.cwd, options.output); + return `Registry items written to ${color.dim(relative)}`; + }, + }); + + await p.tasks(tasks); +} + +/** + * Transforms registryDependencies that start with `local:` into a path + * relative to the current registry-item's json file. + * + * ``` + * "local:stepper" + *``` + * transforms into: + * ``` + * "./stepper.json" + * ``` + */ +export function transformLocal(registryDep: string) { + if (registryDep.startsWith("local:")) { + const LOCAL_REGEX = /^local:(.*)/; + return registryDep.replace(LOCAL_REGEX, "./$1.json"); + } + return registryDep; +} + +/** + * Transforms registry import aliases into a standardized format. + * + * ``` + * import Button from "$lib/registry/ui/button/index.js" + * ``` + * transforms into: + * ``` + * import Button from "$UI$/button/index.js" + * ``` + */ +export function transformAliases( + aliases: NonNullable, + content: string +) { + for (const alias of ALIASES) { + const defaults = ALIAS_DEFAULTS[alias]; + const path = (aliases[alias] ??= defaults.defaultPath); + content = content.replaceAll(path, defaults.placeholder); + } + + return content; +} diff --git a/packages/cli/src/commands/registry/deps-resolver.ts b/packages/cli/src/commands/registry/deps-resolver.ts new file mode 100644 index 0000000000..059bc8f587 --- /dev/null +++ b/packages/cli/src/commands/registry/deps-resolver.ts @@ -0,0 +1,201 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import * as acorn from "acorn"; +import * as svelte from "svelte/compiler"; +import { walk, type Node } from "estree-walker"; +import { tsPlugin } from "@sveltejs/acorn-typescript"; +import type { PackageJson } from "type-fest"; +import { toArray } from "../../utils/utils.js"; +import { loadProjectPackageInfo } from "../../utils/get-package-info.js"; + +export type ResolvedDependencies = { + /** `` */ + deps: Record; + /** `` */ + versions: Record; +}; + +export type ProjectDependencies = { + dependencies: ResolvedDependencies; + devDependencies: ResolvedDependencies; +}; + +const tsParser = acorn.Parser.extend(tsPlugin()); + +export function resolveProjectDeps(cwd: string): ProjectDependencies { + const pkg = loadProjectPackageInfo(cwd); + + // Record + const dependencies = resolvePeerDeps(pkg.dependencies, cwd); + const devDependencies = resolvePeerDeps(pkg.devDependencies, cwd); + + let projectDeps = resolveTypeDeps({ dependencies, devDependencies }); + projectDeps = resolvePeerVersions(projectDeps); + + return projectDeps; +} + +/** + * Adds a dependency's type definition package to their respective peer list (if applicable). + */ +export function resolveTypeDeps(projectDeps: ProjectDependencies) { + for (const dependencies of Object.values(projectDeps)) { + for (const [name, versioned] of Object.entries(dependencies.versions)) { + const peers = dependencies.deps[versioned]!; + // transforms orgs into the proper types package name (e.g. `@org/pkg-name` => `@types/org__pkg-name`) + const typesName = `@types/${name.replace(/^@(.*)\/(.*)/, "$1__$2")}`; + const typesVersion = + projectDeps.dependencies.versions[typesName] ?? + projectDeps.devDependencies.versions[typesName]; + + // if the types package exists, we'll add it to the peers + if (typesVersion) { + peers.push(typesName); + } + } + } + + return projectDeps; +} + +/** + * Applies version tags to the peer dependencies in their respective lists. + * + * `dependencies.deps` goes from `` to `` + */ +export function resolvePeerVersions(projectDeps: ProjectDependencies): ProjectDependencies { + for (const dependencies of Object.values(projectDeps)) { + for (const [name, peers] of Object.entries(dependencies.deps)) { + dependencies.deps[name] = peers + .map( + (peer) => + projectDeps.dependencies.versions[peer] || + projectDeps.devDependencies.versions[peer] + ) + .filter((peer) => peer !== undefined); + } + } + + return projectDeps; +} + +export const IGNORE_DEPS = ["svelte", "@sveltejs/kit", "tailwindcss", "vite"]; + +/** + * Resolves peer dependencies from a given set of dependencies from a package.json. + * + * Optional peer dependencies are ignored. + */ +function resolvePeerDeps( + dependencies: PackageJson["dependencies"], + cwd: string +): ResolvedDependencies { + const deps: ResolvedDependencies["deps"] = {}; + const versions: ResolvedDependencies["versions"] = {}; + const require = createRequire(path.resolve(cwd, "noop.js")); + + for (const [name, version] of Object.entries(dependencies ?? {})) { + let pkgPath: string | undefined; + + const versioned = version ? `${name}@${version}` : name; + const peers = (deps[versioned] ??= []); + + versions[name] = versioned; + + const paths = require.resolve.paths(name); + if (!paths) continue; + + for (const nodeModulesPath of paths) { + const check = path.join(nodeModulesPath, name, "package.json"); + if (fs.existsSync(check)) { + pkgPath = check; + break; + } + } + if (!pkgPath) continue; + + const json = fs.readFileSync(pkgPath, "utf8"); + const { peerDependencies = {}, peerDependenciesMeta = {} }: PackageJson = JSON.parse(json); + + for (const [peerName] of Object.entries(peerDependencies)) { + // ignores certain peer deps and optional peer deps + if (IGNORE_DEPS.includes(peerName) || peerDependenciesMeta[peerName]?.optional) + continue; + peers.push(peerName); + } + } + return { deps, versions }; +} + +type GetFileDepOpts = { + filename: string; + source: string; + dependencies: ReturnType; + devDependencies: ReturnType; +}; +export async function getFileDependencies(opts: GetFileDepOpts) { + const { filename, source } = opts; + let ast: unknown; + let moduleAst: unknown; + + if (filename.endsWith(".svelte")) { + const { code } = await svelte.preprocess(source, [], { filename }); + const result = svelte.parse(code, { filename }); + ast = result.instance; + if (result.module) { + moduleAst = result.module; + } + } else if (filename.endsWith(".ts") || filename.endsWith(".js")) { + ast = tsParser.parse(source, { ecmaVersion: "latest", sourceType: "module" }); + } else { + // unknown file (e.g. `.env` or some config file) + return {}; + } + + const dependencies = new Set(); + const devDependencies = new Set(); + + const enter = (node: Node) => { + if (node.type !== "ImportDeclaration") return; + const source = node.source.value as string; + + const deps = resolveDepsFromImport(source, opts.dependencies); + deps.forEach((dep) => dependencies.add(dep)); + + const devDeps = resolveDepsFromImport(source, opts.devDependencies); + devDeps.forEach((dep) => devDependencies.add(dep)); + }; + + // @ts-expect-error yea, stfu + walk(ast, { enter }); + + if (moduleAst) { + // @ts-expect-error yea, stfu x2 + walk(moduleAst, { enter }); + } + + return { + dependencies: toArray(dependencies), + devDependencies: toArray(devDependencies), + }; +} + +/** Returns an array of found deps. */ +export function resolveDepsFromImport(source: string, dependencies: ResolvedDependencies) { + const depsFound: string[] = []; + const simple = dependencies.versions[source] ? source : undefined; + const depName = + simple ?? + // considers deep imports + Object.keys(dependencies.versions).find((dep) => source.startsWith(dep)); + + if (depName && !IGNORE_DEPS.includes(depName)) { + const versioned = dependencies.versions[depName]!; + const peers = dependencies.deps[versioned]; + depsFound.push(versioned); + peers?.forEach((dep) => depsFound.push(dep)); + } + + return depsFound; +} diff --git a/packages/cli/src/commands/registry/index.ts b/packages/cli/src/commands/registry/index.ts new file mode 100644 index 0000000000..9c8af12cb7 --- /dev/null +++ b/packages/cli/src/commands/registry/index.ts @@ -0,0 +1,4 @@ +import { Command } from "commander"; +import { build } from "./build.js"; + +export const registry = new Command().command("registry").addCommand(build); diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts deleted file mode 100644 index c9875b3f14..0000000000 --- a/packages/cli/src/commands/update.ts +++ /dev/null @@ -1,16 +0,0 @@ -import color from "chalk"; -import { Command } from "commander"; -import { handleError } from "../utils/errors.js"; -import { intro } from "../utils/prompt-helpers.js"; - -const highlight = (msg: string) => color.bold.cyan(msg); - -export const update = new Command() - .command("update", { hidden: true }) - .description("update components to your project") - .action(() => { - intro(); - handleError( - `The ${highlight("update")} command is no longer available in this version of the CLI.\n\nUse ${highlight("npx shadcn-svelte@latest update")} instead to access this command.` - ); - }); diff --git a/packages/cli/src/commands/update/index.ts b/packages/cli/src/commands/update/index.ts new file mode 100644 index 0000000000..a5ab628ca7 --- /dev/null +++ b/packages/cli/src/commands/update/index.ts @@ -0,0 +1,273 @@ +import path from "node:path"; +import process from "node:process"; +import { existsSync, promises as fs } from "node:fs"; +import color from "chalk"; +import { z } from "zod/v4"; +import merge from "deepmerge"; +import { Command } from "commander"; +import { error, handleError } from "../../utils/errors.js"; +import * as cliConfig from "../../utils/get-config.js"; +import { getEnvProxy } from "../../utils/get-env-proxy.js"; +import { cancel, intro, prettifyList } from "../../utils/prompt-helpers.js"; +import * as p from "@clack/prompts"; +import * as registry from "../../utils/registry/index.js"; +import { transformContent, transformCss } from "../../utils/transformers.js"; +import { checkPreconditions } from "../../utils/preconditions.js"; +import { highlight } from "../../utils/utils.js"; +import { installDependencies } from "../../utils/install-deps.js"; + +const updateOptionsSchema = z.object({ + all: z.boolean(), + components: z.string().array().optional(), + cwd: z.string(), + proxy: z.string().optional(), + yes: z.boolean(), +}); + +type UpdateOptions = z.infer; + +export const update = new Command() + .command("update", { hidden: true }) + .description("update components in your project") + .argument("[components...]", "name of components") + .option("-c, --cwd ", "the working directory", process.cwd()) + .option("-a, --all", "update all existing components", false) + .option("-y, --yes", "skip confirmation prompt", false) + .option("--proxy ", "fetch components from registry using a proxy", getEnvProxy()) + .action(async (components, opts) => { + intro(); + + try { + const options = updateOptionsSchema.parse({ + components, + ...opts, + }); + + const cwd = path.resolve(options.cwd); + + if (!existsSync(cwd)) { + throw error(`The path ${color.cyan(cwd)} does not exist. Please try again.`); + } + + const config = await cliConfig.getConfig(cwd); + if (!config) { + throw error( + `Configuration file is missing. Please run ${color.green("init")} to create a ${highlight("components.json")} file.` + ); + } + + checkPreconditions(cwd); + + await runUpdate(cwd, config, options); + + p.note( + `This action ${color.underline("does not")} update your ${highlight("dependencies")} to their ${color.bold("latest")} versions.\n\nConsider updating them as well.` + ); + + p.outro(`${color.green("Success!")} Component update completed.`); + } catch (e) { + handleError(e); + } + }); + +async function runUpdate(cwd: string, config: cliConfig.Config, options: UpdateOptions) { + if (options.proxy !== undefined) { + process.env.HTTP_PROXY = options.proxy; + p.log.info(`You are using the provided proxy: ${color.green(options.proxy)}`); + } + + const components = options.components; + + const registryUrl = registry.getRegistryUrl(config); + const registryIndex = await registry.getRegistryIndex(registryUrl); + + const dirs = { + ui: config.resolvedPaths.ui, + components: config.resolvedPaths.components, + hooks: config.resolvedPaths.hooks, + }; + + // Retrieve existing items in the user's project + const existingComponents: typeof registryIndex = []; + for (const [name, dir] of Object.entries(dirs)) { + if (!existsSync(dir)) { + throw error(`'${name}' directory ${color.cyan(dir)} does not exist.`); + } + + const files = await fs.readdir(dir, { withFileTypes: true }); + for (const file of files) { + if (file.isDirectory()) { + const item = registryIndex.find((item) => item.name === file.name); + // is a valid shadcn item + if (item) existingComponents.push(item); + } + } + } + + // Always offer to update the `utils` + const utilsItem = registryIndex.find((item) => item.name === "utils"); + if (utilsItem) { + existingComponents.push(utilsItem); + } + + // If the user specifies component args + let selectedComponents = options.all ? existingComponents : []; + if (selectedComponents.length === 0 && components !== undefined) { + // ...only add valid components to the list + selectedComponents = existingComponents.filter((component) => + components.includes(component.name) + ); + } + + // If user didn't specify any component arguments + if (selectedComponents.length === 0) { + const selected = await p.multiselect({ + message: "Which components would you like to update?", + maxItems: 10, + options: existingComponents.map((component) => ({ + label: component.name, + value: component, + hint: component.registryDependencies?.length + ? `also updates: ${component.registryDependencies.join(", ")}` + : undefined, + })), + }); + + if (p.isCancel(selected)) cancel(); + + selectedComponents = selected; + } else { + const prettyList = prettifyList(selectedComponents.map(({ name }) => name)); + p.log.step(`Components to update:\n${color.gray(prettyList)}`); + } + + if (options.yes === false) { + const proceed = await p.confirm({ + message: `Ready to update ${highlight("components")}? ${color.gray("(Make sure you have committed your changes before proceeding!)")}`, + initialValue: true, + }); + + if (p.isCancel(proceed) || proceed === false) cancel(); + } + + const tasks: p.Task[] = []; + + const resolvedItems = await registry.resolveRegistryItems({ + registryIndex: registryIndex, + items: selectedComponents.map((comp) => comp.name), + }); + + const payload = await registry.fetchRegistryItems({ + baseUrl: registryUrl, + items: resolvedItems, + }); + payload.sort((a, b) => a.name.localeCompare(b.name)); + + const componentsToRemove: Record = {}; + const dependencies = new Set(); + const devDependencies = new Set(); + let cssVars = {}; + for (const item of payload) { + const aliasDir = registry.getItemAliasDir(config, item.type); + + // Add dependencies to the install list + item.dependencies?.forEach((dep) => dependencies.add(dep)); + item.devDependencies?.forEach((dep) => devDependencies.add(dep)); + + // Update Components + tasks.push({ + title: `Updating ${highlight(item.name)}`, + async task() { + for (const file of item.files) { + let filePath = registry.resolveItemFilePath(config, item, file); + + // Run transformers. + const content = await transformContent(file.content, filePath, config); + + const dir = path.parse(filePath).dir; + if (!existsSync(dir)) { + await fs.mkdir(dir, { recursive: true }); + } + + if (!config.typescript && filePath.endsWith(".ts")) { + filePath = filePath.replace(".ts", ".js"); + } + + await fs.writeFile(filePath, content, "utf8"); + } + + if (item.cssVars) { + cssVars = merge(cssVars, item.cssVars); + } + + const itemDir = path.resolve(aliasDir, item.name); + if (item.files.length > 1) { + const remoteFiles = item.files.map((file) => { + const filepath = registry.resolveItemFilePath(config, item, file); + if (!config.typescript && filepath.endsWith(".ts")) { + return filepath.replace(".ts", ".js"); + } + return filepath; + }); + + const installedFiles = await fs.readdir(itemDir, { withFileTypes: true }); + const filesToDelete = installedFiles + .map((file) => path.resolve(file.path, file.name)) + .filter((filepath) => !remoteFiles.includes(filepath)); + + if (filesToDelete.length > 0) { + componentsToRemove[item.name] = filesToDelete; + } + } + + const componentPath = path.relative(options.cwd, itemDir); + return `${highlight(item.name)} updated at ${color.gray(componentPath)}`; + }, + }); + } + + const installTask = await installDependencies({ + cwd, + dependencies: Array.from(dependencies), + devDependencies: Array.from(devDependencies), + prompt: true, + }); + if (installTask) tasks.push(installTask); + + // Update the config + tasks.push({ + title: "Updating config file", + async task() { + cliConfig.writeConfig(cwd, config); + return `Config file ${highlight("components.json")} updated`; + }, + }); + + if (Object.keys(cssVars).length > 0) { + // Update the stylesheet + tasks.push({ + title: "Updating stylesheet", + async task() { + const cssPath = config.resolvedPaths.tailwindCss; + const cssSource = await fs.readFile(cssPath, "utf8"); + + const modifiedCss = transformCss(cssSource, cssVars); + await fs.writeFile(cssPath, modifiedCss, "utf8"); + + const relative = path.relative(cwd, cssPath); + return `${highlight("Stylesheet")} updated at ${color.dim(relative)}`; + }, + }); + } + + await p.tasks(tasks); + + for (const [component, files] of Object.entries(componentsToRemove)) { + p.log.warn( + `The ${highlight(component)} component does not use the following files:\n${files.map((file) => color.white(`- ${color.gray(path.relative(cwd, file))}`)).join("\n")}` + ); + } + if (Object.keys(componentsToRemove).length > 0) { + p.log.message(color.bold("You may want to delete them.")); + } +} diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 95770deb22..8ab52b5a14 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -1 +1,15 @@ -export const SITE_BASE_URL = "https://tw3.shadcn-svelte.com"; +export const SITE_BASE_URL = "https://next.shadcn-svelte.com"; +export const TW3_SITE_BASE_URL = "https://tw3.shadcn-svelte.com"; + +export const ALIASES = ["components", "ui", "hooks", "lib", "utils"] as const; + +export const ALIAS_DEFAULTS = ALIASES.reduce( + (acc, a) => { + acc[a] = { + placeholder: `$${a.toUpperCase()}$`, + defaultPath: a === "utils" ? "$lib/utils" : `$lib/registry/${a}`, + }; + return acc; + }, + {} as Record<(typeof ALIASES)[number], { placeholder: string; defaultPath: string }> +); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ac4f1604ed..fd3d3047fb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node import process from "node:process"; import { Command } from "commander"; -import { add, init, update } from "./commands"; +import * as commands from "./commands/index.js"; import { getPackageInfo } from "./utils/get-package-info.js"; process.on("SIGINT", () => process.exit(0)); @@ -27,7 +27,10 @@ async function main() { .description("Add shadcn-svelte components to your project") .version(packageInfo.version || "0.0.0", "-v, --version", "display the version number"); - program.addCommand(init).addCommand(add).addCommand(update); + // register commands + for (const cmd of Object.values(commands)) { + program.addCommand(cmd); + } program.parse(); } diff --git a/packages/cli/src/utils/add-registry-items.ts b/packages/cli/src/utils/add-registry-items.ts new file mode 100644 index 0000000000..21ab266efd --- /dev/null +++ b/packages/cli/src/utils/add-registry-items.ts @@ -0,0 +1,172 @@ +import path from "node:path"; +import { existsSync, promises as fs } from "node:fs"; +import color from "chalk"; +import merge from "deepmerge"; +import * as p from "@clack/prompts"; +import * as registry from "./registry/index.js"; +import { highlight } from "./utils.js"; +import { cancel, prettifyList } from "./prompt-helpers.js"; +import { transformContent, transformCss } from "./transformers.js"; +import type { Config } from "./get-config.js"; + +type AddRegistryItemsProps = { + selectedItems: string[]; + config: Config; + overwrite: boolean; + deps: boolean; + path?: string; +}; + +// this logic is shared between the `add` and `init` commands +export async function addRegistryItems(opts: AddRegistryItemsProps) { + const dependencies = new Set(); + const devDependencies = new Set(); + const skippedDeps = new Set(); + const selectedItems = new Set(opts.selectedItems); + const tasks: p.Task[] = []; + const cwd = opts.config.resolvedPaths.cwd; + const registryUrl = registry.getRegistryUrl(opts.config); + let cssVars = {}; + + const registryIndex = await registry.getRegistryIndex(registryUrl); + const resolvedItems = await registry.resolveRegistryItems({ + items: Array.from(selectedItems), + registryIndex, + }); + + const itemsWithContent = await registry.fetchRegistryItems({ + baseUrl: registryUrl, + items: resolvedItems, + }); + + if (itemsWithContent.length === 0) cancel("Selected items not found."); + + // build a list of existing items + const existingItems: string[] = []; + const targetPath = opts.path ? path.resolve(cwd, opts.path) : undefined; + for (const item of itemsWithContent) { + if (selectedItems.has(item.name) === false) continue; + for (const regDep of item.registryDependencies ?? []) { + selectedItems.add(regDep); + } + + const itemExists = item.files.some((file) => { + const filePath = registry.resolveItemFilePath(opts.config, item, file); + return existsSync(filePath); + }); + if (itemExists) { + existingItems.push(item.name); + } + } + + // prompt if the user wants to overwrite ALL files or individually + if (opts.overwrite === false && existingItems.length > 0) { + const prettyList = prettifyList(existingItems); + p.log.warn( + `The following items ${color.bold.yellow("already exist")}:\n${color.gray(prettyList)}` + ); + + const overwrite = await p.confirm({ + message: `Would you like to ${color.bold.red("overwrite")} all existing files?`, + active: "Yes, overwrite everything", + inactive: "No, let me decide individually", + initialValue: false, + }); + + if (p.isCancel(overwrite)) cancel(); + + opts.overwrite = overwrite; + } + + for (const item of itemsWithContent) { + if (item.type !== "registry:style") { + const aliasDir = registry.getItemAliasDir(opts.config, item.type, targetPath); + if (!existsSync(aliasDir)) { + await fs.mkdir(aliasDir, { recursive: true }); + } + + const itemPath = path.relative(cwd, path.resolve(aliasDir, item.name)); + + if (!opts.overwrite && existingItems.includes(item.name)) { + if (selectedItems.has(item.name)) { + p.log.warn( + `Item ${highlight(item.name)} already exists at ${color.gray(itemPath)}` + ); + + const overwrite = await p.confirm({ + message: `Would you like to ${color.bold.red("overwrite")} your existing ${highlight(item.name)} ${item.type}?`, + }); + if (p.isCancel(overwrite)) cancel(); + if (overwrite === false) continue; + } + } + } + + if (opts.deps) { + item.dependencies?.forEach((dep) => dependencies.add(dep)); + item.devDependencies?.forEach((dep) => devDependencies.add(dep)); + } else { + item.dependencies?.forEach((dep) => skippedDeps.add(dep)); + item.devDependencies?.forEach((dep) => devDependencies.add(dep)); + } + + tasks.push({ + title: + item.name === "init" + ? "Setting up shadcn-svelte base configuration" + : `Adding ${highlight(item.name)}`, + // @ts-expect-error this is intentional since we don't want to return a string during `init` + async task() { + for (const file of item.files) { + let filePath = registry.resolveItemFilePath(opts.config, item, file); + + // run transformers + const content = await transformContent(file.content, filePath, opts.config); + + const dir = path.parse(filePath).dir; + if (!existsSync(dir)) { + await fs.mkdir(dir, { recursive: true }); + } + + if (!opts.config.typescript && filePath.endsWith(".ts")) { + filePath = filePath.replace(".ts", ".js"); + } + await fs.writeFile(filePath, content, "utf8"); + } + + if (item.cssVars) { + cssVars = merge(cssVars, item.cssVars); + } + + if (item.name !== "init") { + const aliasDir = registry.getItemAliasDir(opts.config, item.type, targetPath); + const itemPath = path.relative(cwd, path.resolve(aliasDir, item.name)); + return `${highlight(item.name)} installed at ${color.gray(itemPath)}`; + } + }, + }); + } + + if (Object.keys(cssVars).length > 0) { + tasks.push({ + title: "Updating stylesheet", + async task() { + const cssPath = opts.config.resolvedPaths.tailwindCss; + const relative = path.relative(cwd, cssPath); + const cssSource = await fs.readFile(cssPath, "utf8"); + + const modifiedCss = transformCss(cssSource, cssVars); + await fs.writeFile(cssPath, modifiedCss, "utf8"); + + return `${highlight("Stylesheet")} updated at ${color.dim(relative)}`; + }, + }); + } + + return { + tasks, + skippedDeps, + dependencies, + devDependencies, + }; +} diff --git a/packages/cli/src/utils/auto-detect.ts b/packages/cli/src/utils/auto-detect.ts index 2a31d9ac79..c6c131749b 100644 --- a/packages/cli/src/utils/auto-detect.ts +++ b/packages/cli/src/utils/auto-detect.ts @@ -2,45 +2,25 @@ import fs from "node:fs"; import path from "node:path"; import ignore, { type Ignore } from "ignore"; import { type TsConfigResult, getTsconfig } from "get-tsconfig"; -import { detect } from "package-manager-detector"; -import { AGENTS, type Agent } from "package-manager-detector"; -import * as p from "./prompts.js"; +import { AGENTS, detect, getUserAgent, type Agent, type AgentName } from "package-manager-detector"; +import * as p from "@clack/prompts"; import { cancel } from "./prompt-helpers.js"; -const STYLESHEETS = [ - "app.css", - "app.pcss", - "app.postcss", - "main.css", - "main.pcss", - "main.postcss", - "globals.css", - "globals.pcss", - "globals.postcss", -]; -const TAILWIND_CONFIGS = [ - "tailwind.config.js", - "tailwind.config.cjs", - "tailwind.config.mjs", - "tailwind.config.ts", -]; +const STYLESHEETS = ["app.css", "main.css", "globals.css"]; // commonly ignored const IGNORE = ["node_modules", ".git", ".svelte-kit"]; export function detectConfigs(cwd: string, config?: { relative: boolean }) { - let tailwindPath, cssPath; + let cssPath; const paths = findFiles(cwd); for (const filepath of paths) { const filename = path.parse(filepath).base; if (cssPath === undefined && STYLESHEETS.includes(filename)) { cssPath = config?.relative ? path.relative(cwd, filepath) : filepath; } - if (tailwindPath === undefined && TAILWIND_CONFIGS.includes(filename)) { - tailwindPath = config?.relative ? path.relative(cwd, filepath) : filepath; - } } - return { tailwindPath, cssPath }; + return { cssPath }; } /** @@ -96,28 +76,27 @@ export function detectLanguage(cwd: string): DetectLanguageResult | undefined { if (jsConfig !== null) return { type: "jsconfig.json", config: jsConfig }; } +const AGENT_NAMES = AGENTS.filter((agent) => !agent.includes("@")) as AgentName[]; type Options = Array<{ value: Agent | undefined; label: Agent | "None" }>; export async function detectPM(cwd: string, prompt: boolean): Promise { - const detectResult = await detect({ cwd }); - - let agent: Agent | undefined; - if (detectResult != null) { - agent = detectResult.agent; - } else if (detectResult == null && prompt) { - // prompt for package manager - const options: Options = AGENTS.filter((agent) => !agent.includes("@")).map((pm) => ({ - value: pm, - label: pm, - })); + const agent = (await detect({ cwd }))?.agent; + + // prompt for package manager + if (!agent && prompt) { + const options: Options = AGENT_NAMES.map((pm) => ({ value: pm, label: pm })); options.unshift({ label: "None", value: undefined }); - const res = await p.select({ - message: "Which package manager do you want to use?", + const userAgent = getUserAgent() ?? undefined; // replaces `null` for `undefined` + const pm = await p.select({ options, + initialValue: userAgent, + message: "Which package manager do you want to use?", }); - if (p.isCancel(res)) cancel(); + if (p.isCancel(pm)) { + cancel(); + } - agent = res; + return pm; } return agent; diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index 94ce6ddcc4..e8b1131208 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -1,5 +1,5 @@ import process from "node:process"; -import * as p from "./prompts.js"; +import * as p from "@clack/prompts"; export function handleError(error: unknown) { // provide a newline gap diff --git a/packages/cli/src/utils/get-config.ts b/packages/cli/src/utils/get-config.ts index 055478520b..a213faa4d4 100644 --- a/packages/cli/src/utils/get-config.ts +++ b/packages/cli/src/utils/get-config.ts @@ -2,103 +2,109 @@ import color from "chalk"; import { getTsconfig } from "get-tsconfig"; import fs from "node:fs"; import path from "node:path"; -import * as v from "valibot"; +import { z } from "zod/v4"; +import { highlight } from "./utils.js"; +import { SITE_BASE_URL } from "../constants.js"; import { ConfigError, error } from "./errors.js"; import { resolveImport } from "./resolve-imports.js"; -import { syncSvelteKit } from "./sveltekit.js"; -import { SITE_BASE_URL } from "../constants.js"; +import { isUsingSvelteKit, syncSvelteKit } from "./sveltekit.js"; export const DEFAULT_STYLE = "default"; export const DEFAULT_COMPONENTS = "$lib/components"; export const DEFAULT_UTILS = "$lib/utils"; export const DEFAULT_HOOKS = "$lib/hooks"; export const DEFAULT_UI = "$lib/components/ui"; +export const DEFAULT_LIB = "$lib"; export const DEFAULT_TAILWIND_CSS = "src/app.css"; -export const DEFAULT_TAILWIND_CONFIG = "tailwind.config.ts"; export const DEFAULT_TAILWIND_BASE_COLOR = "slate"; export const DEFAULT_TYPESCRIPT = true; -const highlight = (...args: unknown[]) => color.bold.cyan(...args); - const aliasSchema = (alias: string) => - v.pipe( - v.string(`Missing aliases.${color.bold(`${alias}`)} alias`), - v.transform((v) => v.replace(/[\u{0080}-\u{FFFF}]/gu, "")) - ); - -const originalConfigSchema = v.object({ - $schema: v.optional(v.string()), - style: v.string(`Missing ${color.bold("style")} field`), - tailwind: v.object( + z + .string(`Missing aliases.${color.bold(`${alias}`)} alias`) + .transform((v) => v.replace(/[\u{0080}-\u{FFFF}]/gu, "")); + +const baseConfigSchema = z.object({ + $schema: z.string().optional(), + tailwind: z.object( { - config: v.string(`Missing tailwind.${color.bold("config")} path`), - css: v.string(`Missing tailwind.${color.bold("css")} path`), - baseColor: v.string(`Missing tailwind.${color.bold("baseColor")} field`), - // cssVariables: v.boolean().default(true) + css: z.string(`Missing tailwind.${color.bold("css")} path`), + baseColor: z.string(`Missing tailwind.${color.bold("baseColor")} field`), + // cssVariables: z.boolean().default(true) }, `Missing ${color.bold("tailwind")} object` ), - aliases: v.object( + aliases: z.object( { components: aliasSchema("components"), utils: aliasSchema("utils"), }, `Missing ${color.bold("aliases")} object` ), + typescript: z.boolean().default(true), }); -// fields that were added after the fact so they must be optional so we can gracefully migrate -// TODO: ideally, prompts would be triggered if these fields are not populated -const newConfigFields = v.object({ - aliases: v.object({ - ui: v.optional(aliasSchema("ui"), DEFAULT_UI), - hooks: v.optional(aliasSchema("hooks"), DEFAULT_HOOKS), +const originalConfigSchema = baseConfigSchema.extend({ style: z.string().optional() }); + +const newConfigSchema = baseConfigSchema.extend({ + aliases: baseConfigSchema.shape.aliases.extend({ + ui: aliasSchema("ui").default(DEFAULT_UI), + hooks: aliasSchema("hooks").default(DEFAULT_HOOKS), + lib: aliasSchema("lib").default(DEFAULT_LIB), }), - typescript: v.optional(v.boolean(), true), - // TODO: if they're missing this field then they're likely using svelte 4 - // and we should prompt them to see if they'd like to use the new registry - registry: v.optional(v.string(), `${SITE_BASE_URL}/registry`), + registry: z.string().default(`${SITE_BASE_URL}/registry`), }); +export type RawConfig = z.infer; // combines the old with the new -export const rawConfigSchema = v.object({ - ...originalConfigSchema.entries, - ...newConfigFields.entries, - aliases: v.object({ - ...originalConfigSchema.entries.aliases.entries, - ...newConfigFields.entries.aliases.entries, +export const rawConfigSchema = z.object({ + ...originalConfigSchema.shape, + ...newConfigSchema.shape, + aliases: z.object({ + ...originalConfigSchema.shape.aliases.shape, + ...newConfigSchema.shape.aliases.shape, }), }); -export type RawConfig = v.InferOutput; - -export const configSchema = v.object({ - ...rawConfigSchema.entries, - ...v.object({ - resolvedPaths: v.object({ - cwd: v.string(), - tailwindConfig: v.string(), - tailwindCss: v.string(), - utils: v.string(), - components: v.string(), - hooks: v.string(), - ui: v.string(), - }), - }).entries, +export type Config = z.infer; +export const configSchema = rawConfigSchema.extend({ + sveltekit: z.boolean(), + resolvedPaths: z.object({ + cwd: z.string(), + tailwindCss: z.string(), + utils: z.string(), + components: z.string(), + hooks: z.string(), + ui: z.string(), + lib: z.string(), + }), }); -export type Config = v.InferOutput; - export async function getConfig(cwd: string) { - const config = await getRawConfig(cwd); + const config = getRawConfig(cwd); - if (!config) { - return null; - } + if (!config) return null; return await resolveConfigPaths(cwd, config); } +function getRawConfig(cwd: string): RawConfig | null { + const configPath = path.resolve(cwd, "components.json"); + if (!fs.existsSync(configPath)) return null; + + try { + const configResult = fs.readFileSync(configPath, { encoding: "utf8" }); + const config = JSON.parse(configResult); + return rawConfigSchema.parse(config); + } catch (e) { + if (!(e instanceof z.ZodError)) throw e; + const formatted = `Errors:\n- ${color.redBright(e.issues.map((i) => i.message).join("\n- "))}`; + throw new ConfigError( + `Invalid configuration found in ${highlight(configPath)}.\n\n${formatted}` + ); + } +} + export async function resolveConfigPaths(cwd: string, config: RawConfig) { // if it's a SvelteKit project, run sync so that the aliases are always up to date await syncSvelteKit(cwd); @@ -112,10 +118,17 @@ export async function resolveConfigPaths(cwd: string, config: RawConfig) { ); } + const stripTrailingSlash = (s: string) => (s.endsWith("/") ? s.slice(0, -1) : s); + for (const [alias, path] of Object.entries(config.aliases)) { + // @ts-expect-error simmer down + config.aliases[alias] = stripTrailingSlash(path); + } + let utilsPath = resolveImport(config.aliases.utils, pathAliases); let componentsPath = resolveImport(config.aliases.components, pathAliases); let hooksPath = resolveImport(config.aliases.hooks, pathAliases); let uiPath = resolveImport(config.aliases.ui, pathAliases); + let libPath = resolveImport(config.aliases.lib, pathAliases); const aliasError = (type: string, alias: string) => new ConfigError( @@ -128,22 +141,27 @@ export async function resolveConfigPaths(cwd: string, config: RawConfig) { if (componentsPath === undefined) throw aliasError("components", config.aliases.components); if (hooksPath === undefined) throw aliasError("hooks", config.aliases.hooks); if (uiPath === undefined) throw aliasError("ui", config.aliases.ui); + if (libPath === undefined) throw aliasError("lib", config.aliases.lib); utilsPath = path.normalize(utilsPath); componentsPath = path.normalize(componentsPath); hooksPath = path.normalize(hooksPath); uiPath = path.normalize(uiPath); + libPath = path.normalize(libPath); + + const sveltekit = isUsingSvelteKit(cwd); - return v.parse(configSchema, { + return configSchema.parse({ ...config, + sveltekit, resolvedPaths: { cwd, - tailwindConfig: path.resolve(cwd, config.tailwind.config), tailwindCss: path.resolve(cwd, config.tailwind.css), utils: utilsPath, components: componentsPath, hooks: hooksPath, ui: uiPath, + lib: libPath, }, }); } @@ -159,26 +177,8 @@ export function getTSConfig(cwd: string, tsconfigName: "tsconfig.json" | "jsconf return parsedConfig; } -export async function getRawConfig(cwd: string): Promise { - const configPath = path.resolve(cwd, "components.json"); - if (!fs.existsSync(configPath)) return null; - - try { - const configResult = fs.readFileSync(configPath, { encoding: "utf8" }); - const config = JSON.parse(configResult); - return v.parse(rawConfigSchema, config); - } catch (e) { - if (!(e instanceof v.ValiError)) throw e; - const formatted = `Errors:\n- ${color.redBright(e.issues.map((i) => i.message).join("\n- "))}`; - throw new ConfigError( - `Invalid configuration found in ${highlight(configPath)}.\n\n${formatted}` - ); - } -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function writeConfig(cwd: string, config: any): void { +export function writeConfig(cwd: string, config: RawConfig): void { const targetPath = path.resolve(cwd, "components.json"); - const conf = v.parse(rawConfigSchema, config); // inefficient, but it'll do + const conf = newConfigSchema.parse(config, { jitless: true }); // `jitless` to retain the property order fs.writeFileSync(targetPath, JSON.stringify(conf, null, "\t") + "\n", "utf8"); } diff --git a/packages/cli/src/utils/get-package-info.ts b/packages/cli/src/utils/get-package-info.ts index 0b30aa265b..eb5280a845 100644 --- a/packages/cli/src/utils/get-package-info.ts +++ b/packages/cli/src/utils/get-package-info.ts @@ -3,7 +3,7 @@ import fs from "node:fs"; import { fileURLToPath } from "node:url"; import type { PackageJson } from "type-fest"; -export function getPackageInfo() { +export function getPackageInfo(): PackageJson { const packageJsonPath = getPackageFilePath("../package.json"); return readJSONSync(packageJsonPath) as PackageJson; } diff --git a/packages/cli/src/utils/install-deps.ts b/packages/cli/src/utils/install-deps.ts new file mode 100644 index 0000000000..15152ecb29 --- /dev/null +++ b/packages/cli/src/utils/install-deps.ts @@ -0,0 +1,63 @@ +import semver from "semver"; +import { detectPM } from "./auto-detect.js"; +import { loadProjectPackageInfo } from "./get-package-info.js"; +import { highlight, parseDependency } from "./utils.js"; +import { exec } from "tinyexec"; +import { resolveCommand } from "package-manager-detector"; +import { error } from "./errors.js"; +import type { Task } from "@clack/prompts"; + +type InstallOptions = { + dependencies: string[]; + devDependencies: string[]; + cwd: string; + prompt: boolean; +}; +export async function installDependencies({ + cwd, + prompt, + dependencies, + devDependencies, +}: InstallOptions): Promise { + const pm = await detectPM(cwd, prompt); + if (!pm) return; + + const pkg = loadProjectPackageInfo(cwd); + const projectDeps = { ...pkg.dependencies, ...pkg.devDependencies }; + + const validateDep = (dep: string) => { + const { name, version } = parseDependency(dep); + const depVersion = semver.coerce(projectDeps[name]); + if (depVersion && semver.satisfies(depVersion, version, { loose: true })) { + return undefined; + } + return `${name}@${version}`; + }; + + const devDeps = devDependencies.map(validateDep).filter((d) => d !== undefined); + const addDevDeps = resolveCommand(pm, "add", ["-D", ...devDeps]); + + const deps = dependencies.map(validateDep).filter((d) => d !== undefined); + const addDeps = resolveCommand(pm, "add", deps); + + if (!addDevDeps || !addDeps) throw error(`Could not detect a package manager in ${cwd}.`); + return { + title: `${highlight(pm)}: Installing dependencies`, + enabled: deps.length > 0 || devDeps.length > 0, + async task() { + if (deps.length > 0) { + await exec(addDeps.command, addDeps.args, { + throwOnError: true, + nodeOptions: { cwd }, + }); + } + if (devDeps.length > 0) { + await exec(addDevDeps.command, addDevDeps.args, { + throwOnError: true, + nodeOptions: { cwd }, + }); + } + return `Dependencies installed with ${highlight(pm)}`; + }, + }; +} diff --git a/packages/cli/src/utils/preconditions.ts b/packages/cli/src/utils/preconditions.ts index f7fc146862..a80731c77d 100644 --- a/packages/cli/src/utils/preconditions.ts +++ b/packages/cli/src/utils/preconditions.ts @@ -1,12 +1,12 @@ import color from "chalk"; -import semver from "semver"; +import * as semver from "semver"; import { loadProjectPackageInfo } from "./get-package-info.js"; -import { log } from "./prompts.js"; +import { log } from "@clack/prompts"; import { getPadding } from "./prompt-helpers.js"; const peerDependencies: Record = { svelte: "5.x.x", - tailwindcss: "3.x.x", + tailwindcss: "4.x.x", }; export function checkPreconditions(cwd: string) { diff --git a/packages/cli/src/utils/prompt-helpers.ts b/packages/cli/src/utils/prompt-helpers.ts index c4514b9a78..a11f40befd 100644 --- a/packages/cli/src/utils/prompt-helpers.ts +++ b/packages/cli/src/utils/prompt-helpers.ts @@ -1,6 +1,6 @@ import process from "node:process"; import color from "chalk"; -import * as p from "./prompts.js"; +import * as p from "@clack/prompts"; import { getPackageInfo } from "./get-package-info.js"; export function intro() { @@ -12,7 +12,7 @@ export function intro() { // @ts-expect-error types for these globals are not defined if (typeof Bun !== "undefined" || typeof Deno !== "undefined") { p.log.warn( - `You are currently using an unsupported runtime. Only Node.js v18 or higher is officially supported. Continue at your own risk.` + `You are currently using an unsupported runtime. Only Node.js v22 or higher is officially supported. Continue at your own risk.` ); } } diff --git a/packages/cli/src/utils/prompts.ts b/packages/cli/src/utils/prompts.ts deleted file mode 100644 index b0138ea3af..0000000000 --- a/packages/cli/src/utils/prompts.ts +++ /dev/null @@ -1,827 +0,0 @@ -import process from "node:process"; -import type { State } from "@clack/core"; -import { - ConfirmPrompt, - GroupMultiSelectPrompt, - MultiSelectPrompt, - PasswordPrompt, - SelectKeyPrompt, - SelectPrompt, - TextPrompt, - block, - isCancel, -} from "@clack/core"; -import isUnicodeSupported from "is-unicode-supported"; -import color from "chalk"; -import { cursor, erase } from "sisteransi"; - -export { isCancel } from "@clack/core"; - -const unicode = isUnicodeSupported(); -const s = (c: string, fallback: string) => (unicode ? c : fallback); -const S_STEP_ACTIVE = s("◆", "*"); -const S_STEP_CANCEL = s("■", "x"); -const S_STEP_ERROR = s("▲", "x"); -const S_STEP_SUBMIT = s("◇", "o"); - -const S_BAR_START = s("┌", "T"); -const S_BAR = s("│", "|"); -const S_BAR_END = s("└", "—"); - -const S_RADIO_ACTIVE = s("●", ">"); -const S_RADIO_INACTIVE = s("○", " "); -const S_CHECKBOX_ACTIVE = s("◻", "[•]"); -const S_CHECKBOX_SELECTED = s("◼", "[+]"); -const S_CHECKBOX_INACTIVE = s("◻", "[ ]"); -const S_PASSWORD_MASK = s("▪", "•"); - -const S_BAR_H = s("─", "-"); -const S_CORNER_TOP_RIGHT = s("╮", "+"); -const S_CONNECT_LEFT = s("├", "+"); -const S_CORNER_BOTTOM_RIGHT = s("╯", "+"); - -const S_INFO = s("●", "•"); -const S_SUCCESS = s("◆", "*"); -const S_WARN = s("▲", "!"); -const S_ERROR = s("■", "x"); - -function symbol(state: State) { - switch (state) { - case "initial": - case "active": - return color.cyan(S_STEP_ACTIVE); - case "cancel": - return color.red(S_STEP_CANCEL); - case "error": - return color.yellow(S_STEP_ERROR); - case "submit": - return color.green(S_STEP_SUBMIT); - } -} - -type LimitOptionsParams = { - options: TOption[]; - maxItems: number | undefined; - cursor: number; - style: (option: TOption, active: boolean) => string; -}; - -function limitOptions(params: LimitOptionsParams): string[] { - const { cursor, options, style } = params; - - // We clamp to minimum 5 because anything less doesn't make sense UX wise - const maxItems = - params.maxItems === undefined ? Number.POSITIVE_INFINITY : Math.max(params.maxItems, 5); - let slidingWindowLocation = 0; - - if (cursor >= slidingWindowLocation + maxItems - 3) { - slidingWindowLocation = Math.max( - Math.min(cursor - maxItems + 3, options.length - maxItems), - 0 - ); - } else if (cursor < slidingWindowLocation + 2) { - slidingWindowLocation = Math.max(cursor - 2, 0); - } - - const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0; - const shouldRenderBottomEllipsis = - maxItems < options.length && slidingWindowLocation + maxItems < options.length; - - return options - .slice(slidingWindowLocation, slidingWindowLocation + maxItems) - .map((option, i, arr) => { - const isTopLimit = i === 0 && shouldRenderTopEllipsis; - const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis; - return isTopLimit || isBottomLimit - ? color.dim("...") - : style(option, i + slidingWindowLocation === cursor); - }); -} - -export type TextOptions = { - message: string; - placeholder?: string; - defaultValue?: string; - initialValue?: string; - validate?: (value: string) => string | void; -}; -export function text(opts: TextOptions) { - return new TextPrompt({ - validate: opts.validate, - placeholder: opts.placeholder, - defaultValue: opts.defaultValue, - initialValue: opts.initialValue, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const placeholder = opts.placeholder - ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) - : color.inverse(color.hidden("_")); - const value = !this.value ? placeholder : this.valueWithCursor; - - switch (this.state) { - case "error": - return `${title.trim()}\n${color.yellow(S_BAR)} ${value}\n${color.yellow( - S_BAR_END - )} ${color.yellow(this.error)}\n`; - case "submit": - return `${title}${color.gray(S_BAR)} ${color.dim(this.value || opts.placeholder)}`; - case "cancel": - return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(this.value ?? "") - )}${this.value?.trim() ? `\n${color.gray(S_BAR)}` : ""}`; - default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; - } - }, - }).prompt() as Promise; -} - -export type PasswordOptions = { - message: string; - mask?: string; - validate?: (value: string) => string | void; -}; -export function password(opts: PasswordOptions) { - return new PasswordPrompt({ - validate: opts.validate, - mask: opts.mask ?? S_PASSWORD_MASK, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const value = this.valueWithCursor; - const masked = this.masked; - - switch (this.state) { - case "error": - return `${title.trim()}\n${color.yellow(S_BAR)} ${masked}\n${color.yellow( - S_BAR_END - )} ${color.yellow(this.error)}\n`; - case "submit": - return `${title}${color.gray(S_BAR)} ${color.dim(masked)}`; - case "cancel": - return `${title}${color.gray(S_BAR)} ${color.strikethrough(color.dim(masked ?? ""))}${ - masked ? `\n${color.gray(S_BAR)}` : "" - }`; - default: - return `${title}${color.cyan(S_BAR)} ${value}\n${color.cyan(S_BAR_END)}\n`; - } - }, - }).prompt() as Promise; -} - -export type ConfirmOptions = { - message: string; - active?: string; - inactive?: string; - initialValue?: boolean; -}; -export function confirm(opts: ConfirmOptions) { - const active = opts.active ?? "Yes"; - const inactive = opts.inactive ?? "No"; - return new ConfirmPrompt({ - active, - inactive, - initialValue: opts.initialValue ?? true, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - const value = this.value ? active : inactive; - - switch (this.state) { - case "submit": - return `${title}${color.gray(S_BAR)} ${color.dim(value)}`; - case "cancel": - return `${title}${color.gray(S_BAR)} ${color.strikethrough( - color.dim(value) - )}\n${color.gray(S_BAR)}`; - default: { - return `${title}${color.cyan(S_BAR)} ${ - this.value - ? `${color.green(S_RADIO_ACTIVE)} ${active}` - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(active)}` - } ${color.dim("/")} ${ - !this.value - ? `${color.green(S_RADIO_ACTIVE)} ${inactive}` - : `${color.dim(S_RADIO_INACTIVE)} ${color.dim(inactive)}` - }\n${color.cyan(S_BAR_END)}\n`; - } - } - }, - }).prompt() as Promise; -} - -type Primitive = Readonly; - -type Option = Value extends Primitive - ? { value: Value; label?: string; hint?: string } - : { value: Value; label: string; hint?: string }; - -export type SelectOptions = { - message: string; - options: Option[]; - initialValue?: Value; - maxItems?: number; -}; - -export function select(opts: SelectOptions) { - const opt = ( - option: Option, - state: "inactive" | "active" | "selected" | "cancelled" - ) => { - const label = option.label ?? String(option.value); - switch (state) { - case "selected": - return `${color.dim(label)}`; - case "active": - return `${color.green(S_RADIO_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - case "cancelled": - return `${color.strikethrough(color.dim(label))}`; - default: - return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`; - } - }; - - return new SelectPrompt({ - options: opts.options, - initialValue: opts.initialValue, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - - switch (this.state) { - case "submit": - return `${title}${color.gray(S_BAR)} ${opt(this.options[this.cursor]!, "selected")}`; - case "cancel": - return `${title}${color.gray(S_BAR)} ${opt( - this.options[this.cursor]!, - "cancelled" - )}\n${color.gray(S_BAR)}`; - default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ - cursor: this.cursor, - options: this.options, - maxItems: opts.maxItems, - style: (item, active) => opt(item, active ? "active" : "inactive"), - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; - } - } - }, - }).prompt() as Promise; -} - -export function selectKey(opts: SelectOptions) { - const opt = ( - option: Option, - state: "inactive" | "active" | "selected" | "cancelled" = "inactive" - ) => { - const label = option.label ?? String(option.value); - if (state === "selected") { - return `${color.dim(label)}`; - } else if (state === "cancelled") { - return `${color.strikethrough(color.dim(label))}`; - } else if (state === "active") { - return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - } - return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - }; - - return new SelectKeyPrompt({ - options: opts.options, - initialValue: opts.initialValue, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - - switch (this.state) { - case "submit": - return `${title}${color.gray(S_BAR)} ${opt( - this.options.find((opt) => opt.value === this.value)!, - "selected" - )}`; - case "cancel": - return `${title}${color.gray(S_BAR)} ${opt(this.options[0]!, "cancelled")}\n${color.gray( - S_BAR - )}`; - default: { - return `${title}${color.cyan(S_BAR)} ${this.options - .map((option, i) => opt(option, i === this.cursor ? "active" : "inactive")) - .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; - } - } - }, - }).prompt() as Promise; -} - -export type MultiSelectOptions = { - message: string; - options: Option[]; - initialValues?: Value[]; - maxItems?: number; - required?: boolean; - cursorAt?: Value; -}; -export function multiselect(opts: MultiSelectOptions) { - const opt = ( - option: Option, - state: "inactive" | "active" | "selected" | "active-selected" | "submitted" | "cancelled" - ) => { - const label = option.label ?? String(option.value); - if (state === "active") { - return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - } else if (state === "selected") { - return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; - } else if (state === "cancelled") { - return `${color.strikethrough(color.dim(label))}`; - } else if (state === "active-selected") { - return `${color.green(S_CHECKBOX_SELECTED)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - } else if (state === "submitted") { - return `${color.dim(label)}`; - } - return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; - }; - - return new MultiSelectPrompt({ - options: opts.options, - initialValues: opts.initialValues, - required: opts.required ?? true, - cursorAt: opts.cursorAt, - validate(selected: Value[]) { - if (this.required && selected.length === 0) - return `Please select at least one option.\n${color.reset( - color.dim( - `Press ${color.gray(color.bgWhite(color.inverse(" space ")))} to select, ${color.gray( - color.bgWhite(color.inverse(" enter ")) - )} to submit` - ) - )}`; - }, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - - const styleOption = (option: Option, active: boolean) => { - const selected = this.value.includes(option.value); - if (active && selected) { - return opt(option, "active-selected"); - } - if (selected) { - return opt(option, "selected"); - } - return opt(option, active ? "active" : "inactive"); - }; - - switch (this.state) { - case "submit": { - return `${title}${color.gray(S_BAR)} ${ - this.options - .filter(({ value }) => this.value.includes(value)) - .map((option) => opt(option, "submitted")) - .join(color.dim(", ")) || color.dim("none") - }`; - } - case "cancel": { - const label = this.options - .filter(({ value }) => this.value.includes(value)) - .map((option) => opt(option, "cancelled")) - .join(color.dim(", ")); - return `${title}${color.gray(S_BAR)} ${ - label.trim() ? `${label}\n${color.gray(S_BAR)}` : "" - }`; - } - case "error": { - const footer = this.error - .split("\n") - .map((ln, i) => - i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` - ) - .join("\n"); - return `${title + color.yellow(S_BAR)} ${limitOptions({ - options: this.options, - cursor: this.cursor, - maxItems: opts.maxItems, - style: styleOption, - }).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; - } - default: { - return `${title}${color.cyan(S_BAR)} ${limitOptions({ - options: this.options, - cursor: this.cursor, - maxItems: opts.maxItems, - style: styleOption, - }).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; - } - } - }, - }).prompt() as Promise; -} - -export type GroupMultiSelectOptions = { - message: string; - options: Record[]>; - initialValues?: Value[]; - required?: boolean; - cursorAt?: Value; -}; -export function groupMultiselect(opts: GroupMultiSelectOptions) { - const opt = ( - option: Option, - state: - | "inactive" - | "active" - | "selected" - | "active-selected" - | "group-active" - | "group-active-selected" - | "submitted" - | "cancelled", - options: Option[] = [] - ) => { - const label = option.label ?? String(option.value); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const isItem = typeof (option as any).group === "string"; - const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const isLast = isItem && (next as any).group === true; - const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ""; - - if (state === "active") { - return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - } else if (state === "group-active") { - return `${prefix}${color.cyan(S_CHECKBOX_ACTIVE)} ${color.dim(label)}`; - } else if (state === "group-active-selected") { - return `${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; - } else if (state === "selected") { - return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; - } else if (state === "cancelled") { - return `${color.strikethrough(color.dim(label))}`; - } else if (state === "active-selected") { - return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : "" - }`; - } else if (state === "submitted") { - return `${color.dim(label)}`; - } - return `${color.dim(prefix)}${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; - }; - - return new GroupMultiSelectPrompt({ - options: opts.options, - initialValues: opts.initialValues, - required: opts.required ?? true, - cursorAt: opts.cursorAt, - validate(selected: Value[]) { - if (this.required && selected.length === 0) - return `Please select at least one option.\n${color.reset( - color.dim( - `Press ${color.gray(color.bgWhite(color.inverse(" space ")))} to select, ${color.gray( - color.bgWhite(color.inverse(" enter ")) - )} to submit` - ) - )}`; - }, - render() { - const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; - - switch (this.state) { - case "submit": { - return `${title}${color.gray(S_BAR)} ${this.options - .filter(({ value }) => this.value.includes(value)) - .map((option) => opt(option, "submitted")) - .join(color.dim(", "))}`; - } - case "cancel": { - const label = this.options - .filter(({ value }) => this.value.includes(value)) - .map((option) => opt(option, "cancelled")) - .join(color.dim(", ")); - return `${title}${color.gray(S_BAR)} ${ - label.trim() ? `${label}\n${color.gray(S_BAR)}` : "" - }`; - } - case "error": { - const footer = this.error - .split("\n") - .map((ln, i) => - i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}` - ) - .join("\n"); - return `${title}${color.yellow(S_BAR)} ${this.options - .map((option, i, options) => { - const selected = - this.value.includes(option.value) || - (option.group === true && this.isGroupSelected(`${option.value}`)); - const active = i === this.cursor; - const groupActive = - !active && - typeof option.group === "string" && - this.options[this.cursor]!.value === option.group; - if (groupActive) { - return opt( - option, - selected ? "group-active-selected" : "group-active", - options - ); - } - if (active && selected) { - return opt(option, "active-selected", options); - } - if (selected) { - return opt(option, "selected", options); - } - return opt(option, active ? "active" : "inactive", options); - }) - .join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`; - } - default: { - return `${title}${color.cyan(S_BAR)} ${this.options - .map((option, i, options) => { - const selected = - this.value.includes(option.value) || - (option.group === true && this.isGroupSelected(`${option.value}`)); - const active = i === this.cursor; - const groupActive = - !active && - typeof option.group === "string" && - this.options[this.cursor]!.value === option.group; - if (groupActive) { - return opt( - option, - selected ? "group-active-selected" : "group-active", - options - ); - } - if (active && selected) { - return opt(option, "active-selected", options); - } - if (selected) { - return opt(option, "selected", options); - } - return opt(option, active ? "active" : "inactive", options); - }) - .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; - } - } - }, - }).prompt() as Promise; -} - -const strip = (str: string) => str.replace(ansiRegex(), ""); -export function note(message = "", title = "") { - const lines = `\n${message}\n`.split("\n"); - const titleLen = strip(title).length; - const len = - Math.max( - lines.reduce((sum, ln) => { - ln = strip(ln); - return ln.length > sum ? ln.length : sum; - }, 0), - titleLen - ) + 2; - const msg = lines - .map( - (ln) => - `${color.gray(S_BAR)} ${color.dim(ln)}${" ".repeat(len - strip(ln).length)}${color.gray( - S_BAR - )}` - ) - .join("\n"); - process.stdout.write( - `${color.gray(S_BAR)}\n${color.green(S_STEP_SUBMIT)} ${color.reset(title)} ${color.gray( - S_BAR_H.repeat(Math.max(len - titleLen - 1, 1)) + S_CORNER_TOP_RIGHT - )}\n${msg}\n${color.gray(S_CONNECT_LEFT + S_BAR_H.repeat(len + 2) + S_CORNER_BOTTOM_RIGHT)}\n` - ); -} - -export function cancel(message = "") { - process.stdout.write(`${color.gray(S_BAR_END)} ${color.red(message)}\n\n`); -} - -export function intro(title = "") { - process.stdout.write(`${color.gray(S_BAR_START)} ${title}\n`); -} - -export function outro(message = "") { - process.stdout.write(`${color.gray(S_BAR)}\n${color.gray(S_BAR_END)} ${message}\n\n`); -} - -export type LogMessageOptions = { - symbol?: string; -}; -export const log = { - message: (message = "", { symbol = color.gray(S_BAR) }: LogMessageOptions = {}) => { - const parts = [`${color.gray(S_BAR)}`]; - if (message) { - const [firstLine, ...lines] = message.split("\n"); - parts.push( - `${symbol} ${firstLine}`, - ...lines.map((ln) => `${color.gray(S_BAR)} ${ln}`) - ); - } - process.stdout.write(`${parts.join("\n")}\n`); - }, - info: (message: string) => { - log.message(message, { symbol: color.blue(S_INFO) }); - }, - success: (message: string) => { - log.message(message, { symbol: color.green(S_SUCCESS) }); - }, - step: (message: string) => { - log.message(message, { symbol: color.green(S_STEP_SUBMIT) }); - }, - warn: (message: string) => { - log.message(message, { symbol: color.yellow(S_WARN) }); - }, - /** alias for `log.warn()`. */ - warning: (message: string) => { - log.warn(message); - }, - error: (message: string) => { - log.message(message, { symbol: color.red(S_ERROR) }); - }, -}; - -export function spinner() { - const frames = unicode ? ["◒", "◐", "◓", "◑"] : ["•", "o", "O", "0"]; - const delay = unicode ? 80 : 120; - - let unblock: () => void; - let loop: NodeJS.Timeout; - let isSpinnerActive: boolean = false; - let _message: string = ""; - - function handleExit(code: number) { - const msg = code > 1 ? "Something went wrong" : "Canceled"; - if (isSpinnerActive) stop(msg, code); - } - - const errorEventHandler = () => handleExit(2); - const signalEventHandler = () => handleExit(1); - - function registerHooks() { - // Reference: https://nodejs.org/api/process.html#event-uncaughtexception - process.on("uncaughtExceptionMonitor", errorEventHandler); - // Reference: https://nodejs.org/api/process.html#event-unhandledrejection - process.on("unhandledRejection", errorEventHandler); - // Reference Signal Events: https://nodejs.org/api/process.html#signal-events - process.on("SIGINT", signalEventHandler); - process.on("SIGTERM", signalEventHandler); - // process.on("exit", handleExit); - } - - function clearHooks() { - process.removeListener("uncaughtExceptionMonitor", errorEventHandler); - process.removeListener("unhandledRejection", errorEventHandler); - process.removeListener("SIGINT", signalEventHandler); - process.removeListener("SIGTERM", signalEventHandler); - // process.removeListener("exit", handleExit); - } - - function start(msg: string = ""): void { - isSpinnerActive = true; - unblock = block(); - _message = msg.replace(/\.+$/, ""); - process.stdout.write(`${color.gray(S_BAR)}\n`); - let frameIndex = 0; - let dotsTimer = 0; - registerHooks(); - loop = setInterval(() => { - const frame = color.magenta(frames[frameIndex]); - const loadingDots = ".".repeat(Math.floor(dotsTimer)).slice(0, 3); - process.stdout.write(cursor.move(-999, 0)); - process.stdout.write(erase.down(1)); - process.stdout.write(`${frame} ${_message}${loadingDots}`); - frameIndex = frameIndex + 1 < frames.length ? frameIndex + 1 : 0; - dotsTimer = dotsTimer < frames.length ? dotsTimer + 0.125 : 0; - }, delay); - } - - function stop(msg: string = "", code: number = 0): void { - _message = msg ?? _message; - isSpinnerActive = false; - clearInterval(loop); - const step = - code === 0 - ? color.green(S_STEP_SUBMIT) - : code === 1 - ? color.red(S_STEP_CANCEL) - : color.red(S_STEP_ERROR); - process.stdout.write(cursor.move(-999, 0)); - process.stdout.write(erase.down(1)); - process.stdout.write(`${step} ${_message}\n`); - clearHooks(); - unblock(); - } - - const message = (msg: string = ""): void => { - _message = msg ?? _message; - }; - - return { - start, - stop, - message, - }; -} - -// Adapted from https://github.com/chalk/ansi-regex -// @see LICENSE -function ansiRegex() { - const pattern = [ - "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", - "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))", - ].join("|"); - - return new RegExp(pattern, "g"); -} - -export type PromptGroupAwaitedReturn = { - [P in keyof T]: Exclude, symbol>; -}; - -export type PromptGroupOptions = { - /** - * Control how the group can be canceled - * if one of the prompts is canceled. - */ - onCancel?: (opts: { results: Prettify>> }) => void; -}; - -type Prettify = { - [P in keyof T]: T[P]; -} & {}; - -export type PromptGroup = { - [P in keyof T]: (opts: { - results: Prettify>>>; - }) => void | Promise; -}; - -/** - * Define a group of prompts to be displayed - * and return a results of objects within the group - */ -export async function group( - prompts: PromptGroup, - opts?: PromptGroupOptions -): Promise>> { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const results = {} as any; - const promptNames = Object.keys(prompts); - - for (const name of promptNames) { - const prompt = prompts[name as keyof T]; - const result = await prompt({ results })?.catch((e) => { - throw e; - }); - - // Pass the results to the onCancel function - // so the user can decide what to do with the results - // TODO: Switch to callback within core to avoid isCancel Fn - if (typeof opts?.onCancel === "function" && isCancel(result)) { - results[name] = "canceled"; - opts.onCancel({ results }); - continue; - } - - results[name] = result; - } - - return results; -} - -export type Task = { - /** - * Task title - */ - title: string; - /** - * Task function - */ - task: (message: (string: string) => void) => string | Promise | void | Promise; - - /** - * If enabled === false the task will be skipped - */ - enabled?: boolean; -}; - -/** - * Define a group of tasks to be executed - */ -export async function tasks(tasks: Task[]) { - for (const task of tasks) { - if (task.enabled === false) continue; - - const s = spinner(); - s.start(task.title); - const result = await task.task(s.message); - s.stop(result || task.title); - } -} diff --git a/packages/cli/src/utils/registry/index.ts b/packages/cli/src/utils/registry/index.ts index 8bd0ccffa6..d2e8bc7427 100644 --- a/packages/cli/src/utils/registry/index.ts +++ b/packages/cli/src/utils/registry/index.ts @@ -1,32 +1,22 @@ +import path from "node:path"; import { fetch } from "node-fetch-native"; import { createProxy } from "node-fetch-native/proxy"; -import path from "node:path"; -import * as v from "valibot"; +import { isUrl, resolveURL } from "../utils.js"; import { CLIError, error } from "../errors.js"; import type { Config } from "../get-config.js"; import { getEnvProxy } from "../get-env-proxy.js"; -import * as schemas from "./schema.js"; -import { SITE_BASE_URL } from "../../constants.js"; - -const baseUrl = `${SITE_BASE_URL}/registry`; +import * as schemas from "@shadcn-svelte/registry"; -export type RegistryItem = v.InferOutput; - -function getRegistryUrl(path: string) { - if (!baseUrl) throw new Error("Registry URL not set"); - - if (isUrl(path)) { - const url = new URL(path); - return url.toString(); - } - return `${baseUrl}/${path}`; +export function getRegistryUrl(config: Config) { + const url = process.env.COMPONENTS_REGISTRY_URL ?? config.registry; + return url; } -export async function getRegistryIndex() { +export async function getRegistryIndex(registryUrl: string) { try { - const [result] = await fetchRegistry(["index.json"]); - - return v.parse(schemas.registryIndexSchema, result); + const url = resolveURL(registryUrl, "index.json"); + const [result] = await fetchRegistry([url]); + return schemas.registryIndexSchema.parse(result); } catch (e) { if (e instanceof CLIError) throw e; throw error(`Failed to fetch components from registry.`); @@ -43,157 +33,162 @@ export function getBaseColors() { ]; } -export function getStyles() { - return [ - { name: "default", label: "Default" }, - { name: "new-york", label: "New York" }, - ]; -} - -export async function getRegistryBaseColor( - baseColor: string, - style: "default" | "new-york" | (string & {}) -) { +export async function getRegistryBaseColor(baseUrl: string, baseColor: string) { try { - const [result] = await fetchRegistry([`${style}/colors/${baseColor}.json`]); + const url = resolveURL(baseUrl, `colors/${baseColor}.json`); + const [result] = await fetchRegistry([url]); - return v.parse(schemas.registryBaseColorSchema, result); + return schemas.registryBaseColorSchema.parse(result); } catch (err) { - throw error(`Failed to fetch base color from registry. Error: ${err}`); + throw error( + `Failed to fetch base color from registry. ${err instanceof Error ? err.message : err}` + ); } } -type RegistryIndex = v.InferOutput; - -type ResolveTreeProps = { - index: RegistryIndex; - names: string[]; - includeRegDeps?: boolean; - config: Config; +type ResolveRegistryItemsProps = { + registryIndex: schemas.RegistryIndex; + items: string[]; + parentUrl?: URL; }; -export async function resolveTree({ - index, - names, - includeRegDeps = true, - config, -}: ResolveTreeProps) { - const tree: RegistryIndex = []; - - for (const name of names) { - let entry = index.find((entry) => entry.name === name); - - if (!entry) { - // attempt to find entry elsewhere in the registry - const trueStyle = config.typescript ? config.style : `${config.style}-js`; - const [item] = await fetchRegistry([`styles/${trueStyle}/${name}.json`]); - if (item) entry = item; - if (!entry) continue; +type ResolvedRegistryItem = schemas.RegistryItem | schemas.RegistryIndexItem; +export async function resolveRegistryItems({ + registryIndex, + items, + parentUrl, +}: ResolveRegistryItemsProps): Promise { + const resolvedItems: ResolvedRegistryItem[] = []; + + for (const item of items) { + let remoteUrl: URL | undefined; + let resolvedItem: ResolvedRegistryItem | undefined = registryIndex.find( + (entry) => entry.name === item + ); + + /** + * The `item` doesn't exist in the registry's `index`, so it can be one of two things: + * 1. a remote registry item (URL) + * 2. a `local:registryDep` of a _remote_ item (relative path from that item to the dep) + */ + if (!resolvedItem) { + const isRelative = item.startsWith("./") || item.startsWith("../"); + if (isUrl(item) || (parentUrl && isRelative)) { + remoteUrl = new URL(item, parentUrl); + const [result] = await fetchRegistry([remoteUrl]); + resolvedItem = schemas.registryItemSchema.parse(result); + } else { + throw error( + `Registry item '${item}' does not exist in the registry, nor is it a valid URL or a relative path to a registry dependency.` + ); + } } - tree.push(entry); + resolvedItems.push(resolvedItem); - if (includeRegDeps && entry.registryDependencies) { - const dependencies = await resolveTree({ - index, - names: entry.registryDependencies, - config, + if (resolvedItem.registryDependencies?.length) { + const registryDeps = await resolveRegistryItems({ + registryIndex: registryIndex, + items: resolvedItem.registryDependencies, + parentUrl: remoteUrl, }); - tree.push(...dependencies); + resolvedItems.push(...registryDeps); } } - return tree.filter( + // dedupes tree + return resolvedItems.filter( (component, index, self) => self.findIndex((c) => c.name === component.name) === index ); } -export async function fetchTree(config: Config, tree: RegistryIndex) { +type FetchTreeProps = { baseUrl: string; items: ResolvedRegistryItem[] }; +export async function fetchRegistryItems({ + baseUrl, + items, +}: FetchTreeProps): Promise { + const itemsWithContent = items.filter((item) => !("relativeUrl" in item)); + const itemsToFetch = items.filter((item) => "relativeUrl" in item); + try { - const trueStyle = config.typescript ? config.style : `${config.style}-js`; - const paths = tree.map((item) => `styles/${trueStyle}/${item.name}.json`); - const result = await fetchRegistry(paths); + const itemUrls = itemsToFetch.map((item) => resolveURL(baseUrl, item.relativeUrl)); + const result = (await fetchRegistry(itemUrls)).concat(itemsWithContent); - return v.parse(schemas.registryWithContentSchema, result); + return schemas.registryItemSchema.array().parse(result); } catch (e) { if (e instanceof CLIError) throw e; throw error(`Failed to fetch tree from registry.`); } } -export function getItemTargetPath( - config: Config, - item: v.InferOutput, - override?: string -) { - // Allow overrides for all items but ui. - if (override && item.type !== "registry:ui") { - return override; - } - - const [, type] = item.type.split(":"); - if (!type || !(type in config.resolvedPaths)) return null; +async function fetchRegistry(urls: Array): Promise { + const proxyUrl = getEnvProxy(); + const proxy = proxyUrl ? createProxy({ url: proxyUrl }) : {}; - return path.join(config.resolvedPaths[type as keyof typeof config.resolvedPaths]); -} + const loaders = urls.map(async (url) => { + const response = await fetch(url, { ...proxy }); + if (!response.ok) { + throw error( + `Failed to fetch registry from ${url}: ${response.status} ${response.statusText}` + ); + } -async function fetchRegistry(paths: string[]) { - if (!baseUrl) throw new Error("Registry URL not set"); + try { + return await response.json(); + } catch (e) { + throw error(`Error parsing json response from ${url}: Error ${e}`); + } + }); - const proxyUrl = getEnvProxy(); - const proxy = proxyUrl ? createProxy({ url: proxyUrl }) : {}; try { - const results = await Promise.all( - paths.map(async (path) => { - const url = getRegistryUrl(path); - - const response = await fetch(url, { - ...proxy, - }); - - if (!response.ok) { - throw error( - `Failed to fetch registry from ${url}: ${response.status} ${response.statusText}` - ); - } - - try { - return await response.json(); - } catch (e) { - throw error(`Error parsing json response from ${url}: Error ${e}`); - } - }) - ); - + const results = await Promise.all(loaders); return results; } catch (e) { if (e instanceof CLIError) throw e; - throw error(`Failed to fetch registry from ${baseUrl}. Error: ${e}`); + throw error(`Failed to fetch registry. ${e instanceof Error ? `Error: ${e.message}` : e}`); } } -function isUrl(path: string) { - try { - new URL(path); - return true; - } catch { - return false; +export function getItemAliasDir(config: Config, type: schemas.RegistryItemType, override?: string) { + if (override) return override; + + if (type === "registry:ui") return config.resolvedPaths.ui; + if (type === "registry:lib") return config.resolvedPaths.lib; + if (type === "registry:hook") return config.resolvedPaths.hooks; + if (type === "registry:file") return config.resolvedPaths.cwd; + + if (type === "registry:block" || type === "registry:component") { + return config.resolvedPaths.components; } + + if (type === "registry:page") { + if (config.sveltekit) return path.resolve(config.resolvedPaths.cwd, "src", "routes"); + + // we'll fallback to components alias + return config.resolvedPaths.components; + } + + throw new Error(`TODO: unhandled item type ${type}`); } -export function getRegistryItemTargetPath( +export function resolveItemFilePath( config: Config, - type: schemas.RegistryItemType, - override?: string -) { - if (override) return override; + item: schemas.RegistryItem, + file: schemas.RegistryItemFile +): string { + // resolves relative to the root (cwd) + if (file.target.startsWith("~/")) { + return path.resolve(config.resolvedPaths.cwd, file.target.replace("~/", "")); + } - if (type === "registry:ui") return config.resolvedPaths.ui; - if (type === "registry:block" || type === "registry:component" || type === "registry:page") { - return config.resolvedPaths.components; + let aliasDir; + if (file.type === "registry:file") { + // resolves relative to the item-type's alias + aliasDir = getItemAliasDir(config, item.type); + } else { + // resolves relative to the file-type's alias + aliasDir = getItemAliasDir(config, file.type); } - if (type === "registry:hook") return config.resolvedPaths.hooks; - // TODO - we put this in components for now but will move to the appropriate route location - // depending on if using SvelteKit or whatever - return config.resolvedPaths.components; + + return path.resolve(aliasDir, file.target); } diff --git a/packages/cli/src/utils/registry/schema.ts b/packages/cli/src/utils/registry/schema.ts deleted file mode 100644 index 055f3fee94..0000000000 --- a/packages/cli/src/utils/registry/schema.ts +++ /dev/null @@ -1,73 +0,0 @@ -import * as v from "valibot"; - -const registryItemTypeSchema = v.picklist([ - "registry:ui", - "registry:component", - "registry:example", - "registry:block", - "registry:hook", - "registry:page", -]); - -export type RegistryItemType = v.InferOutput; - -export const registryItemTailwindSchema = v.object({ - config: v.optional( - v.object({ - content: v.optional(v.array(v.string())), - theme: v.optional(v.record(v.string(), v.any())), - plugins: v.optional(v.array(v.string())), - }) - ), -}); - -export const registryItemFileSchema = v.object({ - content: v.fallback(v.string(), ""), - type: registryItemTypeSchema, -}); - -export const registryItemCssVarsSchema = v.object({ - light: v.optional(v.record(v.string(), v.string())), - dark: v.optional(v.record(v.string(), v.string())), -}); - -export const registryItemSchema = v.object({ - name: v.string(), - dependencies: v.fallback(v.array(v.string()), []), - registryDependencies: v.fallback(v.array(v.string()), []), - files: v.array(registryItemFileSchema), - type: registryItemTypeSchema, - tailwind: v.optional(registryItemTailwindSchema), - cssVars: v.optional(registryItemCssVarsSchema), -}); - -export const registryIndexSchema = v.array(registryItemSchema); - -export const registryItemWithContentSchema = v.object({ - ...registryItemSchema.entries, - ...v.object({ - files: v.array( - v.object({ - name: v.string(), - content: v.string(), - type: registryItemTypeSchema, - target: v.string(), - }) - ), - }).entries, -}); - -export const registryWithContentSchema = v.array(registryItemWithContentSchema); - -export const registryBaseColorSchema = v.object({ - inlineColors: v.object({ - light: v.record(v.string(), v.string()), - dark: v.record(v.string(), v.string()), - }), - cssVars: v.object({ - light: v.record(v.string(), v.string()), - dark: v.record(v.string(), v.string()), - }), - inlineColorsTemplate: v.string(), - cssVarsTemplate: v.string(), -}); diff --git a/packages/cli/src/utils/sveltekit.ts b/packages/cli/src/utils/sveltekit.ts index 3ed8f26b19..821309a665 100644 --- a/packages/cli/src/utils/sveltekit.ts +++ b/packages/cli/src/utils/sveltekit.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { execa } from "execa"; +import { exec } from "tinyexec"; import { detect, resolveCommand } from "package-manager-detector"; import { loadProjectPackageInfo } from "./get-package-info.js"; @@ -11,16 +11,10 @@ export async function syncSvelteKit(cwd: string) { // we'll exit early since syncing is rather slow if (fs.existsSync(path.join(cwd, ".svelte-kit"))) return; - let agent = await detect({ cwd }); + const agent = (await detect({ cwd }))?.agent ?? "npm"; + const cmd = resolveCommand(agent, "execute-local", ["svelte-kit", "sync"])!; - agent ??= { agent: "npm", name: "npm" }; - - const cmd = resolveCommand(agent.agent, "execute-local", ["svelte-kit", "sync"]); - if (cmd) { - await execa(cmd.command, cmd.args, { - cwd, - }); - } + await exec(cmd.command, cmd.args, { throwOnError: true, nodeOptions: { cwd } }); } } diff --git a/packages/cli/src/utils/templates.ts b/packages/cli/src/utils/templates.ts deleted file mode 100644 index 5cfd2a8c7c..0000000000 --- a/packages/cli/src/utils/templates.ts +++ /dev/null @@ -1,126 +0,0 @@ -export const UTILS = `import { type ClassValue, clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); -} -`; - -export const UTILS_JS = `import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs) { - return twMerge(clsx(inputs)); -} -`; - -const TAILWIND_JS = `import { fontFamily } from "tailwindcss/defaultTheme"; -import tailwindcssAnimate from "tailwindcss-animate"; - -/** @type {import('tailwindcss').Config} */ -const config = {`; - -const TAILWIND_TS = `import { fontFamily } from "tailwindcss/defaultTheme"; -import type { Config } from "tailwindcss"; -import tailwindcssAnimate from "tailwindcss-animate"; - -const config: Config = {`; - -const TAILWIND_WITH_VARIABLES = ` - darkMode: ["class"], - content: ["./src/**/*.{html,js,svelte,ts}"], - safelist: ["dark"], - theme: { - container: { - center: true, - padding: "2rem", - screens: { - "2xl": "1400px" - } - }, - extend: { - colors: { - border: "hsl(var(--border) / )", - input: "hsl(var(--input) / )", - ring: "hsl(var(--ring) / )", - background: "hsl(var(--background) / )", - foreground: "hsl(var(--foreground) / )", - primary: { - DEFAULT: "hsl(var(--primary) / )", - foreground: "hsl(var(--primary-foreground) / )" - }, - secondary: { - DEFAULT: "hsl(var(--secondary) / )", - foreground: "hsl(var(--secondary-foreground) / )" - }, - destructive: { - DEFAULT: "hsl(var(--destructive) / )", - foreground: "hsl(var(--destructive-foreground) / )" - }, - muted: { - DEFAULT: "hsl(var(--muted) / )", - foreground: "hsl(var(--muted-foreground) / )" - }, - accent: { - DEFAULT: "hsl(var(--accent) / )", - foreground: "hsl(var(--accent-foreground) / )" - }, - popover: { - DEFAULT: "hsl(var(--popover) / )", - foreground: "hsl(var(--popover-foreground) / )" - }, - card: { - DEFAULT: "hsl(var(--card) / )", - foreground: "hsl(var(--card-foreground) / )" - }, - sidebar: { - DEFAULT: "hsl(var(--sidebar-background))", - foreground: "hsl(var(--sidebar-foreground))", - primary: "hsl(var(--sidebar-primary))", - "primary-foreground": "hsl(var(--sidebar-primary-foreground))", - accent: "hsl(var(--sidebar-accent))", - "accent-foreground": "hsl(var(--sidebar-accent-foreground))", - border: "hsl(var(--sidebar-border))", - ring: "hsl(var(--sidebar-ring))", - }, - }, - borderRadius: { - xl: "calc(var(--radius) + 4px)", - lg: "var(--radius)", - md: "calc(var(--radius) - 2px)", - sm: "calc(var(--radius) - 4px)" - }, - fontFamily: { - sans: [...fontFamily.sans] - }, - keyframes: { - "accordion-down": { - from: { height: "0" }, - to: { height: "var(--bits-accordion-content-height)" }, - }, - "accordion-up": { - from: { height: "var(--bits-accordion-content-height)" }, - to: { height: "0" }, - }, - "caret-blink": { - "0%,70%,100%": { opacity: "1" }, - "20%,50%": { opacity: "0" }, - }, - }, - animation: { - "accordion-down": "accordion-down 0.2s ease-out", - "accordion-up": "accordion-up 0.2s ease-out", - "caret-blink": "caret-blink 1.25s ease-out infinite", - }, - }, - }, - plugins: [tailwindcssAnimate], -}; - -export default config; -`; - -export const TAILWIND_CONFIG_WITH_VARIABLES = { - TS: TAILWIND_TS + TAILWIND_WITH_VARIABLES, - JS: TAILWIND_JS + TAILWIND_WITH_VARIABLES, -}; diff --git a/packages/cli/src/utils/transformers.ts b/packages/cli/src/utils/transformers.ts index e730561e1b..992b5bd7f8 100644 --- a/packages/cli/src/utils/transformers.ts +++ b/packages/cli/src/utils/transformers.ts @@ -1,9 +1,66 @@ +import { parse } from "postcss"; +import { transform } from "sucrase"; +import { strip } from "@svecosystem/strip-types"; +import type { CssVars } from "@shadcn-svelte/registry"; +import { ALIASES, ALIAS_DEFAULTS } from "../constants.js"; +import { updateCssVars, updateTailwindPlugins } from "./updaters.js"; import type { Config } from "./get-config.js"; +const CONSECUTIVE_NEWLINE_REGEX = new RegExp(/^\s\s*\n+/gm); + +export async function transformContent(content: string, filename: string, config: Config) { + content = transformImports(content, config); + + if (!config.typescript) { + content = await stripTypes(content, filename); + } + + return content; +} + export function transformImports(content: string, config: Config) { - let str = content.replace(/\$lib\/registry\/.*\/components/g, config.aliases.components); - str = str.replace(/\$lib\/registry\/.*\/ui/g, config.aliases.ui); - str = str.replace(/\$lib\/registry\/.*\/hook/g, config.aliases.hooks); - str = str.replace(/\$lib\/utils/g, config.aliases.utils); - return str; + for (const alias of ALIASES) { + content = content.replaceAll(ALIAS_DEFAULTS[alias].placeholder, config.aliases[alias]); + } + return content; +} + +export async function stripTypes(content: string, filename: string) { + if (filename.endsWith(".svelte")) { + content = strip(content, { filename }); + } else { + content = transform(content, { transforms: ["typescript"] }).code.trim(); + } + + // cursed formatting + return content.replaceAll(CONSECUTIVE_NEWLINE_REGEX, "\n"); +} + +type TransformCssOptions = { + /** Array of plugin names to update */ + plugins?: string[]; +}; + +export function transformCss( + source: string, + cssVars: CssVars, + options: TransformCssOptions = {} +): string { + const opts = { plugins: [], ...options }; + + // if no CSS variables are provided to update and no plugins, + // we don't need to do anything so we can just return the source + if (Object.keys(cssVars).length === 0 && !opts.plugins.length) return source; + + const ast = parse(source); + + // add plugins if any + if (opts.plugins.length) { + updateTailwindPlugins(ast, opts.plugins); + } + + // update CSS variables/themes + updateCssVars(ast, cssVars); + + return ast.toString(); } diff --git a/packages/cli/src/utils/updaters.ts b/packages/cli/src/utils/updaters.ts new file mode 100644 index 0000000000..9b3bc80cf5 --- /dev/null +++ b/packages/cli/src/utils/updaters.ts @@ -0,0 +1,101 @@ +import { Declaration, Rule, AtRule, Root } from "postcss"; +import type { CssVars } from "@shadcn-svelte/registry"; + +const DARK_SELECTOR = ".dark"; +const LIGHT_SELECTOR = ":root"; + +export function updateCssVars(ast: Root, cssVars: CssVars): void { + // updates colors for `dark` and `light` + if (cssVars.light || cssVars.dark) { + ast.walkRules((rule) => { + if (!rule.selectors.includes(LIGHT_SELECTOR) && !rule.selectors.includes(DARK_SELECTOR)) + return; + + let remainingDark, remainingLight; + if (cssVars.light && rule.selectors.includes(LIGHT_SELECTOR)) { + remainingDark = updateCssRule(rule, cssVars.light); + } + + if (cssVars.dark && rule.selectors.includes(DARK_SELECTOR)) { + remainingLight = updateCssRule(rule, cssVars.dark); + } + + // appends the remaining + for (const [prop, value] of Object.entries(remainingLight ?? remainingDark ?? {})) { + const decl = new Declaration({ prop: `--${prop}`, value }); + rule.append(decl); + } + }); + } + + // updates `@theme` + if (cssVars.theme) { + ast.walkAtRules((atRule) => { + if (atRule.name !== "theme") return; + + // updates existing css vars + const remaining = updateCssRule(atRule, cssVars.theme!); + + // appends the remaining + for (const [prop, value] of Object.entries(remaining)) { + const decl = new Declaration({ prop: `--${prop}`, value }); + atRule.append(decl); + } + }); + } +} + +export function updateTailwindPlugins(ast: Root, plugins: string[]): void { + const foundPlugins: string[] = []; + + /** + * we track the last import and plugin to know where to insert the new ones + * + * this goes like this: + * - if there are existing plugins, we insert after the last plugin + * - if there are no existing plugins and an import exists, we insert after the last import + * - if there are no existing plugins and no import exists, we prepend the new plugins + */ + let lastImport: AtRule | undefined; + let lastPlugin: AtRule | undefined; + + ast.walkAtRules((atRule: AtRule) => { + if (atRule.name === "import") { + lastImport = atRule; + } + if (atRule.name !== "plugin") return; + const pluginName = atRule.params.trim(); + lastPlugin = atRule; + if (plugins.includes(pluginName.replace(/['"]/g, ""))) { + foundPlugins.push(pluginName.replace(/['"]/g, "")); + } + }); + + // add any plugins that don't exist yet + for (const plugin of plugins) { + if (!foundPlugins.includes(plugin)) { + const atRule = new AtRule({ name: "plugin", params: `"${plugin}"` }); + if (lastPlugin) { + lastPlugin.after(atRule); + } else if (lastImport) { + lastImport.after(atRule); + } else { + ast.prepend(atRule); + } + } + } +} + +function updateCssRule(rule: Rule | AtRule, _vars: Record) { + const vars = structuredClone(_vars); + rule.walkDecls((decl) => { + if (!decl.variable) return; + const prop = decl.prop.slice(2); + const value = vars[prop]; + if (value) { + decl.value = value; + delete vars[prop]; + } + }); + return vars; +} diff --git a/packages/cli/src/utils/utils.ts b/packages/cli/src/utils/utils.ts new file mode 100644 index 0000000000..4579d13abe --- /dev/null +++ b/packages/cli/src/utils/utils.ts @@ -0,0 +1,55 @@ +import color from "chalk"; +import { z } from "zod/v4"; +import { error } from "./errors.js"; + +export function isUrl(path: string) { + const result = z.url().safeParse(path); + return result.success; +} + +export const highlight = (...args: unknown[]) => color.bold.cyan(...args); + +/** Adds a trailing slash to the end of the URL, if missing. */ +function normalizeURL(url: URL | string): URL { + if (!(url instanceof URL)) { + url = new URL(url); + } + + if (!url.pathname.endsWith("/")) { + url = new URL(url); + url.pathname = url.pathname + "/"; + } + return url; +} + +export function resolveURL(base: URL | string, path: string): URL { + const url = normalizeURL(base); + return new URL(path, url); +} + +export function parseDependency(dep: string) { + let name: string | undefined = dep; + let version: string | undefined = "latest"; + + if (dep.startsWith("@")) { + if (dep.includes("@", 1)) { + [, name, version] = dep.split(/(.*)(?:@)(.*)/); + } + } else { + if (dep.includes("@", 1)) { + [name, version] = dep.split("@"); + } + } + + if (!name || !version) throw error(`Failed to parse dependency: ${dep}`); + + return { name, version }; +} + +/** Converts a `Set` into an array if its size is greater than 0. Otherwise, `undefined` is returned. */ +export function toArray(set: Set): Array | undefined { + if (set.size > 0) { + return Array.from(set); + } + return undefined; +} diff --git a/packages/cli/test/commands/init.spec.ts b/packages/cli/test/commands/init.spec.ts deleted file mode 100644 index b1501c7534..0000000000 --- a/packages/cli/test/commands/init.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { execa } from "execa"; -import { afterEach, expect, it, vi } from "vitest"; -import { runInit } from "../../src/commands/init"; -import { getConfig } from "../../src/utils/get-config"; -import * as registry from "../../src/utils/registry"; - -vi.mock("execa"); -vi.mock("fs/promises", () => ({ - writeFile: vi.fn(), - mkdir: vi.fn(), - readFile: vi.fn(), -})); -vi.mock("ora"); - -it("init (config-full)", async () => { - vi.spyOn(registry, "getRegistryBaseColor").mockResolvedValue({ - inlineColors: { - light: {}, - dark: {}, - }, - cssVars: { - light: {}, - dark: {}, - }, - inlineColorsTemplate: "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n", - cssVarsTemplate: "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n", - }); - - const mockMkdir = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); - const mockWriteFile = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(); - - const targetDir = path.resolve(__dirname, "../fixtures/config-full"); - const config = await getConfig(targetDir); - if (config === null) throw new Error("config is null"); - - await runInit(targetDir, config, { deps: true, cwd: targetDir }); - - // mkDir mocks - expect(mockMkdir).toHaveBeenNthCalledWith(1, expect.stringContaining("src"), expect.anything()); - // writeFile mocks - expect(mockWriteFile).toHaveBeenNthCalledWith( - 1, - expect.stringContaining("tailwind.config"), - expect.stringContaining(`import { fontFamily } from "tailwindcss/defaultTheme"`), - "utf8" - ); - expect(mockWriteFile).toHaveBeenNthCalledWith( - 2, - expect.stringContaining("app.pcss"), - expect.stringContaining(`@tailwind base`), - "utf8" - ); - expect(mockWriteFile).toHaveBeenNthCalledWith( - 3, - expect.stringContaining("utils.ts"), - expect.stringContaining('import { type ClassValue, clsx } from "clsx"'), - "utf8" - ); - expect(mockMkdir).toHaveBeenNthCalledWith( - 4, - expect.stringContaining(path.join("src", "lib", "components")), - expect.anything() - ); - - expect(execa).toHaveBeenCalledWith( - "pnpm", - ["add", "-D", "tailwind-variants", "clsx", "tailwind-merge", "tailwindcss-animate"], - { cwd: targetDir } - ); - - mockMkdir.mockRestore(); - mockWriteFile.mockRestore(); -}); - -afterEach(() => { - vi.resetAllMocks(); -}); diff --git a/packages/cli/test/commands/init.test.ts b/packages/cli/test/commands/init.test.ts new file mode 100644 index 0000000000..319de2aaf3 --- /dev/null +++ b/packages/cli/test/commands/init.test.ts @@ -0,0 +1,122 @@ +import fs from "node:fs"; +import path from "node:path"; +import { exec } from "tinyexec"; +import { afterEach, expect, it, vi } from "vitest"; +import { runInit } from "../../src/commands/init"; +import { getConfig } from "../../src/utils/get-config"; +import * as registry from "../../src/utils/registry"; + +vi.mock("tinyexec"); +vi.mock("fs/promises", () => ({ + writeFile: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), +})); + +it("init (config-full)", async () => { + vi.spyOn(registry, "getRegistryBaseColor").mockResolvedValue({ + inlineColors: { + light: {}, + dark: {}, + }, + cssVars: { + light: {}, + dark: {}, + }, + inlineColorsTemplate: "@import 'tailwindcss';\n", + cssVarsTemplate: "@import 'tailwindcss';\n", + }); + + vi.spyOn(registry, "resolveRegistryItems").mockResolvedValue([ + { + name: "init", + type: "registry:style", + relativeUrl: "init.json", + }, + ]); + + vi.spyOn(registry, "getRegistryIndex").mockResolvedValue([ + { + name: "init", + type: "registry:style", + devDependencies: ["tailwind-variants", "@lucide/svelte", "tw-animate-css"], + registryDependencies: ["utils"], + relativeUrl: "init.json", + }, + ]); + + vi.spyOn(registry, "fetchRegistryItems").mockResolvedValue([ + { + name: "init", + type: "registry:style", + devDependencies: ["tailwind-variants", "@lucide/svelte", "tw-animate-css"], + registryDependencies: ["utils"], + files: [], + $schema: "https://next.shadcn-svelte.com/schema/registry-item.json", + }, + ]); + + const mockMkdir = vi.spyOn(fs.promises, "mkdir").mockResolvedValue(undefined); + const mockWriteFile = vi.spyOn(fs.promises, "writeFile").mockResolvedValue(); + const mockWriteFileSync = vi.spyOn(fs, "writeFileSync").mockResolvedValue(); + + const targetDir = path.resolve(__dirname, "../fixtures/config-full"); + const config = await getConfig(targetDir); + if (config === null) throw new Error("config is null"); + + await runInit(targetDir, config, { deps: true, cwd: targetDir, overwrite: true }); + + // mkDir mocks + expect(mockMkdir).toHaveBeenNthCalledWith(1, expect.stringContaining("src"), expect.anything()); + expect(mockWriteFile).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("app.css"), + expect.stringContaining(`@import 'tailwindcss'`), + "utf8" + ); + expect(mockMkdir).toHaveBeenNthCalledWith( + 2, + expect.stringContaining(path.join("src", "lib", "components")), + expect.anything() + ); + + expect(mockMkdir).toHaveBeenNthCalledWith( + 3, + expect.stringContaining(path.join("src", "lib", "hooks")), + expect.anything() + ); + + // expect(mockWriteFile).toHaveBeenNthCalledWith( + // 4, + // expect.stringContaining("utils.ts"), + // expect.stringContaining('import { type ClassValue, clsx } from "clsx"'), + // "utf8" + // ); + + expect(mockWriteFileSync).toHaveBeenNthCalledWith( + 1, + expect.stringContaining("components.json"), + expect.stringContaining('"aliases"'), + "utf8" + ); + + // todo: this should be passing no? + // expect(mockWriteFile).toHaveBeenNthCalledWith( + // 2, + // expect.stringContaining("utils"), + // expect.stringContaining("import { clsx") + // ); + + expect(exec).toHaveBeenCalledWith( + "pnpm", + ["add", "-D", "tailwind-variants@latest", "@lucide/svelte@latest", "tw-animate-css@latest"], + { throwOnError: true, nodeOptions: { cwd: targetDir } } + ); + + mockMkdir.mockRestore(); + mockWriteFile.mockRestore(); +}); + +afterEach(() => { + vi.resetAllMocks(); +}); diff --git a/packages/cli/test/fixtures/config-full/components.json b/packages/cli/test/fixtures/config-full/components.json index 71bb26f8f4..4b34c671f9 100644 --- a/packages/cli/test/fixtures/config-full/components.json +++ b/packages/cli/test/fixtures/config-full/components.json @@ -1,16 +1,15 @@ { - "style": "new-york", "tailwind": { - "config": "tailwind.config.js", - "css": "src/app.pcss", + "css": "src/app.css", "baseColor": "zinc" }, "aliases": { "components": "$lib/components", "utils": "$lib/utils", "ui": "$lib/components/ui", - "hooks": "$lib/hooks" + "hooks": "$lib/hooks", + "lib": "$lib" }, "typescript": true, - "registry": "https://tw3.shadcn-svelte.com/registry" + "registry": "https://next.shadcn-svelte.com/registry" } diff --git a/packages/cli/test/fixtures/config-invalid/components.json b/packages/cli/test/fixtures/config-invalid/components.json index febbae2826..a7bab420a2 100644 --- a/packages/cli/test/fixtures/config-invalid/components.json +++ b/packages/cli/test/fixtures/config-invalid/components.json @@ -1,8 +1,7 @@ { - "style": "new-york", - "tailwiknd": { + "tailwind": { "config": "tailwind.config.js", - "css": "src/app.pcss", + "css": "src/app.css", "baseColor": "zinc" }, "alias": { diff --git a/packages/cli/test/fixtures/config-jsconfig/components.json b/packages/cli/test/fixtures/config-jsconfig/components.json index 6e1b1b6f73..e386b0da20 100644 --- a/packages/cli/test/fixtures/config-jsconfig/components.json +++ b/packages/cli/test/fixtures/config-jsconfig/components.json @@ -1,8 +1,7 @@ { - "style": "new-york", "tailwind": { "config": "tailwind.config.js", - "css": "src/app.pcss", + "css": "src/app.css", "baseColor": "zinc" }, "aliases": { @@ -12,5 +11,5 @@ "hooks": "$lib/hooks" }, "typescript": false, - "registry": "https://tw3.shadcn-svelte.com/registry" + "registry": "https://next.shadcn-svelte.com/registry" } diff --git a/packages/cli/test/fixtures/config-partial/components.json b/packages/cli/test/fixtures/config-partial/components.json index 914941faa1..b4176b18e1 100644 --- a/packages/cli/test/fixtures/config-partial/components.json +++ b/packages/cli/test/fixtures/config-partial/components.json @@ -1,8 +1,7 @@ { - "style": "new-york", "tailwind": { "config": "tailwind.config.js", - "css": "src/app.pcss", + "css": "src/app.css", "baseColor": "zinc" }, "aliases": { diff --git a/packages/cli/test/fixtures/config-vite/components.json b/packages/cli/test/fixtures/config-vite/components.json index 392c6c3672..1ed2079d6a 100644 --- a/packages/cli/test/fixtures/config-vite/components.json +++ b/packages/cli/test/fixtures/config-vite/components.json @@ -1,8 +1,7 @@ { - "style": "new-york", "tailwind": { "config": "tailwind.config.js", - "css": "src/app.pcss", + "css": "src/app.css", "baseColor": "zinc" }, "aliases": { diff --git a/packages/cli/test/fixtures/legacy/README.md b/packages/cli/test/fixtures/legacy/README.md new file mode 100644 index 0000000000..d5a8c58159 --- /dev/null +++ b/packages/cli/test/fixtures/legacy/README.md @@ -0,0 +1,56 @@ +# Backward Compatibility Fixtures + +This directory contains fixtures to ensure backward compatibility/continuity with users running Svelte v5 and Tailwind v3. + +## Scenarios + +There are a few scenarios that we need to support: + +### 1. User has an already initialized shadcn-svelte project with Svelte v5 & Tailwind v3 + +This is going to be the most common scenario, as the `next` registry has been receiving a lot more traffic than the `main` registry over the past year, so it's important we don't break users trust in that old `next` registry. + +In this scenario, we want to keep the existing project going as it is, meaning we keep installing the older versions of the components +and their dependencies pinned to the last version of the Tailwind v3/Svelte v5 registry. + +This will ensure that there is not inconsistency between the components that have already been added, and the ones that may be added in the future. + +#### How it works + +These users will have a `components.json` file that looks like this: + +```json +{ + "style": "new-york" // | "default", + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app.pcss", + "baseColor": "zinc" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks" + }, + "typescript": true, + "registry": "https://next.shadcn-svelte.com/registry" +} +``` + +When the user runs `npx shadcn-svelte@next add `, this (should?) fail the schema validation, which we use as the signal to check if the user is running Tailwind v3 in their project. If so, the following should happen: + +1. Pull their `style` property from the `components.json` file, which will be used to point them to the correct registry. +2. Update the `components.json` file to the new format, stripping anything that doesn't need to be there +3. Update the registry URL to point to the appropriate "legacy" registry, which is `https://tw3.shadcn-svelte.com/registry/ diff --git a/sites/docs/src/lib/components/docs/charts/helpers.ts b/sites/docs/src/lib/components/docs/charts/helpers.ts deleted file mode 100644 index 4c50092dd1..0000000000 --- a/sites/docs/src/lib/components/docs/charts/helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -export function color(opacity: string = "1") { - return () => `hsl(var(--primary) / ${opacity})`; -} - -export type Data = { average: number; today: number; id: number }; - -/** - * If you want to set color for multiple lines at once, you'll have to define a colors array in your component and reference colors by index in the accessor function. - * For example, in this instance below, we know that we only have 2 lines, so we can hardcode the colors array to return 2 colors for the 2 indexes i.e. 2 lines. - */ -export function lineColors(_: T, i: number) { - return ["hsl(var(--primary))", "hsl(var(--primary) / 0.25)"][i]; -} - -export function scatterPointColors(_: T, i: number) { - return ["hsl(0, 0%, 100%)", "hsl(var(--primary) / 0.25)"][i]; -} - -export function scatterPointStrokeColors(_: T, i: number) { - return ["hsl(var(--primary))", "hsl(var(--primary) / 0.25)"][i]; -} - -export function crosshairPointColors(_: T, i: number) { - return ["hsl(var(--primary))", "hsl(var(--primary) / 0.25)"][i]; -} - -export function crosshairStrokeWidths(_: T, i: number) { - return [2, 1][i]; -} - -export function tooltipTemplate(d: Data) { - return ` -
-
-
- - Average - - - ${d.average} - -
-
- - Today - - - ${d.today} - -
-
-
-`; -} diff --git a/sites/docs/src/lib/components/docs/charts/index.ts b/sites/docs/src/lib/components/docs/charts/index.ts deleted file mode 100644 index 6cc23495cc..0000000000 --- a/sites/docs/src/lib/components/docs/charts/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { default as Activity } from "./activity.svelte"; -export { default as Bar } from "./bar.svelte"; -export { default as Metric } from "./metric.svelte"; -export { default as Revenue } from "./revenue.svelte"; -export { default as Subscription } from "./subscription.svelte"; diff --git a/sites/docs/src/lib/components/docs/charts/metric.svelte b/sites/docs/src/lib/components/docs/charts/metric.svelte deleted file mode 100644 index 369c2405c6..0000000000 --- a/sites/docs/src/lib/components/docs/charts/metric.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - diff --git a/sites/docs/src/lib/components/docs/charts/revenue.svelte b/sites/docs/src/lib/components/docs/charts/revenue.svelte deleted file mode 100644 index 818c7637b5..0000000000 --- a/sites/docs/src/lib/components/docs/charts/revenue.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - diff --git a/sites/docs/src/lib/components/docs/charts/subscription.svelte b/sites/docs/src/lib/components/docs/charts/subscription.svelte deleted file mode 100644 index 43234cc87b..0000000000 --- a/sites/docs/src/lib/components/docs/charts/subscription.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/sites/docs/src/lib/components/docs/command-menu.svelte b/sites/docs/src/lib/components/docs/command-menu.svelte index 24529a6644..eaa4141923 100644 --- a/sites/docs/src/lib/components/docs/command-menu.svelte +++ b/sites/docs/src/lib/components/docs/command-menu.svelte @@ -1,13 +1,13 @@ {#snippet ExampleFallback()} {#if component} - {#await component} -
- - Loading... -
- {:then Component} - - {:catch} -

- Component - - {name} - - not found in registry. -

- {/await} + {@const Component = component} + + {:else} +

+ Component + + {name} + + not found in registry. +

{/if} {/snippet}
- +
- - - Preview - - - Code - - + {#if !hideCode} + + + Preview + + + Code + + + {/if}
- -
- -
+
- - + +
- - + +
diff --git a/sites/docs/src/lib/components/docs/copy-button.svelte b/sites/docs/src/lib/components/docs/copy-button.svelte index 99f92e2b40..e3d497ff5e 100644 --- a/sites/docs/src/lib/components/docs/copy-button.svelte +++ b/sites/docs/src/lib/components/docs/copy-button.svelte @@ -1,64 +1,37 @@ diff --git a/sites/docs/src/lib/components/docs/dashboard/dashboard-page.svelte b/sites/docs/src/lib/components/docs/dashboard/dashboard-page.svelte index b3de48864d..6787c26891 100644 --- a/sites/docs/src/lib/components/docs/dashboard/dashboard-page.svelte +++ b/sites/docs/src/lib/components/docs/dashboard/dashboard-page.svelte @@ -1,9 +1,9 @@
@@ -38,7 +38,7 @@
@@ -57,7 +57,7 @@ class="flex flex-row items-center justify-between space-y-0 pb-2" > Total Revenue - +
$45,231.89
@@ -69,7 +69,7 @@ class="flex flex-row items-center justify-between space-y-0 pb-2" > Subscriptions - +
+2350
@@ -81,7 +81,7 @@ class="flex flex-row items-center justify-between space-y-0 pb-2" > Sales - +
+12,234
@@ -93,7 +93,7 @@ class="flex flex-row items-center justify-between space-y-0 pb-2" > Active Now - +
+573
diff --git a/sites/docs/src/lib/components/docs/dashboard/overview.svelte b/sites/docs/src/lib/components/docs/dashboard/overview.svelte index 222686e52e..d0c7e3a481 100644 --- a/sites/docs/src/lib/components/docs/dashboard/overview.svelte +++ b/sites/docs/src/lib/components/docs/dashboard/overview.svelte @@ -1,5 +1,85 @@ - + + ({ ...d, index: i }))} + xScale={scaleBand().padding(0.21)} + x="name" + y="total" + tooltip={false} + grid={false} + padding={{ left: 40 }} + props={{ + yAxis: { + format: (v) => + // remove decimals from end + new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + }).format(v), + }, + }} + /> + diff --git a/sites/docs/src/lib/components/docs/dashboard/recent-sales.svelte b/sites/docs/src/lib/components/docs/dashboard/recent-sales.svelte index 7540f374bf..bc18777718 100644 --- a/sites/docs/src/lib/components/docs/dashboard/recent-sales.svelte +++ b/sites/docs/src/lib/components/docs/dashboard/recent-sales.svelte @@ -1,61 +1,57 @@
-
- - - OM - -
-

Olivia Martin

-

olivia.martin@email.com

-
-
+$1,999.00
-
-
- - - JL - -
-

Jackson Lee

-

jackson.lee@email.com

-
-
+$39.00
-
-
- - - IN - -
-

Isabella Nguyen

-

isabella.nguyen@email.com

-
-
+$299.00
-
-
- - - WK - -
-

William Kim

-

will@email.com

-
-
+$99.00
-
-
- - - SD - -
-

Sofia Davis

-

sofia.davis@email.com

+ {#each sales as sale (sale.name)} +
+ + + + {sale.name + .split(" ") + .map((n) => n[0]) + .join("")} + + +
+

{sale.name}

+

{sale.email}

+
+
+ {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(sale.amount)} +
-
+$39.00
-
+ {/each}
diff --git a/sites/docs/src/lib/components/docs/dashboard/search.svelte b/sites/docs/src/lib/components/docs/dashboard/search.svelte index 853620da66..46faf68dfb 100644 --- a/sites/docs/src/lib/components/docs/dashboard/search.svelte +++ b/sites/docs/src/lib/components/docs/dashboard/search.svelte @@ -1,5 +1,5 @@
diff --git a/sites/docs/src/lib/components/docs/dashboard/team-switcher.svelte b/sites/docs/src/lib/components/docs/dashboard/team-switcher.svelte index 3f20e2e2d6..663f529e64 100644 --- a/sites/docs/src/lib/components/docs/dashboard/team-switcher.svelte +++ b/sites/docs/src/lib/components/docs/dashboard/team-switcher.svelte @@ -1,17 +1,17 @@ @@ -16,12 +16,12 @@ - +

shadcn

m@example.com

-
+ diff --git a/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-content.svelte b/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-content.svelte new file mode 100644 index 0000000000..8e17c689f5 --- /dev/null +++ b/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-content.svelte @@ -0,0 +1,15 @@ + + + diff --git a/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-list.svelte b/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-list.svelte new file mode 100644 index 0000000000..2368dc4dd5 --- /dev/null +++ b/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-list.svelte @@ -0,0 +1,15 @@ + + + diff --git a/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-trigger.svelte b/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-trigger.svelte new file mode 100644 index 0000000000..6425533516 --- /dev/null +++ b/sites/docs/src/lib/components/docs/doc-tabs/doc-tabs-trigger.svelte @@ -0,0 +1,15 @@ + + + diff --git a/sites/docs/src/lib/components/docs/doc-tabs/index.ts b/sites/docs/src/lib/components/docs/doc-tabs/index.ts new file mode 100644 index 0000000000..ba6a49b5ce --- /dev/null +++ b/sites/docs/src/lib/components/docs/doc-tabs/index.ts @@ -0,0 +1,7 @@ +import { Tabs as TabsPrimitive } from "bits-ui"; + +export { default as List } from "./doc-tabs-list.svelte"; +export { default as Trigger } from "./doc-tabs-trigger.svelte"; +export { default as Content } from "./doc-tabs-content.svelte"; + +export const Root = TabsPrimitive.Root as typeof TabsPrimitive.Root; diff --git a/sites/docs/src/lib/components/docs/docs-nav/docs-nav-items.svelte b/sites/docs/src/lib/components/docs/docs-nav/docs-nav-items.svelte new file mode 100644 index 0000000000..b0756a5231 --- /dev/null +++ b/sites/docs/src/lib/components/docs/docs-nav/docs-nav-items.svelte @@ -0,0 +1,53 @@ + + +{#if items.length} +
+ {#each items as item, index (index)} + {#if item.href && !item.disabled} + + {item.title} + {#if item.label} + + {item.label} + + {/if} + + {:else} + + {item.title} + {#if item.label} + + {item.label} + + {/if} + + {/if} + {/each} +
+{/if} diff --git a/sites/docs/src/lib/components/docs/docs-nav/docs-nav.svelte b/sites/docs/src/lib/components/docs/docs-nav/docs-nav.svelte new file mode 100644 index 0000000000..0056a79128 --- /dev/null +++ b/sites/docs/src/lib/components/docs/docs-nav/docs-nav.svelte @@ -0,0 +1,28 @@ + + +{#if config.sidebarNav.length} +
+ {#each config.sidebarNav as item, index (index)} +
+

+ {item.title} + {#if item.label} + + {item.label} + + {/if} +

+ {#if item.items?.length} + + {/if} +
+ {/each} +
+{/if} diff --git a/sites/docs/src/lib/components/docs/docs-pager.svelte b/sites/docs/src/lib/components/docs/docs-pager.svelte index 7385133c72..ea97bde044 100644 --- a/sites/docs/src/lib/components/docs/docs-pager.svelte +++ b/sites/docs/src/lib/components/docs/docs-pager.svelte @@ -1,9 +1,9 @@
-
- -
- {#each examples as example, index (index)} - {@const isActive = - page.url.pathname.startsWith(example.href) || - (page.url.pathname === "/" && index === 0)} - - - {#if isActive} -
- {/if} -
- {example.name} - {#if example.label} - - {example.label} - - {/if} -
-
- {/each} -
-
-
+ +
+ {@render ExampleLink({ + example: { name: "Examples", href: "/", code: "", hidden: false }, + isActive: page.url.pathname === "/", + })} + {#each examples as example (example.href)} + {@render ExampleLink({ + example, + isActive: page.url.pathname.startsWith(example.href), + })} + {/each} +
+
+ +{#snippet ExampleLink({ + example, + isActive, +}: { + example: (typeof examples)[number]; + isActive: boolean; +})} + {#if !example.hidden} + + {example.name} + + {/if} +{/snippet} diff --git a/sites/docs/src/lib/components/docs/forms/form-preview.svelte b/sites/docs/src/lib/components/docs/forms/form-preview.svelte index e446b76d7a..8d48535332 100644 --- a/sites/docs/src/lib/components/docs/forms/form-preview.svelte +++ b/sites/docs/src/lib/components/docs/forms/form-preview.svelte @@ -1,18 +1,12 @@ - {#if $config.style === "new-york"} - - {:else} - - {/if} + diff --git a/sites/docs/src/lib/components/docs/hex-to-channels.svelte b/sites/docs/src/lib/components/docs/hex-to-channels.svelte deleted file mode 100644 index 2609dddf0f..0000000000 --- a/sites/docs/src/lib/components/docs/hex-to-channels.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - -
-
-
- - -
-
- - - -
-
- - - -
-
-
diff --git a/sites/docs/src/lib/components/docs/icons/hamburger.svelte b/sites/docs/src/lib/components/docs/icons/hamburger.svelte deleted file mode 100644 index b3f5be3c94..0000000000 --- a/sites/docs/src/lib/components/docs/icons/hamburger.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - diff --git a/sites/docs/src/lib/components/docs/icons/index.ts b/sites/docs/src/lib/components/docs/icons/index.ts deleted file mode 100644 index 790ae7b097..0000000000 --- a/sites/docs/src/lib/components/docs/icons/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { default as Apple } from "./apple.svelte"; -export { default as GitHub } from "./github.svelte"; -export { default as Google } from "./google.svelte"; -export { default as Logo } from "./logo.svelte"; -export { default as PayPal } from "./paypal.svelte"; -export { default as Twitter } from "./twitter.svelte"; -export { default as Spinner } from "./spinner.svelte"; diff --git a/sites/docs/src/lib/components/docs/icons/svelte-logo.svelte b/sites/docs/src/lib/components/docs/icons/svelte-logo.svelte deleted file mode 100644 index d1f22def64..0000000000 --- a/sites/docs/src/lib/components/docs/icons/svelte-logo.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - - diff --git a/sites/docs/src/lib/components/docs/icons/twitter.svelte b/sites/docs/src/lib/components/docs/icons/twitter.svelte deleted file mode 100644 index f89c1d2752..0000000000 --- a/sites/docs/src/lib/components/docs/icons/twitter.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/sites/docs/src/lib/components/docs/index.ts b/sites/docs/src/lib/components/docs/index.ts index 9e08179a78..a580d3c052 100644 --- a/sites/docs/src/lib/components/docs/index.ts +++ b/sites/docs/src/lib/components/docs/index.ts @@ -10,7 +10,9 @@ export { default as PMCreate } from "./pm-create.svelte"; export { default as PMInstall } from "./pm-install.svelte"; export { default as PMRemove } from "./pm-remove.svelte"; export { default as PMRun } from "./pm-run.svelte"; +export { default as PMUpgrade } from "./pm-upgrade.svelte"; export { default as InstallTabs } from "./install-tabs.svelte"; +export { default as InstallCards } from "./install-cards.svelte"; export * from "./page-header/index.js"; export * from "./forms/index.js"; diff --git a/sites/docs/src/lib/components/docs/install-cards.svelte b/sites/docs/src/lib/components/docs/install-cards.svelte new file mode 100644 index 0000000000..39ac61d781 --- /dev/null +++ b/sites/docs/src/lib/components/docs/install-cards.svelte @@ -0,0 +1,42 @@ + + +
+ + +

SvelteKit

+
+ + Astro +

Astro

+
+ + Vite +

Vite

+
+ + +

Manual

+
+
diff --git a/sites/docs/src/lib/components/docs/install-tabs.svelte b/sites/docs/src/lib/components/docs/install-tabs.svelte index 1f8c31d71b..5022349e09 100644 --- a/sites/docs/src/lib/components/docs/install-tabs.svelte +++ b/sites/docs/src/lib/components/docs/install-tabs.svelte @@ -1,19 +1,27 @@ - - - CLI - Manual - - + + + CLI + Manual + + {@render cli?.()} - - + + {@render manual?.()} - - + + diff --git a/sites/docs/src/lib/components/docs/linked-card.svelte b/sites/docs/src/lib/components/docs/linked-card.svelte index cfd144ff19..4bf49b6646 100644 --- a/sites/docs/src/lib/components/docs/linked-card.svelte +++ b/sites/docs/src/lib/components/docs/linked-card.svelte @@ -7,7 +7,7 @@ import type { Snippet } from "svelte"; - import * as Accordion from "$lib/registry/new-york/ui/accordion/index.js"; + import * as Accordion from "$lib/registry/ui/accordion/index.js"; let { children }: { children?: Snippet } = $props(); diff --git a/sites/docs/src/lib/components/docs/markdown/blueprint.svelte b/sites/docs/src/lib/components/docs/markdown/blueprint.svelte index 9872a63bb2..1aa3a49fe5 100644 --- a/sites/docs/src/lib/components/docs/markdown/blueprint.svelte +++ b/sites/docs/src/lib/components/docs/markdown/blueprint.svelte @@ -1,5 +1,5 @@ + + diff --git a/sites/docs/src/lib/components/docs/markdown/h1.svelte b/sites/docs/src/lib/components/docs/markdown/h1.svelte index 6aec3bb993..6d2fc97783 100644 --- a/sites/docs/src/lib/components/docs/markdown/h1.svelte +++ b/sites/docs/src/lib/components/docs/markdown/h1.svelte @@ -4,6 +4,6 @@ let { class: className, children, ...restProps }: PrimitiveElementAttributes = $props(); -

+

{@render children?.()}

diff --git a/sites/docs/src/lib/components/docs/markdown/h2.svelte b/sites/docs/src/lib/components/docs/markdown/h2.svelte index c2fbf4c65e..a18282377f 100644 --- a/sites/docs/src/lib/components/docs/markdown/h2.svelte +++ b/sites/docs/src/lib/components/docs/markdown/h2.svelte @@ -6,7 +6,7 @@

{@render children?.()} diff --git a/sites/docs/src/lib/components/docs/markdown/p.svelte b/sites/docs/src/lib/components/docs/markdown/p.svelte index d89850bd62..0867170fc6 100644 --- a/sites/docs/src/lib/components/docs/markdown/p.svelte +++ b/sites/docs/src/lib/components/docs/markdown/p.svelte @@ -4,6 +4,6 @@ let { class: className, children, ...restProps }: PrimitiveElementAttributes = $props(); -

+

{@render children?.()}

diff --git a/sites/docs/src/lib/components/docs/markdown/pre.svelte b/sites/docs/src/lib/components/docs/markdown/pre.svelte index b57ee822fd..7094c62ece 100644 --- a/sites/docs/src/lib/components/docs/markdown/pre.svelte +++ b/sites/docs/src/lib/components/docs/markdown/pre.svelte @@ -18,10 +18,10 @@
 	{@render children?.()}
 
- + diff --git a/sites/docs/src/lib/components/docs/markdown/table.svelte b/sites/docs/src/lib/components/docs/markdown/table.svelte index 0669bb51f8..d7eaa47edb 100644 --- a/sites/docs/src/lib/components/docs/markdown/table.svelte +++ b/sites/docs/src/lib/components/docs/markdown/table.svelte @@ -4,12 +4,9 @@ let { class: className, children, ...restProps }: PrimitiveElementAttributes = $props(); -
+
{@render children?.()} diff --git a/sites/docs/src/lib/components/docs/markdown/td.svelte b/sites/docs/src/lib/components/docs/markdown/td.svelte index 1ab8c7a1c5..d6468bffec 100644 --- a/sites/docs/src/lib/components/docs/markdown/td.svelte +++ b/sites/docs/src/lib/components/docs/markdown/td.svelte @@ -6,7 +6,7 @@ + {@render children?.()} diff --git a/sites/docs/src/lib/components/docs/mode-switcher.svelte b/sites/docs/src/lib/components/docs/mode-switcher.svelte new file mode 100644 index 0000000000..21eeb8cd28 --- /dev/null +++ b/sites/docs/src/lib/components/docs/mode-switcher.svelte @@ -0,0 +1,15 @@ + + + diff --git a/sites/docs/src/lib/components/docs/mode-toggle.svelte b/sites/docs/src/lib/components/docs/mode-toggle.svelte deleted file mode 100644 index 6392e408fb..0000000000 --- a/sites/docs/src/lib/components/docs/mode-toggle.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - Toggle theme - - - setMode("light")}>Light - setMode("dark")}>Dark - resetMode()}>System - - diff --git a/sites/docs/src/lib/components/docs/nav/main-nav.svelte b/sites/docs/src/lib/components/docs/nav/main-nav.svelte index e66244a91d..cdfa2ed7b3 100644 --- a/sites/docs/src/lib/components/docs/nav/main-nav.svelte +++ b/sites/docs/src/lib/components/docs/nav/main-nav.svelte @@ -1,23 +1,25 @@ diff --git a/sites/docs/src/lib/components/docs/steps.svelte b/sites/docs/src/lib/components/docs/steps.svelte index 0cfc72b222..dc496e01bd 100644 --- a/sites/docs/src/lib/components/docs/steps.svelte +++ b/sites/docs/src/lib/components/docs/steps.svelte @@ -4,6 +4,9 @@ let { children, ...restProps }: PrimitiveDivAttributes = $props(); -
+
{@render children?.()}
diff --git a/sites/docs/src/lib/components/docs/style-switcher.svelte b/sites/docs/src/lib/components/docs/style-switcher.svelte deleted file mode 100644 index 386f0e1d01..0000000000 --- a/sites/docs/src/lib/components/docs/style-switcher.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - value, - (v) => { - if (!isStyle(v)) return; - value = v; - } - } -> - - Style: - {styleLabel} - - - {#each styles as style (style.name)} - - {/each} - - diff --git a/sites/docs/src/lib/components/docs/style-wrapper.svelte b/sites/docs/src/lib/components/docs/style-wrapper.svelte deleted file mode 100644 index 149c386cb4..0000000000 --- a/sites/docs/src/lib/components/docs/style-wrapper.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -{#if !styleName || $config.style === styleName} - {@render children?.()} -{/if} diff --git a/sites/docs/src/lib/components/docs/table-of-contents.svelte b/sites/docs/src/lib/components/docs/table-of-contents.svelte index c4e8e830af..22d78469b8 100644 --- a/sites/docs/src/lib/components/docs/table-of-contents.svelte +++ b/sites/docs/src/lib/components/docs/table-of-contents.svelte @@ -46,7 +46,8 @@ } else if ( level === 3 && currentLevel?.items && - !heading.hasAttribute("data-toc-ignore") + !heading.hasAttribute("data-toc-ignore") && + !heading.closest("[data-manual-install]") ) { currentLevel.items.push(item); } diff --git a/sites/docs/src/lib/components/docs/tabs/tabs-content.svelte b/sites/docs/src/lib/components/docs/tabs/tabs-content.svelte index bfd7a462c1..336009dbd8 100644 --- a/sites/docs/src/lib/components/docs/tabs/tabs-content.svelte +++ b/sites/docs/src/lib/components/docs/tabs/tabs-content.svelte @@ -1,5 +1,5 @@ -{#if activeTheme} - - - {#snippet child({ props })} - - {/snippet} - - - - Theme - - Copy and paste the following code into your CSS file. - - - - - {#if activeTheme} - - {/if} - - - -{/if} + + + {#snippet child({ props })} + + {/snippet} + + + + Theme + + Copy and paste the following code into your CSS file. + + + + + + + + + + {#snippet child({ props })} + + {/snippet} + + + + Theme + + Copy and paste the following code into your CSS file. + + + + + + + diff --git a/sites/docs/src/lib/components/docs/theme-customizer/customizer-code.svelte b/sites/docs/src/lib/components/docs/theme-customizer/customizer-code.svelte index 6263580771..8a59f6c481 100644 --- a/sites/docs/src/lib/components/docs/theme-customizer/customizer-code.svelte +++ b/sites/docs/src/lib/components/docs/theme-customizer/customizer-code.svelte @@ -1,85 +1,80 @@ + + -
+ +
-            
-                @layer base {
-                  :root {
-                    --background: {activeTheme?.cssVars.light
-						.background};
-                --foreground: {activeTheme?.cssVars.light
-						.foreground};
-            {#each prefixes as prefix (prefix)}
-					    --{prefix}: {activeTheme?.cssVars.light[
-							prefix
-						]};
-                  --{prefix}-foreground: {activeTheme?.cssVars
-							.light[`${prefix}-foreground`]};
+			class="flex max-h-[450px] flex-col overflow-x-auto rounded-lg border bg-zinc-950 py-4 dark:bg-zinc-900">
+			
+			   :root {
+			     --radius: {config.current.radius}rem;
+			  {#each Object.entries(activeThemeOKLCH?.light) as [key, value] (key)}
+					   --{key}: {value};
 				{/each}
-                    --border: {activeTheme?.cssVars.light
-						.border};
-                      --input: {activeTheme?.cssVars.light
-						.input};
-                      --ring: {activeTheme?.cssVars.light.ring};
-                      --radius: {$config.radius}rem;
-                    }
-                  
-                    .dark {
-                      --background: {activeTheme?.cssVars.dark
-						.background};
-                      --foreground: {activeTheme?.cssVars.dark
-						.foreground};
-                  {#each prefixes as prefix (prefix)}
-					    --{prefix}: {activeTheme?.cssVars.dark[
-							prefix
-						]};
-                --{prefix}-foreground: {activeTheme?.cssVars
-							.dark[`${prefix}-foreground`]};
+			   }
+			   
+			   .dark {
+			  {#each Object.entries(activeThemeOKLCH?.dark) as [key, value] (key)}
+					   --{key}: {value};
 				{/each}
-                    --border: {activeTheme?.cssVars.dark
-						.border};
-                      --input: {activeTheme?.cssVars.dark
-						.input};
-                      --ring: {activeTheme?.cssVars.dark.ring};
-                    }
-                  }
-            
-        
-
+  } + + +
diff --git a/sites/docs/src/lib/components/docs/theme-customizer/customizer.svelte b/sites/docs/src/lib/components/docs/theme-customizer/customizer.svelte index 771a68d624..db51fd43ab 100644 --- a/sites/docs/src/lib/components/docs/theme-customizer/customizer.svelte +++ b/sites/docs/src/lib/components/docs/theme-customizer/customizer.svelte @@ -1,157 +1,88 @@ - -
-
-
Customize
-
- Pick a style and color for your components. -
-
- -
-
-
-
- - - - - About styles - - -

- What is the difference between the New York and Default style? -

-

- A style comes with its own set of components, animations, icons and - more. -

-

- The Default style has larger inputs. -

-

- The New York style ships with smaller buttons - and cards with shadows. -

-
-
-
-
- - -
-
-
- -
- {#each themes as theme (theme.name)} - {@const isActive = $config.theme === theme.name} - + + {#if isActive} + + {/if} + + + + {:else} + + {/if} {/each}
-
- -
- {#each ["0", "0.3", "0.5", "0.75", "1.0"] as value, _ (value)} - {@const valueFloat = Number.parseFloat(value)} +
+ {@render children?.()} + diff --git a/sites/docs/src/lib/registry/ui/table/table-caption.svelte b/sites/docs/src/lib/registry/ui/table/table-caption.svelte new file mode 100644 index 0000000000..4696cff571 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table-caption.svelte @@ -0,0 +1,20 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/table/table-cell.svelte b/sites/docs/src/lib/registry/ui/table/table-cell.svelte new file mode 100644 index 0000000000..4854a66781 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table-cell.svelte @@ -0,0 +1,23 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/table/table-footer.svelte b/sites/docs/src/lib/registry/ui/table/table-footer.svelte new file mode 100644 index 0000000000..b9b14ebfac --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table-footer.svelte @@ -0,0 +1,20 @@ + + +tr]:last:border-b-0", className)} + {...restProps} +> + {@render children?.()} + diff --git a/sites/docs/src/lib/registry/ui/table/table-head.svelte b/sites/docs/src/lib/registry/ui/table/table-head.svelte new file mode 100644 index 0000000000..b2d5962569 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table-head.svelte @@ -0,0 +1,23 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/table/table-header.svelte b/sites/docs/src/lib/registry/ui/table/table-header.svelte new file mode 100644 index 0000000000..f47d2597cf --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table-header.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/sites/docs/src/lib/registry/ui/table/table-row.svelte b/sites/docs/src/lib/registry/ui/table/table-row.svelte new file mode 100644 index 0000000000..8829581c98 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table-row.svelte @@ -0,0 +1,23 @@ + + + + {@render children?.()} + diff --git a/sites/docs/src/lib/registry/ui/table/table.svelte b/sites/docs/src/lib/registry/ui/table/table.svelte new file mode 100644 index 0000000000..a334956324 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/table/table.svelte @@ -0,0 +1,22 @@ + + +
+
-
+ {@render children?.()} +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...restProps} +> + {@render children?.()} +
[role=checkbox]]:translate-y-[2px]", + className + )} + {...restProps} +> + {@render children?.()} +
+ {@render children?.()} +
+
diff --git a/sites/docs/src/lib/registry/ui/tabs/index.ts b/sites/docs/src/lib/registry/ui/tabs/index.ts new file mode 100644 index 0000000000..12d4327aaa --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/sites/docs/src/lib/registry/ui/tabs/tabs-content.svelte b/sites/docs/src/lib/registry/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000000..340d65cf21 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/tabs/tabs-list.svelte b/sites/docs/src/lib/registry/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000000..08932b60e7 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tabs/tabs-list.svelte @@ -0,0 +1,20 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/tabs/tabs-trigger.svelte b/sites/docs/src/lib/registry/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000000..dced992e5a --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/tabs/tabs.svelte b/sites/docs/src/lib/registry/ui/tabs/tabs.svelte new file mode 100644 index 0000000000..ef6cada5e0 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/sites/docs/src/lib/registry/default/ui/textarea/index.ts b/sites/docs/src/lib/registry/ui/textarea/index.ts similarity index 100% rename from sites/docs/src/lib/registry/default/ui/textarea/index.ts rename to sites/docs/src/lib/registry/ui/textarea/index.ts diff --git a/sites/docs/src/lib/registry/ui/textarea/textarea.svelte b/sites/docs/src/lib/registry/ui/textarea/textarea.svelte new file mode 100644 index 0000000000..545b377b0e --- /dev/null +++ b/sites/docs/src/lib/registry/ui/textarea/textarea.svelte @@ -0,0 +1,22 @@ + + + diff --git a/sites/docs/src/lib/registry/default/ui/toggle-group/index.ts b/sites/docs/src/lib/registry/ui/toggle-group/index.ts similarity index 100% rename from sites/docs/src/lib/registry/default/ui/toggle-group/index.ts rename to sites/docs/src/lib/registry/ui/toggle-group/index.ts diff --git a/sites/docs/src/lib/registry/ui/toggle-group/toggle-group-item.svelte b/sites/docs/src/lib/registry/ui/toggle-group/toggle-group-item.svelte new file mode 100644 index 0000000000..64ecefa49c --- /dev/null +++ b/sites/docs/src/lib/registry/ui/toggle-group/toggle-group-item.svelte @@ -0,0 +1,34 @@ + + + diff --git a/sites/docs/src/lib/registry/ui/toggle-group/toggle-group.svelte b/sites/docs/src/lib/registry/ui/toggle-group/toggle-group.svelte new file mode 100644 index 0000000000..f2bb956e49 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/toggle-group/toggle-group.svelte @@ -0,0 +1,47 @@ + + + + + + diff --git a/sites/docs/src/lib/registry/default/ui/toggle/index.ts b/sites/docs/src/lib/registry/ui/toggle/index.ts similarity index 100% rename from sites/docs/src/lib/registry/default/ui/toggle/index.ts rename to sites/docs/src/lib/registry/ui/toggle/index.ts diff --git a/sites/docs/src/lib/registry/ui/toggle/toggle.svelte b/sites/docs/src/lib/registry/ui/toggle/toggle.svelte new file mode 100644 index 0000000000..d3b35f026a --- /dev/null +++ b/sites/docs/src/lib/registry/ui/toggle/toggle.svelte @@ -0,0 +1,52 @@ + + + + + diff --git a/sites/docs/src/lib/registry/ui/tooltip/index.ts b/sites/docs/src/lib/registry/ui/tooltip/index.ts new file mode 100644 index 0000000000..313a7f069c --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tooltip/index.ts @@ -0,0 +1,21 @@ +import { Tooltip as TooltipPrimitive } from "bits-ui"; +import Trigger from "./tooltip-trigger.svelte"; +import Content from "./tooltip-content.svelte"; + +const Root = TooltipPrimitive.Root; +const Provider = TooltipPrimitive.Provider; +const Portal = TooltipPrimitive.Portal; + +export { + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, +}; diff --git a/sites/docs/src/lib/registry/ui/tooltip/tooltip-content.svelte b/sites/docs/src/lib/registry/ui/tooltip/tooltip-content.svelte new file mode 100644 index 0000000000..a279295346 --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tooltip/tooltip-content.svelte @@ -0,0 +1,47 @@ + + + + + {@render children?.()} + + {#snippet child({ props })} +
+ {/snippet} +
+
+
diff --git a/sites/docs/src/lib/registry/ui/tooltip/tooltip-trigger.svelte b/sites/docs/src/lib/registry/ui/tooltip/tooltip-trigger.svelte new file mode 100644 index 0000000000..1acdaa47be --- /dev/null +++ b/sites/docs/src/lib/registry/ui/tooltip/tooltip-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/sites/docs/src/lib/stores/config.ts b/sites/docs/src/lib/stores/config.ts deleted file mode 100644 index 0d78736d7e..0000000000 --- a/sites/docs/src/lib/stores/config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { persisted } from "svelte-persisted-store"; - -import type { Style } from "$lib/registry/styles.js"; -import type { Theme } from "$lib/registry/themes.js"; - -type Config = { - style: Style["name"]; - theme: Theme["name"]; - radius: number; -}; - -export const config = persisted("config", { - style: "default", - theme: "zinc", - radius: 0.5, -}); diff --git a/sites/docs/src/lib/stores/index.ts b/sites/docs/src/lib/stores/index.ts deleted file mode 100644 index d1bc97e809..0000000000 --- a/sites/docs/src/lib/stores/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./config.js"; diff --git a/sites/docs/src/lib/stores/package-manager.ts b/sites/docs/src/lib/stores/package-manager.ts deleted file mode 100644 index 1a880d00f5..0000000000 --- a/sites/docs/src/lib/stores/package-manager.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { getContext, setContext } from "svelte"; -import { persisted } from "svelte-persisted-store"; -import type { Agent, Command, ResolvedCommand } from "package-manager-detector"; -import { resolveCommand } from "package-manager-detector/commands"; - -export const PACKAGE_MANAGERS: Agent[] = ["pnpm", "npm", "bun", "yarn"] as const; - -const PACKAGE_MANAGER = Symbol("packageManager"); - -export function setPackageManager(initialValue: Agent = "npm") { - const packageManager = createPackageManagerStore("packageManager", initialValue); - setContext(PACKAGE_MANAGER, packageManager); - return packageManager; -} - -export function getPackageManager(): ReturnType { - return getContext(PACKAGE_MANAGER); -} - -function createPackageManagerStore(key: string, initialValue: Agent) { - const store = persisted(key, initialValue); - return store; -} - -export type PackageManagerCommand = Command | "create"; - -export function getCommand( - pm: Agent, - type: PackageManagerCommand, - command: string | string[] -): ResolvedCommand { - let args = []; - if (typeof command === "string") { - args = command.split(" "); - } else { - args = command; - } - - // special handling for create - if (type === "create") return { command: pm, args: ["create", ...args] }; - - const cmd = resolveCommand(pm, type, args); - - // since docs are static any unresolved command is a code error - if (cmd === null) throw new Error("Could not resolve command!"); - - return cmd; -} diff --git a/sites/docs/src/lib/tmp-chart-theme-code.ts b/sites/docs/src/lib/tmp-chart-theme-code.ts new file mode 100644 index 0000000000..f6f1f9499c --- /dev/null +++ b/sites/docs/src/lib/tmp-chart-theme-code.ts @@ -0,0 +1,119 @@ +export const tmpChartThemeCode = `@import url("https://fonts.googleapis.com/css2?family=Geist+Mono:wght@100..900&family=Geist:wght@100..900&display=swap"); +@import "tailwindcss"; + +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@import "./themes.css"; + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.269 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.371 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --font-sans: "Geist", sans-serif; + --font-mono: "Geist Mono", monospace; + --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-accordion-down: accordion-down 0.2s ease-out; +}`; diff --git a/sites/docs/src/lib/utils.ts b/sites/docs/src/lib/utils.ts index a574be0b28..f49ea26575 100644 --- a/sites/docs/src/lib/utils.ts +++ b/sites/docs/src/lib/utils.ts @@ -1,26 +1,8 @@ import type { ClassValue } from "clsx"; import { clsx } from "clsx"; -import { cubicOut } from "svelte/easing"; -import { derived, get, writable } from "svelte/store"; -import type { TransitionConfig } from "svelte/transition"; import { twMerge } from "tailwind-merge"; import { error } from "@sveltejs/kit"; -import { persisted } from "svelte-local-storage-store"; -import type { WithElementRef } from "bits-ui"; -import type { - HTMLAnchorAttributes, - HTMLAttributes, - HTMLButtonAttributes, - HTMLImgAttributes, - HTMLInputAttributes, - HTMLLabelAttributes, - HTMLLiAttributes, - HTMLOlAttributes, - HTMLTableAttributes, - HTMLTdAttributes, - HTMLTextareaAttributes, - HTMLThAttributes, -} from "svelte/elements"; +import type { HTMLAnchorAttributes, HTMLAttributes, HTMLImgAttributes } from "svelte/elements"; import type { DocResolver } from "$lib/types/docs.js"; import { docs } from "$content/index.js"; @@ -28,103 +10,19 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithElementRef = T & { ref?: U | null }; + export const isBrowser = typeof document !== "undefined"; export function slugFromPath(path: string) { return path.replace("/src/content/", "").replace(".md", ""); } -export function hexToHsl(hex: string): [number, number, number] { - if (hex) { - const sanitizedHex = hex.replace("#", ""); - - const red = Number.parseInt(sanitizedHex.substring(0, 2), 16); - const green = Number.parseInt(sanitizedHex.substring(2, 4), 16); - const blue = Number.parseInt(sanitizedHex.substring(4, 6), 16); - - const normalizedRed = red / 255; - const normalizedGreen = green / 255; - const normalizedBlue = blue / 255; - - const max = Math.max(normalizedRed, normalizedGreen, normalizedBlue); - const min = Math.min(normalizedRed, normalizedGreen, normalizedBlue); - - let hue, saturation, lightness; - - if (max === min) { - hue = 0; - } else if (max === normalizedRed) { - hue = ((normalizedGreen - normalizedBlue) / (max - min)) % 6; - } else if (max === normalizedGreen) { - hue = (normalizedBlue - normalizedRed) / (max - min) + 2; - } else { - hue = (normalizedRed - normalizedGreen) / (max - min) + 4; - } - - hue = Math.round(hue * 60); - - if (hue < 0) { - hue += 360; - } - - lightness = (max + min) / 2; - - if (max === min) { - saturation = 0; - } else if (lightness <= 0.5) { - saturation = (max - min) / (max + min); - } else { - saturation = (max - min) / (2 - max - min); - } - - saturation = Math.round(saturation * 100); - lightness = Math.round(lightness * 100); - - return [hue, saturation, lightness]; - } - return [0, 0, 0]; -} - -export function hexToRgb(hex: string): [number, number, number] { - if (hex) { - const sanitizedHex = hex.replace("#", ""); - - const red = Number.parseInt(sanitizedHex.substring(0, 2), 16); - const green = Number.parseInt(sanitizedHex.substring(2, 4), 16); - const blue = Number.parseInt(sanitizedHex.substring(4, 6), 16); - - return [red, green, blue]; - } - return [0, 0, 0]; -} - -export function createCopyCodeButton() { - const codeString = writable(""); - const copied = writable(false); - let copyTimeout = 0; - - function copyCode() { - if (!isBrowser) return; - navigator.clipboard.writeText(get(codeString)); - copied.set(true); - clearTimeout(copyTimeout); - copyTimeout = window.setTimeout(() => { - copied.set(false); - }, 2500); - } - - function setCodeString(node: HTMLElement) { - codeString.set(node.innerText.trim() ?? ""); - } - - return { - copied, - copyCode, - setCodeString, - codeString, - }; -} - export function updateTheme(activeTheme: string, path: string) { if (!isBrowser) return; document.body.classList.forEach((className) => { @@ -139,58 +37,6 @@ export function updateTheme(activeTheme: string, path: string) { } } -type FlyAndScaleParams = { - y?: number; - x?: number; - start?: number; - duration?: number; -}; - -export function styleToString(style: Record): string { - return Object.keys(style).reduce((str, key) => { - if (style[key] === undefined) return str; - return `${str}${key}:${style[key]};`; - }, ""); -} - -export function flyAndScale( - node: Element, - params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } -): TransitionConfig { - const style = getComputedStyle(node); - const transform = style.transform === "none" ? "" : style.transform; - - const scaleConversion = ( - valueA: number, - scaleA: [number, number], - scaleB: [number, number] - ) => { - const [minA, maxA] = scaleA; - const [minB, maxB] = scaleB; - - const percentage = (valueA - minA) / (maxA - minA); - const valueB = percentage * (maxB - minB) + minB; - - return valueB; - }; - - return { - duration: params.duration ?? 200, - delay: 0, - css: (t) => { - const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]); - const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]); - const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]); - - return styleToString({ - transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, - opacity: t, - }); - }, - easing: cubicOut, - }; -} - type Modules = Record Promise>; function findMatch(slug: string, modules: Modules) { @@ -228,7 +74,7 @@ export async function getDoc(slug: string) { const doc = await match?.resolver?.(); const metadata = docs.find((doc) => doc.path === slug); - if (!doc || !metadata) { + if (!doc || !metadata || !("default" in doc)) { error(404); } @@ -243,41 +89,12 @@ export function slugFromPathname(pathname: string) { return pathname.split("/").pop() ?? ""; } -const liftMode = persisted("lift-mode", []); - -export function getLiftMode(name: string) { - function toggleLiftMode(name: string) { - liftMode.update((prev) => { - return prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]; - }); - } - - const isLiftMode = derived(liftMode, ($configStore) => $configStore.includes(name)); - - return { - isLiftMode, - toggleLiftMode, - }; -} - // Wrappers around svelte's `HTMLAttributes` types to add a `ref` prop can be bound to // to get a reference to the underlying DOM element the component is rendering. export type PrimitiveDivAttributes = WithElementRef>; export type PrimitiveElementAttributes = WithElementRef>; export type PrimitiveAnchorAttributes = WithElementRef; -export type PrimitiveButtonAttributes = WithElementRef; -export type PrimitiveInputAttributes = WithElementRef; -export type PrimitiveSpanAttributes = WithElementRef>; -export type PrimitiveTextareaAttributes = WithElementRef; export type PrimitiveHeadingAttributes = WithElementRef>; -export type PrimitiveLiAttributes = WithElementRef; -export type PrimitiveOlAttributes = WithElementRef; -export type PrimitiveLabelAttributes = WithElementRef; -export type PrimitiveUlAttributes = WithElementRef>; -export type PrimitiveTableAttributes = WithElementRef; -export type PrimitiveTdAttributes = WithElementRef; -export type PrimitiveTrAttributes = WithElementRef>; -export type PrimitiveThAttributes = WithElementRef; export type PrimitiveTableSectionAttributes = WithElementRef< HTMLAttributes >; diff --git a/sites/docs/src/lib/utils/copy-to-clipboard.svelte.ts b/sites/docs/src/lib/utils/copy-to-clipboard.svelte.ts deleted file mode 100644 index f8fc777c28..0000000000 --- a/sites/docs/src/lib/utils/copy-to-clipboard.svelte.ts +++ /dev/null @@ -1,35 +0,0 @@ -type CopyToClipboardProps = { - timeout?: number; - onCopy?: () => void; -}; - -export class CopyToClipboard { - isCopied = $state(false); - onCopy: CopyToClipboardProps["onCopy"]; - timeout: CopyToClipboardProps["timeout"]; - - constructor(props?: CopyToClipboardProps) { - this.onCopy = props?.onCopy; - this.timeout = props?.timeout ?? 2000; - } - - copyToClipboard = (value: string) => { - if (typeof window === "undefined" || !navigator.clipboard.writeText) { - return; - } - - if (!value) return; - - navigator.clipboard.writeText(value).then(() => { - this.isCopied = true; - - if (this.onCopy) { - this.onCopy(); - } - - setTimeout(() => { - this.isCopied = false; - }, this.timeout); - }, console.error); - }; -} diff --git a/sites/docs/src/routes/(app)/+layout.svelte b/sites/docs/src/routes/(app)/+layout.svelte index 1dda6ed4ca..20199d3618 100644 --- a/sites/docs/src/routes/(app)/+layout.svelte +++ b/sites/docs/src/routes/(app)/+layout.svelte @@ -1,15 +1,14 @@ - -
- +
+ +
+ {@render children()} +
+
- -{#if dev} - -{/if} diff --git a/sites/docs/src/routes/(app)/+page.server.ts b/sites/docs/src/routes/(app)/+page.server.ts index f65a63bcc2..aa0a848d73 100644 --- a/sites/docs/src/routes/(app)/+page.server.ts +++ b/sites/docs/src/routes/(app)/+page.server.ts @@ -4,16 +4,16 @@ import { superValidate } from "sveltekit-superforms"; import type { AnyZodObject } from "zod"; import type { Actions, PageServerLoad, RequestEvent } from "./$types.js"; -import { formSchema } from "$lib/registry/default/example/form-demo.svelte"; -import { formSchema as checkboxSingleSchema } from "$lib/registry/default/example/checkbox-form-single.svelte"; -import { formSchema as radioGroupSchema } from "$lib/registry/default/example/radio-group-form.svelte"; -import { formSchema as selectSchema } from "$lib/registry/default/example/select-form.svelte"; -import { formSchema as switchSchema } from "$lib/registry/default/example/switch-form.svelte"; -import { formSchema as textareaSchema } from "$lib/registry/default/example/textarea-form.svelte"; -import { formSchema as comboboxFormSchema } from "$lib/registry/default/example/combobox-form.svelte"; -import { formSchema as datePickerFormSchema } from "$lib/registry/default/example/date-picker-form.svelte"; -import { formSchema as checkboxMultipleSchema } from "$lib/registry/default/example/checkbox-form-multiple.svelte"; -import { formSchema as inputOtpSchema } from "$lib/registry/default/example/input-otp-form.svelte"; +import { formSchema } from "$lib/registry/examples/form-demo.svelte"; +import { formSchema as checkboxSingleSchema } from "$lib/registry/examples/checkbox-form-single.svelte"; +import { formSchema as radioGroupSchema } from "$lib/registry/examples/radio-group-form.svelte"; +import { formSchema as selectSchema } from "$lib/registry/examples/select-form.svelte"; +import { formSchema as switchSchema } from "$lib/registry/examples/switch-form.svelte"; +import { formSchema as textareaSchema } from "$lib/registry/examples/textarea-form.svelte"; +import { formSchema as comboboxFormSchema } from "$lib/registry/examples/combobox-form.svelte"; +import { formSchema as datePickerFormSchema } from "$lib/registry/examples/date-picker-form.svelte"; +import { formSchema as checkboxMultipleSchema } from "$lib/registry/examples/checkbox-form-multiple.svelte"; +import { formSchema as inputOtpSchema } from "$lib/registry/examples/input-otp-form.svelte"; export const actions: Actions = { username: async (e) => handleForm(e, formSchema), diff --git a/sites/docs/src/routes/(app)/+page.svelte b/sites/docs/src/routes/(app)/+page.svelte index f3672eb3a7..06a9ede1e6 100644 --- a/sites/docs/src/routes/(app)/+page.svelte +++ b/sites/docs/src/routes/(app)/+page.svelte @@ -1,75 +1,50 @@ -
- - - Build your component library - - Beautifully designed components that you can copy and paste into your apps. - -

- This is an unofficial port of shadcn/ui - to Svelte, and is not affiliated with - @shadcn. -

- - - - -
- -
-
- - -
-
- +
+
+
+
+
+
+ + +
+
+ +
diff --git a/sites/docs/src/routes/(app)/blocks/+layout.svelte b/sites/docs/src/routes/(app)/blocks/+layout.svelte index 60ab3fa7af..84c75526a2 100644 --- a/sites/docs/src/routes/(app)/blocks/+layout.svelte +++ b/sites/docs/src/routes/(app)/blocks/+layout.svelte @@ -1,31 +1,31 @@ -
- - - Building Blocks for the Web - - Beautifully designed. Copy and paste into your apps. Open source. - - - - - - -
- {@render children?.()} -
+ + + {title} + {description} + + + + + +
+
+
+ +
+
+
{@render children()}
diff --git a/sites/docs/src/routes/(app)/blocks/+page.server.ts b/sites/docs/src/routes/(app)/blocks/+page.server.ts deleted file mode 100644 index 0a83341f3e..0000000000 --- a/sites/docs/src/routes/(app)/blocks/+page.server.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { PageServerLoad } from "./$types.js"; -import { BLOCK_WHITELIST, getAllBlockIds, getBlock } from "$lib/blocks.js"; -import { styles } from "$lib/registry/styles.js"; - -export const prerender = true; - -export const load: PageServerLoad = async () => { - const blocks = []; - const blockIds = getAllBlockIds(); - - for (const style of styles) { - const styledBlocks = await Promise.all(blockIds.map((name) => getBlock(name, style.name))); - - // `blockNames` is the order in-which the blocks should appear - for (const name of BLOCK_WHITELIST) { - const block = styledBlocks.find((b) => b.name === name); - if (!block) throw new Error(`Missing block ${name} for style ${style.name}`); - - blocks.push(block); - } - } - - return { - blocks, - }; -}; diff --git a/sites/docs/src/routes/(app)/blocks/+page.svelte b/sites/docs/src/routes/(app)/blocks/+page.svelte index 77aab5ebf3..3800dbfcf7 100644 --- a/sites/docs/src/routes/(app)/blocks/+page.svelte +++ b/sites/docs/src/routes/(app)/blocks/+page.svelte @@ -1,10 +1,25 @@ -{#each data.blocks as block (block)} - -{/each} +
+ {#each data.blocks as block (block.name)} +
+ +
+ {/each} +
+
+ +
+
+
diff --git a/sites/docs/src/routes/(app)/blocks/+page.ts b/sites/docs/src/routes/(app)/blocks/+page.ts new file mode 100644 index 0000000000..c05e6bb443 --- /dev/null +++ b/sites/docs/src/routes/(app)/blocks/+page.ts @@ -0,0 +1,83 @@ +import { + registryItemSchema, + type RegistryItem, + type RegistryItemFile, +} from "@shadcn-svelte/registry"; +import type { PageLoad } from "./$types.js"; +import { highlightCode } from "$lib/highlight-code.js"; +import { transformBlockPath, transformImportPaths } from "$lib/registry/registry-utils.js"; +import { blockMeta } from "$lib/registry/registry-block-meta.js"; + +export const prerender = true; + +type CachedItem = Omit & { + files: (RegistryItemFile & { + highlightedContent: string; + target: string; + })[]; +}; + +const FEATURED_BLOCKS = ["dashboard-01", "sidebar-07", "sidebar-03", "login-03", "login-04"]; + +export const load: PageLoad = async () => { + const registryJsonItems = import.meta.glob([ + "../../../__registry__/json/dashboard-*.json", + "../../../__registry__/json/sidebar-*.json", + "../../../__registry__/json/login-*.json", + ]); + const promises: Promise[] = []; + + for (const path in registryJsonItems) { + const filename = path.split("/").pop()?.split(".")[0]; + if (!filename) continue; + if (!FEATURED_BLOCKS.includes(filename)) continue; + + promises.push( + registryJsonItems[path]().then(async (m: unknown) => { + const res = registryItemSchema.parse((m as { default: unknown }).default); + const files = await Promise.all( + res.files.map(async (v) => { + let lang: "svelte" | "ts" | "json" = "svelte"; + if (v.target && v.target.endsWith(".ts")) { + lang = "ts"; + } else if (v.target && v.target.endsWith(".json")) { + lang = "json"; + } + + const highlightedContent = await highlightCode( + transformImportPaths(v.content), + lang + ); + const target = v.target ? transformBlockPath(v.target, v.type) : ""; + return { + ...v, + highlightedContent, + target, + }; + }) + ); + + const description = blockMeta?.[res.name as keyof typeof blockMeta]?.description; + + const processedItem = { + ...res, + files: files, + description, + }; + return processedItem; + }) + ); + } + + const result = await Promise.all(promises); + + return { + blocks: result + .filter((block): block is CachedItem => block !== null) + .sort((a, b) => { + const aIndex = FEATURED_BLOCKS.indexOf(a.name); + const bIndex = FEATURED_BLOCKS.indexOf(b.name); + return aIndex - bIndex; + }), + }; +}; diff --git a/sites/docs/src/routes/(app)/blocks/[category]/+page.svelte b/sites/docs/src/routes/(app)/blocks/[category]/+page.svelte new file mode 100644 index 0000000000..23aeb4b20c --- /dev/null +++ b/sites/docs/src/routes/(app)/blocks/[category]/+page.svelte @@ -0,0 +1,19 @@ + + +
+ {#each data.blocks as block (block.name)} +
+ +
+ {/each} +
diff --git a/sites/docs/src/routes/(app)/blocks/[category]/+page.ts b/sites/docs/src/routes/(app)/blocks/[category]/+page.ts new file mode 100644 index 0000000000..8c0ad554b1 --- /dev/null +++ b/sites/docs/src/routes/(app)/blocks/[category]/+page.ts @@ -0,0 +1,87 @@ +import { + registryItemSchema, + type RegistryItem, + type RegistryItemFile, +} from "@shadcn-svelte/registry"; +import type { PageLoad } from "./$types.js"; +import { highlightCode } from "$lib/highlight-code.js"; +import { transformBlockPath, transformImportPaths } from "$lib/registry/registry-utils.js"; +import { isBlock } from "$lib/blocks.js"; +import { blockMeta } from "$lib/registry/registry-block-meta.js"; + +export const prerender = true; + +type CachedItem = Omit & { + files: Array< + RegistryItemFile & { + highlightedContent: string; + target: string; + } + >; +}; + +export const load: PageLoad = async ({ params }) => { + const category = params.category; + + let registryJsonItems: Record Promise> = {}; + + // remove the items that don't match the category from the object. + + if (category === "sidebar") { + registryJsonItems = import.meta.glob("../../../../__registry__/json/sidebar-*.json"); + } else if (category === "dashboard") { + registryJsonItems = import.meta.glob("../../../../__registry__/json/dashboard-*.json"); + } else if (category === "login" || category === "authentication") { + registryJsonItems = import.meta.glob("../../../../__registry__/json/login-*.json"); + } + + const promises: Promise[] = []; + + for (const path in registryJsonItems) { + const filename = path.split("/").pop()?.split(".")[0]; + if (!filename) continue; + if (!isBlock(filename)) continue; + + promises.push( + registryJsonItems[path]().then(async (m: unknown) => { + const res = registryItemSchema.parse((m as { default: unknown }).default); + const files = await Promise.all( + res.files.map(async (v) => { + let lang: "svelte" | "ts" | "json" = "svelte"; + if (v.target && v.target.endsWith(".ts")) { + lang = "ts"; + } else if (v.target && v.target.endsWith(".json")) { + lang = "json"; + } + + const highlightedContent = await highlightCode( + transformImportPaths(v.content), + lang + ); + const target = v.target ? transformBlockPath(v.target, v.type) : ""; + return { + ...v, + highlightedContent, + target, + }; + }) + ); + + const description = blockMeta?.[res.name as keyof typeof blockMeta]?.description; + + const processedItem = { + ...res, + files: files, + description, + }; + return processedItem; + }) + ); + } + + const result = await Promise.all(promises); + + return { + blocks: result.filter((block): block is CachedItem => block !== null), + }; +}; diff --git a/sites/docs/src/routes/(app)/charts/+layout.svelte b/sites/docs/src/routes/(app)/charts/+layout.svelte new file mode 100644 index 0000000000..5a9e81d53c --- /dev/null +++ b/sites/docs/src/routes/(app)/charts/+layout.svelte @@ -0,0 +1,35 @@ + + + + + {title} + {description} + + + + + +
+
+
+ +
+
+
+
+
+
+ {@render children()} +
+
+
diff --git a/sites/docs/src/routes/(app)/charts/+page.svelte b/sites/docs/src/routes/(app)/charts/+page.svelte new file mode 100644 index 0000000000..df567376a1 --- /dev/null +++ b/sites/docs/src/routes/(app)/charts/+page.svelte @@ -0,0 +1,291 @@ + + +
+
+
+

Examples

+
+ + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/sites/docs/src/routes/(app)/charts/+page.ts b/sites/docs/src/routes/(app)/charts/+page.ts new file mode 100644 index 0000000000..b304361b80 --- /dev/null +++ b/sites/docs/src/routes/(app)/charts/+page.ts @@ -0,0 +1,37 @@ +import { registryItemSchema, type RegistryItem } from "@shadcn-svelte/registry"; +import type { PageLoad } from "./$types.js"; +import { highlightCode } from "$lib/highlight-code.js"; + +export const prerender = true; + +type CachedItem = RegistryItem & { highlightedCode: string }; +const registryCache = new Map(); + +export const load: PageLoad = async () => { + const registryJsonItems = import.meta.glob("../../../__registry__/json/chart-*.json"); + const promises: Promise[] = []; + + for (const path in registryJsonItems) { + const filename = path.split("/").pop()?.split(".")[0]; + if (!filename) continue; + if (registryCache.has(filename)) { + promises.push(Promise.resolve(registryCache.get(filename)!)); + } else { + promises.push( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + registryJsonItems[path]().then(async (m: any) => { + const parsed = registryItemSchema.parse(m.default); + const highlightedCode = await highlightCode(parsed.files?.[0]?.content ?? ""); + const item = { ...parsed, highlightedCode }; + registryCache.set(filename, item); + return item; + }) + ); + } + } + + const charts = await Promise.all(promises); + return { + chartData: charts.filter((chart): chart is CachedItem => chart !== null), + }; +}; diff --git a/sites/docs/src/routes/(app)/charts/charts.ts b/sites/docs/src/routes/(app)/charts/charts.ts new file mode 100644 index 0000000000..0d830135bc --- /dev/null +++ b/sites/docs/src/routes/(app)/charts/charts.ts @@ -0,0 +1,76 @@ +export { default as ChartAreaDefault } from "$lib/registry/blocks/chart-area-default.svelte"; +export { default as ChartAreaLinear } from "$lib/registry/blocks/chart-area-linear.svelte"; +export { default as ChartAreaStep } from "$lib/registry/blocks/chart-area-step.svelte"; +export { default as ChartAreaLegend } from "$lib/registry/blocks/chart-area-legend.svelte"; +export { default as ChartAreaStacked } from "$lib/registry/blocks/chart-area-stacked.svelte"; +export { default as ChartAreaStackedExpand } from "$lib/registry/blocks/chart-area-stacked-expand.svelte"; +export { default as ChartAreaIcons } from "$lib/registry/blocks/chart-area-icons.svelte"; +export { default as ChartAreaGradient } from "$lib/registry/blocks/chart-area-gradient.svelte"; +export { default as ChartAreaAxes } from "$lib/registry/blocks/chart-area-axes.svelte"; +export { default as ChartAreaInteractive } from "$lib/registry/blocks/chart-area-interactive.svelte"; + +export { default as ChartBarDefault } from "$lib/registry/blocks/chart-bar-default.svelte"; +export { default as ChartBarHorizontal } from "$lib/registry/blocks/chart-bar-horizontal.svelte"; +export { default as ChartBarMultiple } from "$lib/registry/blocks/chart-bar-multiple.svelte"; +export { default as ChartBarStacked } from "$lib/registry/blocks/chart-bar-stacked.svelte"; +export { default as ChartBarLabel } from "$lib/registry/blocks/chart-bar-label.svelte"; +export { default as ChartBarLabelCustom } from "$lib/registry/blocks/chart-bar-label-custom.svelte"; +export { default as ChartBarMixed } from "$lib/registry/blocks/chart-bar-mixed.svelte"; +export { default as ChartBarActive } from "$lib/registry/blocks/chart-bar-active.svelte"; +export { default as ChartBarNegative } from "$lib/registry/blocks/chart-bar-negative.svelte"; +export { default as ChartBarInteractive } from "$lib/registry/blocks/chart-bar-interactive.svelte"; + +export { default as ChartLineDefault } from "$lib/registry/blocks/chart-line-default.svelte"; +export { default as ChartLineLinear } from "$lib/registry/blocks/chart-line-linear.svelte"; +export { default as ChartLineStep } from "$lib/registry/blocks/chart-line-step.svelte"; +export { default as ChartLineMultiple } from "$lib/registry/blocks/chart-line-multiple.svelte"; +export { default as ChartLineDots } from "$lib/registry/blocks/chart-line-dots.svelte"; +export { default as ChartLineDotsCustom } from "$lib/registry/blocks/chart-line-dots-custom.svelte"; +export { default as ChartLineDotsColors } from "$lib/registry/blocks/chart-line-dots-colors.svelte"; +export { default as ChartLineLabel } from "$lib/registry/blocks/chart-line-label.svelte"; +export { default as ChartLineLabelCustom } from "$lib/registry/blocks/chart-line-label-custom.svelte"; +export { default as ChartLineInteractive } from "$lib/registry/blocks/chart-line-interactive.svelte"; + +export { default as ChartPieSimple } from "$lib/registry/blocks/chart-pie-simple.svelte"; +export { default as ChartPieSeparatorNone } from "$lib/registry/blocks/chart-pie-separator-none.svelte"; +export { default as ChartPieLabel } from "$lib/registry/blocks/chart-pie-label.svelte"; +export { default as ChartPieLabelCustom } from "$lib/registry/blocks/chart-pie-label-custom.svelte"; +export { default as ChartPieLabelList } from "$lib/registry/blocks/chart-pie-label-list.svelte"; +export { default as ChartPieLegend } from "$lib/registry/blocks/chart-pie-legend.svelte"; +export { default as ChartPieDonut } from "$lib/registry/blocks/chart-pie-donut.svelte"; +export { default as ChartPieDonutActive } from "$lib/registry/blocks/chart-pie-donut-active.svelte"; +export { default as ChartPieDonutText } from "$lib/registry/blocks/chart-pie-donut-text.svelte"; +export { default as ChartPieStacked } from "$lib/registry/blocks/chart-pie-stacked.svelte"; +export { default as ChartPieInteractive } from "$lib/registry/blocks/chart-pie-interactive.svelte"; + +export { default as ChartRadarDefault } from "$lib/registry/blocks/chart-radar-default.svelte"; +export { default as ChartRadarDots } from "$lib/registry/blocks/chart-radar-dots.svelte"; +export { default as ChartRadarLinesOnly } from "$lib/registry/blocks/chart-radar-lines-only.svelte"; +export { default as ChartRadarLabelCustom } from "$lib/registry/blocks/chart-radar-label-custom.svelte"; +export { default as ChartRadarGridCustom } from "$lib/registry/blocks/chart-radar-grid-custom.svelte"; +export { default as ChartRadarGridNone } from "$lib/registry/blocks/chart-radar-grid-none.svelte"; +export { default as ChartRadarGridCircle } from "$lib/registry/blocks/chart-radar-grid-circle.svelte"; +export { default as ChartRadarGridCircleNoLines } from "$lib/registry/blocks/chart-radar-grid-circle-no-lines.svelte"; +// export { default as ChartRadarGridCircleFill } from "$lib/registry/blocks/chart-radar-grid-circle-fill.svelte" +export { default as ChartRadarGridFill } from "$lib/registry/blocks/chart-radar-grid-fill.svelte"; +export { default as ChartRadarMultiple } from "$lib/registry/blocks/chart-radar-multiple.svelte"; +export { default as ChartRadarLegend } from "$lib/registry/blocks/chart-radar-legend.svelte"; +export { default as ChartRadarIcons } from "$lib/registry/blocks/chart-radar-icons.svelte"; +export { default as ChartRadarRadius } from "$lib/registry/blocks/chart-radar-radius.svelte"; + +export { default as ChartRadialSimple } from "$lib/registry/blocks/chart-radial-simple.svelte"; +export { default as ChartRadialLabel } from "$lib/registry/blocks/chart-radial-label.svelte"; +export { default as ChartRadialGrid } from "$lib/registry/blocks/chart-radial-grid.svelte"; +export { default as ChartRadialText } from "$lib/registry/blocks/chart-radial-text.svelte"; +export { default as ChartRadialShape } from "$lib/registry/blocks/chart-radial-shape.svelte"; +export { default as ChartRadialStacked } from "$lib/registry/blocks/chart-radial-stacked.svelte"; + +export { default as ChartTooltipDefault } from "$lib/registry/blocks/chart-tooltip-default.svelte"; +export { default as ChartTooltipIndicatorLine } from "$lib/registry/blocks/chart-tooltip-indicator-line.svelte"; +export { default as ChartTooltipIndicatorNone } from "$lib/registry/blocks/chart-tooltip-indicator-none.svelte"; +export { default as ChartTooltipLabelCustom } from "$lib/registry/blocks/chart-tooltip-label-custom.svelte"; +export { default as ChartTooltipLabelFormatter } from "$lib/registry/blocks/chart-tooltip-label-formatter.svelte"; +export { default as ChartTooltipLabelNone } from "$lib/registry/blocks/chart-tooltip-label-none.svelte"; +export { default as ChartTooltipFormatter } from "$lib/registry/blocks/chart-tooltip-formatter.svelte"; +export { default as ChartTooltipIcons } from "$lib/registry/blocks/chart-tooltip-icons.svelte"; +export { default as ChartTooltipAdvanced } from "$lib/registry/blocks/chart-tooltip-advanced.svelte"; diff --git a/sites/docs/src/routes/(app)/colors/+page.svelte b/sites/docs/src/routes/(app)/colors/+page.svelte index 226b01db80..7e2e0da1b9 100644 --- a/sites/docs/src/routes/(app)/colors/+page.svelte +++ b/sites/docs/src/routes/(app)/colors/+page.svelte @@ -8,32 +8,23 @@ -
- - - - - Tailwind Colors - - Tailwind CSS colors in HSL, RGB, and HEX formats. - - - + + + + + Tailwind Colors + + Tailwind CSS colors in HSL, RGB, HEX, and OKLCH formats. + + + -
- -
+
+
diff --git a/sites/docs/src/routes/(app)/docs/+layout.server.ts b/sites/docs/src/routes/(app)/docs/+layout.server.ts index bd99042f69..835529e98c 100644 --- a/sites/docs/src/routes/(app)/docs/+layout.server.ts +++ b/sites/docs/src/routes/(app)/docs/+layout.server.ts @@ -2,16 +2,16 @@ import { superValidate } from "sveltekit-superforms"; import { zod } from "sveltekit-superforms/adapters"; import type { LayoutServerLoad } from "./$types.js"; -import { formSchema } from "$lib/registry/default/example/form-demo.svelte"; -import { formSchema as checkboxSingleSchema } from "$lib/registry/default/example/checkbox-form-single.svelte"; -import { formSchema as radioGroupSchema } from "$lib/registry/default/example/radio-group-form.svelte"; -import { formSchema as selectSchema } from "$lib/registry/default/example/select-form.svelte"; -import { formSchema as switchSchema } from "$lib/registry/default/example/switch-form.svelte"; -import { formSchema as textareaSchema } from "$lib/registry/default/example/textarea-form.svelte"; -import { formSchema as comboboxFormSchema } from "$lib/registry/default/example/combobox-form.svelte"; -import { formSchema as datePickerFormSchema } from "$lib/registry/default/example/date-picker-form.svelte"; -import { formSchema as checkboxMultipleSchema } from "$lib/registry/default/example/checkbox-form-multiple.svelte"; -import { formSchema as inputOtpSchema } from "$lib/registry/default/example/input-otp-form.svelte"; +import { formSchema } from "$lib/registry/examples/form-demo.svelte"; +import { formSchema as checkboxSingleSchema } from "$lib/registry/examples/checkbox-form-single.svelte"; +import { formSchema as radioGroupSchema } from "$lib/registry/examples/radio-group-form.svelte"; +import { formSchema as selectSchema } from "$lib/registry/examples/select-form.svelte"; +import { formSchema as switchSchema } from "$lib/registry/examples/switch-form.svelte"; +import { formSchema as textareaSchema } from "$lib/registry/examples/textarea-form.svelte"; +import { formSchema as comboboxFormSchema } from "$lib/registry/examples/combobox-form.svelte"; +import { formSchema as datePickerFormSchema } from "$lib/registry/examples/date-picker-form.svelte"; +import { formSchema as checkboxMultipleSchema } from "$lib/registry/examples/checkbox-form-multiple.svelte"; +import { formSchema as inputOtpSchema } from "$lib/registry/examples/input-otp-form.svelte"; export const load: LayoutServerLoad = async () => { return { diff --git a/sites/docs/src/routes/(app)/docs/+layout.svelte b/sites/docs/src/routes/(app)/docs/+layout.svelte index d8bec2fae0..96bb143d6b 100644 --- a/sites/docs/src/routes/(app)/docs/+layout.svelte +++ b/sites/docs/src/routes/(app)/docs/+layout.svelte @@ -1,21 +1,20 @@ -
+
{@render children?.()}
diff --git a/sites/docs/src/routes/(app)/docs/+page.svelte b/sites/docs/src/routes/(app)/docs/+page.svelte index 1db4ab32ee..54212d2f19 100644 --- a/sites/docs/src/routes/(app)/docs/+page.svelte +++ b/sites/docs/src/routes/(app)/docs/+page.svelte @@ -1,47 +1,77 @@
-
-
-
Docs
- -
{doc.title}
+
+
+ Docs + +
{doc.title}
-

+

{doc.title}

{#if doc.description} -

+

{doc.description}

{/if}
- -
- + {#if doc.links} +
+ {#if doc.links?.doc} + + Docs + + + {/if} + {#if doc.links?.api} + + API Reference + + + {/if} +
+ {/if} +
+
+ +
-