diff --git a/package.json b/package.json index 30b43c3c1d..9f72273d04 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:v4": "pnpm -F v4 dev", "preview": "pnpm -F docs preview", "test": "pnpm -F shadcn-svelte test", + "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 .", 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 36b0e8b943..60516999d7 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,7 @@ "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", - "test": "vitest" + "test": "pnpm -w build:registry-template && vitest" }, "dependencies": { "@svecosystem/strip-types": "^0.0.2", @@ -55,6 +55,7 @@ "estree-walker": "^3.0.3", "get-tsconfig": "^4.7.3", "ignore": "^7.0.4", + "memfs": "^4.17.2", "package-manager-detector": "^1.2.0", "semver": "^7.7.1", "sucrase": "^3.35.0", diff --git a/packages/cli/src/commands/registry/build.ts b/packages/cli/src/commands/registry/build.ts index 49f9e7c32c..57f029e4c4 100644 --- a/packages/cli/src/commands/registry/build.ts +++ b/packages/cli/src/commands/registry/build.ts @@ -126,16 +126,6 @@ async function runBuild(options: BuildOptions) { * import Button from "$UI$/button/index.js" * ``` */ - const transformAliases = (content: string) => { - registry.aliases ??= {}; - for (const alias of ALIASES) { - const defaults = ALIAS_DEFAULTS[alias]; - const path = (registry.aliases[alias] ??= defaults.defaultPath); - content = content.replaceAll(path, defaults.placeholder); - } - - return content; - }; for (const item of registry.items) { message(`Building item ${color.cyan(item.name)}`); @@ -147,7 +137,8 @@ async function runBuild(options: BuildOptions) { ]; const toResolve = item.files.map(async (file) => { let content = await fs.readFile(file.path, "utf8"); - content = transformAliases(content); + registry.aliases ??= {}; + content = transformAliases(registry.aliases, content); const name = path.basename(file.path); @@ -224,10 +215,34 @@ async function runBuild(options: BuildOptions) { * "./stepper.json" * ``` */ -function transformLocal(registryDep: string) { +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 index e22b56c0d2..059bc8f587 100644 --- a/packages/cli/src/commands/registry/deps-resolver.ts +++ b/packages/cli/src/commands/registry/deps-resolver.ts @@ -9,14 +9,14 @@ import type { PackageJson } from "type-fest"; import { toArray } from "../../utils/utils.js"; import { loadProjectPackageInfo } from "../../utils/get-package-info.js"; -type ResolvedDependencies = { +export type ResolvedDependencies = { /** `` */ deps: Record; /** `` */ versions: Record; }; -type ProjectDependencies = { +export type ProjectDependencies = { dependencies: ResolvedDependencies; devDependencies: ResolvedDependencies; }; @@ -39,7 +39,7 @@ export function resolveProjectDeps(cwd: string): ProjectDependencies { /** * Adds a dependency's type definition package to their respective peer list (if applicable). */ -function resolveTypeDeps(projectDeps: ProjectDependencies) { +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]!; @@ -64,7 +64,7 @@ function resolveTypeDeps(projectDeps: ProjectDependencies) { * * `dependencies.deps` goes from `` to `` */ -function resolvePeerVersions(projectDeps: ProjectDependencies): ProjectDependencies { +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 @@ -80,7 +80,7 @@ function resolvePeerVersions(projectDeps: ProjectDependencies): ProjectDependenc return projectDeps; } -const IGNORE_DEPS = ["svelte", "@sveltejs/kit", "tailwindcss", "vite"]; +export const IGNORE_DEPS = ["svelte", "@sveltejs/kit", "tailwindcss", "vite"]; /** * Resolves peer dependencies from a given set of dependencies from a package.json. @@ -182,7 +182,7 @@ export async function getFileDependencies(opts: GetFileDepOpts) { } /** Returns an array of found deps. */ -function resolveDepsFromImport(source: string, dependencies: ResolvedDependencies) { +export function resolveDepsFromImport(source: string, dependencies: ResolvedDependencies) { const depsFound: string[] = []; const simple = dependencies.versions[source] ? source : undefined; const depName = diff --git a/packages/cli/test/commands/init.spec.ts b/packages/cli/test/commands/init.test.ts similarity index 100% rename from packages/cli/test/commands/init.spec.ts rename to packages/cli/test/commands/init.test.ts diff --git a/packages/cli/test/registry-template.test.ts b/packages/cli/test/registry-template.test.ts new file mode 100644 index 0000000000..fb6d5d2b1d --- /dev/null +++ b/packages/cli/test/registry-template.test.ts @@ -0,0 +1,696 @@ +/** + * This ensures that any changes we make to the registry build script that would affect the output + * of this test are caught by CI and thus require a manual review of the snapshot outputs for the + * time being. + * + * Whether this is a good idea or not, idk but seems like a good start. + */ + +import { it, expect } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +const registryTemplateStaticRegistryPath = path.join( + __dirname, + "..", + "..", + "..", + "registry-template", + "static", + "r" +); + +function getFile(name: string) { + return JSON.parse( + fs.readFileSync(path.join(registryTemplateStaticRegistryPath, `${name}.json`), "utf8") + ); +} + +it("should accurately build the registry template", async () => { + const index = getFile("index"); + const checkoutSteps = getFile("checkout-steps"); + const complexComponent = getFile("complex-component"); + const exampleForm = getFile("example-form"); + const exampleWithCss = getFile("example-with-css"); + const helloWorld = getFile("hello-world"); + const stepper = getFile("stepper"); + + expect(index).toMatchInlineSnapshot(` + [ + { + "description": "A simple hello world component", + "name": "hello-world", + "registryDependencies": [ + "button", + ], + "relativeUrl": "hello-world.json", + "title": "Hello World", + "type": "registry:component", + }, + { + "dependencies": [ + "zod", + ], + "description": "A contact form with Zod validation.", + "name": "example-form", + "registryDependencies": [ + "button", + "input", + "label", + "textarea", + "card", + ], + "relativeUrl": "example-form.json", + "title": "Example Form", + "type": "registry:component", + }, + { + "description": "A complex component showing hooks, libs and components.", + "name": "complex-component", + "registryDependencies": [ + "card", + ], + "relativeUrl": "complex-component.json", + "title": "Complex Component", + "type": "registry:component", + }, + { + "description": "A login form with a CSS file.", + "name": "example-with-css", + "registryDependencies": [], + "relativeUrl": "example-with-css.json", + "title": "Example with CSS", + "type": "registry:component", + }, + { + "name": "stepper", + "registryDependencies": [], + "relativeUrl": "stepper.json", + "type": "registry:ui", + }, + { + "description": "A checkout steps component.", + "name": "checkout-steps", + "registryDependencies": [ + "local:stepper", + ], + "relativeUrl": "checkout-steps.json", + "title": "Checkout Steps", + "type": "registry:component", + }, + ] + `); + + expect(checkoutSteps).toMatchInlineSnapshot(` + { + "$schema": "https://next.shadcn-svelte.com/schema/registry-item.json", + "description": "A checkout steps component.", + "files": [ + { + "content": " + + + {#each { length: 5 } as _, i (i)} + + {/each} + + ", + "target": "checkout-steps.svelte", + "type": "registry:component", + }, + ], + "name": "checkout-steps", + "registryDependencies": [ + "./stepper.json", + ], + "title": "Checkout Steps", + "type": "registry:component", + } + `); + + expect(complexComponent).toMatchInlineSnapshot(` + { + "$schema": "https://next.shadcn-svelte.com/schema/registry-item.json", + "description": "A complex component showing hooks, libs and components.", + "devDependencies": [ + "zod@^3.24.4", + ], + "files": [ + { + "content": " + + {#await getPokemonList({ limit: 12 })} +
Loading pokemons...
+ {:then pokemons} + {#if pokemons} +
+
+ {#each pokemons.results as pokemon (pokemon.name)} + + {/each} +
+
+ {/if} + {:catch} +
+

Error loading pokemons

+
+ {/await} + ", + "target": "src/routes/pokemon/+page.svelte", + "type": "registry:page", + }, + { + "content": " + + {#await getPokemon(name)} +
Loading...
+ {:then pokemon} + {#if pokemon} + + +
+ +
+
{pokemon.name}
+
+
+ {/if} + {:catch} +
Error loading pokemon
+ {/await} + ", + "target": "pokemon-card.svelte", + "type": "registry:component", + }, + { + "content": " + + {#if imageUrl} + {name} + {/if} + ", + "target": "pokemon-image.svelte", + "type": "registry:component", + }, + { + "content": "import { z } from "zod"; + + export async function getPokemonList({ limit = 10 }: { limit?: number }) { + try { + const response = await fetch(\`https://pokeapi.co/api/v2/pokemon?limit=\${limit}\`); + return z + .object({ + results: z.array(z.object({ name: z.string() })), + }) + .parse(await response.json()); + } catch (error) { + console.error(error); + return null; + } + } + + export async function getPokemon(name: string) { + try { + const response = await fetch(\`https://pokeapi.co/api/v2/pokemon/\${name}\`); + if (!response.ok) throw new Error("Failed to fetch pokemon"); + + return z + .object({ + name: z.string(), + id: z.number(), + sprites: z.object({ + front_default: z.string(), + }), + stats: z.array( + z.object({ + base_stat: z.number(), + stat: z.object({ + name: z.string(), + }), + }) + ), + }) + .parse(await response.json()); + } catch (error) { + console.error(error); + return null; + } + } + ", + "target": "pokemon.ts", + "type": "registry:lib", + }, + { + "content": "// Unnecessary hook, but an example of how to add a hook to a custom registry. + + export function usePokemonImage(number: number) { + return \`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/\${number}.png\`; + } + ", + "target": "use-pokemon.svelte.ts", + "type": "registry:hook", + }, + ], + "name": "complex-component", + "registryDependencies": [ + "card", + ], + "title": "Complex Component", + "type": "registry:component", + } + `); + expect(exampleForm).toMatchInlineSnapshot(` + { + "$schema": "https://next.shadcn-svelte.com/schema/registry-item.json", + "dependencies": [ + "zod", + ], + "description": "A contact form with Zod validation.", + "devDependencies": [ + "zod@^3.24.4", + ], + "files": [ + { + "content": " + +
+ + + How can we help? + + Need help with your project? We're here to assist you. + + + +
+ + + {#if formState.errors.name} +

+ {formState.errors.name} +

+ {/if} +
+
+ + + {#if formState.errors.email} +

+ {formState.errors.email} +

+ {/if} +
+
+ +