[create your own]
Community add-ons are currently experimental. The API may change. Don't use them in production yet!
This guide covers how to create, test, and publish community add-ons for sv.
Quick start
The easiest way to create an add-on is by using the addon template:
npx sv create --template addon [path]The newly created project will have a README.md and CONTRIBUTING.md to guide you along.
Project structure
Typically, an add-on looks like this:
import { const transforms: {
script(cb: (file: {
ast: Program;
comments: Comments;
content: string;
js: typeof index_d_exports$3;
}) => void | false, options?: TransformOptions): (content: string) => string;
svelte(cb: (file: {
ast: AST.Root;
content: string;
svelte: typeof index_d_exports$4;
js: typeof index_d_exports$3;
}) => void | false, options?: TransformOptions): (content: string) => string;
... 6 more ...;
text(cb: (file: {
content: string;
text: typeof text_d_exports;
}) => string | false): TransformFn;
}
File transform primitives that know their format.
sv-utils = what to do to content, sv = where and when to do it.
Each transform wraps: parse -> callback({ast/data, utils}) -> generateCode().
The parser choice is baked into the transform type - you can't accidentally
parse a vite config as svelte because you never call a parser yourself.
Transforms are curried: call with the callback to get a (content: string) => string
function that plugs directly into sv.file().
transforms } from '@sveltejs/sv-utils';
import { function defineAddon<const Id extends string, Args extends OptionDefinition>(config: Addon<Args, Id>): Addon<Args, Id>The entry point for your addon, It will hold every thing! (options, setup, run, nextSteps, ...)
defineAddon, function defineAddonOptions(): OptionBuilder<{}>Options for an addon.
Will be prompted to the user if there are not answered by args when calling the cli.
const options = defineAddonOptions()
.add('demo', {
question: `demo? ${color.optional('(a cool one!)')}`
type: string | boolean | number | select | multiselect,
default: true,
})
.build();
To define by args, you can do
npx sv add <addon>=<option1>:<value1>+<option2>:<value2>
defineAddonOptions } from 'sv';
export default defineAddon<"addon-name", {
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>(config: Addon<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}, "addon-name">): Addon<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}, "addon-name">
The entry point for your addon, It will hold every thing! (options, setup, run, nextSteps, ...)
defineAddon({
id: "addon-name"id: 'addon-name',
shortDescription?: string | undefinedshortDescription: 'a better description of what your addon does ;)',
options: {
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}
options: function defineAddonOptions(): OptionBuilder<{}>Options for an addon.
Will be prompted to the user if there are not answered by args when calling the cli.
const options = defineAddonOptions()
.add('demo', {
question: `demo? ${color.optional('(a cool one!)')}`
type: string | boolean | number | select | multiselect,
default: true,
})
.build();
To define by args, you can do
npx sv add <addon>=<option1>:<value1>+<option2>:<value2>
defineAddonOptions()
.add<"who", Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>>(key: "who", question: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>): OptionBuilder<Record<"who", Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>>>
This type is a bit complex, but in usage, it's quite simple!
The idea is to add() options one by one, with the key and the question.
.add('demo', {
question: 'Do you want to add a demo?',
type: 'boolean', // string, number, select, multiselect
default: true,
// condition: (o) => o.previousOption === 'ok',
})
add('who', {
question: stringquestion: 'To whom should the addon say hello?',
type: "string"type: 'string' // boolean | number | select | multiselect
})
.function build(): {
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}
build(),
setup?: ((workspace: Workspace & {
dependsOn: (name: keyof OfficialAddons) => void;
unsupported: (reason: string) => void;
runsAfter: (name: keyof OfficialAddons) => void;
}) => MaybePromise<...>) | undefined
setup: ({ dependsOn: (name: keyof OfficialAddons) => voidOn what official addons does this addon depend on?
dependsOn, isKit: booleanisKit, unsupported: (reason: string) => voidWhy is this addon not supported?
unsupported }) => {
if (!isKit: booleanisKit) unsupported: (reason: string) => voidWhy is this addon not supported?
unsupported('Requires SvelteKit');
dependsOn: (name: keyof OfficialAddons) => voidOn what official addons does this addon depend on?
dependsOn('vitest');
},
run: (workspace: Workspace & {
options: OptionValues<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>;
sv: SvApi;
cancel: (reason: string) => void;
}) => MaybePromise<void>
run: ({ isKit: booleanisKit, cancel: (reason: string) => voidCancel the addon at any time!
cancel, sv: SvApisv, options: OptionValues<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>
Add-on options
options, file: {
viteConfig: "vite.config.js" | "vite.config.ts";
svelteConfig: "svelte.config.js" | "svelte.config.ts";
typeConfig: "jsconfig.json" | "tsconfig.json" | undefined;
stylesheet: `${string}/layout.css` | "src/app.css";
package: "package.json";
gitignore: ".gitignore";
prettierignore: ".prettierignore";
prettierrc: ".prettierrc";
eslintConfig: "eslint.config.js";
vscodeSettings: ".vscode/settings.json";
vscodeExtensions: ".vscode/extensions.json";
getRelative: ({ from, to }: {
from?: string;
to: string;
}) => string;
}
file, language: "ts" | "js"language, directory: {
src: string;
lib: string;
kitRoutes: string;
}
directory }) => {
// Add "Hello [who]!" to the root page
sv: SvApisv.file: (path: string, edit: (content: string) => string | false) => voidEdit a file in the workspace. (will create it if it doesn't exist)
Return false from the callback to abort - the original content is returned unchanged.
file(
directory: {
src: string;
lib: string;
kitRoutes: string;
}
directory.kitRoutes: stringkitRoutes + '/+page.svelte',
const transforms: {
script(cb: (file: {
ast: Program;
comments: Comments;
content: string;
js: typeof index_d_exports$3;
}) => void | false, options?: TransformOptions): (content: string) => string;
svelte(cb: (file: {
ast: AST.Root;
content: string;
svelte: typeof index_d_exports$4;
js: typeof index_d_exports$3;
}) => void | false, options?: TransformOptions): (content: string) => string;
... 6 more ...;
text(cb: (file: {
content: string;
text: typeof text_d_exports;
}) => string | false): TransformFn;
}
File transform primitives that know their format.
sv-utils = what to do to content, sv = where and when to do it.
Each transform wraps: parse -> callback({ast/data, utils}) -> generateCode().
The parser choice is baked into the transform type - you can't accidentally
parse a vite config as svelte because you never call a parser yourself.
Transforms are curried: call with the callback to get a (content: string) => string
function that plugs directly into sv.file().
transforms.function svelte(cb: (file: {
ast: AST.Root;
content: string;
svelte: typeof index_d_exports$4;
js: typeof index_d_exports$3;
}) => void | false, options?: TransformOptions): (content: string) => string
Transform a Svelte component file.
Return false from the callback to abort - the original content is returned unchanged.
svelte(({ ast: AST.Rootast, svelte: typeof index_d_exports$4svelte }) => {
svelte: typeof index_d_exports$4svelte.index_d_exports$4.addFragment(ast: AST.Root, content: string, options?: {
mode?: "append" | "prepend";
}): void
export index_d_exports$4.addFragment
addFragment(ast: AST.Rootast, `<p>Hello ${options: OptionValues<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>
Add-on options
options.who: "ERROR: The value for this type is invalid. Ensure that the `default` value exists in `options`."who}!</p>`);
})
);
},
nextSteps?: ((data: Workspace & {
options: OptionValues<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>;
}) => string[]) | undefined
nextSteps: ({ options: OptionValues<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>
options }) => ['enjoy the add-on!']
});The Svelte CLI is split into two packages with a clear boundary:
sv= where and when to do it. It owns paths, workspace detection, dependency tracking, and file I/O. The engine orchestrates add-on execution.@sveltejs/sv-utils= what to do to content. It provides parsers, language tooling, and typed transforms. Everything here is pure - no file system, no workspace awareness.
This separation means transforms are testable without a workspace and composable across add-ons.
Development
You can run your add-on locally using the file: protocol:
cd /path/to/test-project
npx sv add file:../path/to/my-addonThis allows you to iterate quickly without publishing to npm.
The file: protocol also works for custom or private add-ons that you don't intend to publish - for example, to standardize project setup across your team or organization.
The
demo-addscript automatically builds your add-on before running it.
Testing
The sv/testing module provides utilities for testing your add-on. createSetupTest is a factory that takes your vitest imports and returns a setupTest function. It creates real SvelteKit projects from templates, runs your add-on, and gives you access to the resulting files.
import { import expectexpect } from '@playwright/test';
import module "node:fs"fs from 'node:fs';
import const path: path.PlatformPathpath from 'node:path';
import { function createSetupTest(vitest: VitestContext): <Addons extends AddonMap>(addons: Addons, options?: SetupTestOptions<Addons>) => {
test: vitest.TestAPI<Fixtures>;
testCases: Array<AddonTestCase<AddonMap>>;
prepareServer: typeof prepareServer;
}
createSetupTest } from 'sv/testing';
import * as import vitestvitest from 'vitest';
import import addonaddon from './index.js';
const { const test: vitest.TestAPI<Fixtures>test, const testCases: AddonTestCase<AddonMap>[]testCases } = function createSetupTest(vitest: VitestContext): <Addons extends AddonMap>(addons: Addons, options?: SetupTestOptions<Addons>) => {
test: vitest.TestAPI<Fixtures>;
testCases: Array<AddonTestCase<AddonMap>>;
prepareServer: typeof prepareServer;
}
createSetupTest(import vitestvitest)(
{ addon: anyaddon },
{
kinds: {
type: string;
options: OptionMap<{
addon: any;
}>;
}[]
kinds: [
{
type: stringtype: 'default',
options: OptionMap<{
addon: any;
}>
options: {
'your-addon-name': { who: stringwho: 'World' }
}
}
],
filter?: ((addonTestCase: AddonTestCase<{
addon: any;
}>) => boolean) | undefined
filter: (testCase: AddonTestCase<{
addon: any;
}>
testCase) => testCase: AddonTestCase<{
addon: any;
}>
testCase.variant: ProjectVariantvariant.String.includes(searchString: string, position?: number): booleanReturns true if searchString appears as a substring of the result of converting this
object to a String, at one or more positions that are
greater than or equal to position; otherwise, returns false.
includes('kit'),
browser?: boolean | undefinedbrowser: false
}
);
const test: vitest.TestAPI<Fixtures>test.concurrent: ChainableFunction<"concurrent" | "sequential" | "only" | "skip" | "todo" | "fails", TestCollectorCallable<Fixtures>, {
each: TestEachFunction;
for: TestForFunction<Fixtures>;
}>
concurrent.for: TestForFunction
<AddonTestCase<AddonMap>>(cases: readonly AddonTestCase<AddonMap>[]) => TestForFunctionReturn<AddonTestCase<AddonMap>, vitest.TestContext & Fixtures> (+1 overload)
for(const testCases: AddonTestCase<AddonMap>[]testCases)('my-addon $kind.type $variant', async (testCase: AddonTestCase<AddonMap>testCase, ctx: vitest.TestContext & Fixturesctx) => {
const const cwd: stringcwd = ctx: vitest.TestContext & Fixturesctx.function cwd(addonTestCase: AddonTestCase<any>): stringcwd(testCase: AddonTestCase<AddonMap>testCase);
const const page: stringpage = module "node:fs"fs.function readFileSync(path: fs.PathOrFileDescriptor, options: {
encoding: BufferEncoding;
flag?: string | undefined;
} | BufferEncoding): string (+2 overloads)
Synchronously reads the entire contents of a file.
readFileSync(const path: path.PlatformPathpath.path.PlatformPath.resolve(...paths: string[]): stringThe right-most parameter is considered {to}. Other parameters are considered an array of {from}.
Starting from leftmost {from} parameter, resolves {to} to an absolute path.
If {to} isn't already absolute, {from} arguments are prepended in right to left order,
until an absolute path is found. If after using all {from} paths still no absolute path is found,
the current working directory is used as well. The resulting path is normalized,
and trailing slashes are removed unless the path gets resolved to the root directory.
resolve(const cwd: stringcwd, 'src/routes/+page.svelte'), 'utf8');
import expectexpect(const page: stringpage).toContain('Hello World!');
});Your vitest.config.js must include the global setup from sv/testing:
import { function defineConfig(config: UserConfig): UserConfig (+4 overloads)defineConfig } from 'vitest/config';
export default function defineConfig(config: UserConfig): UserConfig (+4 overloads)defineConfig({
UserConfig.test?: InlineConfig | undefinedOptions for Vitest
test: {
InlineConfig.include?: string[] | undefinedA list of glob patterns that match your test files.
include: ['tests/**/*.test.{js,ts}'],
InlineConfig.globalSetup?: string | string[] | undefinedPath to global setup files
globalSetup: ['tests/setup/global.js']
}
});And the global test setup script tests/setup/global.js:
import { function fileURLToPath(url: string | URL, options?: FileUrlToPathOptions): stringThis function ensures the correct decodings of percent-encoded characters as
well as ensuring a cross-platform valid absolute path string.
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
new URL('file:///C:/path/').pathname; // Incorrect: /C:/path/
fileURLToPath('file:///C:/path/'); // Correct: C:\path\ (Windows)
new URL('file://nas/foo.txt').pathname; // Incorrect: /foo.txt
fileURLToPath('file://nas/foo.txt'); // Correct: \\nas\foo.txt (Windows)
new URL('file:///你好.txt').pathname; // Incorrect: /%E4%BD%A0%E5%A5%BD.txt
fileURLToPath('file:///你好.txt'); // Correct: /你好.txt (POSIX)
new URL('file:///hello world').pathname; // Incorrect: /hello%20world
fileURLToPath('file:///hello world'); // Correct: /hello world (POSIX)
fileURLToPath } from 'node:url';
import { function setupGlobal({ TEST_DIR, pre, post }: {
TEST_DIR: string;
pre?: () => Promise<void>;
post?: () => Promise<void>;
}): ({ provide }: TestProject) => Promise<() => Promise<void>>
setupGlobal } from 'sv/testing';
const const TEST_DIR: stringTEST_DIR = function fileURLToPath(url: string | URL, options?: FileUrlToPathOptions): stringThis function ensures the correct decodings of percent-encoded characters as
well as ensuring a cross-platform valid absolute path string.
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
new URL('file:///C:/path/').pathname; // Incorrect: /C:/path/
fileURLToPath('file:///C:/path/'); // Correct: C:\path\ (Windows)
new URL('file://nas/foo.txt').pathname; // Incorrect: /foo.txt
fileURLToPath('file://nas/foo.txt'); // Correct: \\nas\foo.txt (Windows)
new URL('file:///你好.txt').pathname; // Incorrect: /%E4%BD%A0%E5%A5%BD.txt
fileURLToPath('file:///你好.txt'); // Correct: /你好.txt (POSIX)
new URL('file:///hello world').pathname; // Incorrect: /hello%20world
fileURLToPath('file:///hello world'); // Correct: /hello world (POSIX)
fileURLToPath(new var URL: new (url: string | URL, base?: string | URL) => URLThe URL interface is used to parse, construct, normalize, and encode URL.
URL class is a global reference for import { URL } from 'node:url'
https://nodejs.org/api/url.html#the-whatwg-url-api
URL('../../.test-output/', import.meta.ImportMeta.url: stringThe absolute file: URL of the module.
url));
export default function setupGlobal({ TEST_DIR, pre, post }: {
TEST_DIR: string;
pre?: () => Promise<void>;
post?: () => Promise<void>;
}): ({ provide }: TestProject) => Promise<() => Promise<void>>
setupGlobal({ type TEST_DIR: stringTEST_DIR });Publishing
Bundling
Community add-ons are bundled with tsdown into a single file. Everything is bundled except sv. (It is a peer dependency provided at runtime.)
package.json
Your add-on must have sv as a peer dependency and no dependencies in package.json:
{
"name": "@my-org/sv",
"version": "1.0.0",
"type": "module",
// bundled entrypoint (tsdown outputs .mjs for ESM)
"exports": {
".": { "default": "./dist/index.mjs" }
},
"publishConfig": {
"access": "public"
},
// cannot have dependencies
"dependencies": {},
"peerDependencies": {
// minimum version required to run by this add-on
"sv": "^0.13.0"
},
// Add the "sv-add" keyword so users can discover your add-on
"keywords": ["sv-add", "svelte", "sveltekit"]
}Naming convention
packages names
If you name your package @my-org/sv, users can install it by typing just the org handle:
npx sv add @my-orgIt's also possible to publish like @my-org/core, just users will need to type the full package name.
npx sv add @my-org/coreUnscoped packages are not supported yet
export options
sv first tries to import your-package/sv, then falls back to the default export. This means you have two options:
Default export (for dedicated add-on packages):
{ "exports": { ".": "./src/index.js" } }./svexport (for packages that also export other functionality):{ "exports": { ".": "./src/main.js", "./sv": "./src/addon.js" } }
Publish to npm
npm login
npm publish
prepublishOnlyautomatically runs the build before publishing.
Next steps
You can optionally display guidance in the console after your add-on runs:
import { const color: {
addon: (str: string) => string;
command: (str: string) => string;
env: (str: string) => string;
path: (str: string) => string;
route: (str: string) => string;
website: (str: string) => string;
optional: (str: string) => string;
dim: (str: string) => string;
success: (str: string) => string;
warning: (str: string) => string;
error: (str: string) => string;
hidden: (str: string) => string;
}
color } from '@sveltejs/sv-utils';
export default defineAddon({
// ...
nextSteps: ({ options }: {
options: any;
}) => string[]
nextSteps: ({ options: anyoptions }) => [
`Run ${const color: {
addon: (str: string) => string;
command: (str: string) => string;
env: (str: string) => string;
path: (str: string) => string;
route: (str: string) => string;
website: (str: string) => string;
optional: (str: string) => string;
dim: (str: string) => string;
success: (str: string) => string;
warning: (str: string) => string;
error: (str: string) => string;
hidden: (str: string) => string;
}
color.command: (str: string) => stringcommand('npm run dev')} to start developing`,
`Check out the docs at https://...`
]
});Version compatibility
Your add-on should specify a minimum sv version in peerDependencies. Your users will get a compatibility warning if their sv version has a different major version than what was specified.
Examples
See the official add-on source code for some real world examples.