[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 { import transformstransforms } from '@sveltejs/sv-utils';
import { function defineAddon<const Id$1 extends string, Args extends OptionDefinition>(config: Addon<Args, Id$1>): Addon<Args, Id$1>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";
}>>;
}
Finalize all options of your add-on.
build(),
setup?: ((workspace: Workspace & {
dependsOn: (name: keyof OfficialAddons) => void;
unsupported: (reason: string) => void;
runsAfter: (name: keyof OfficialAddons) => void;
}) => MaybePromise<...>) | undefined
Setup the addon. Will be called before the addon is run.
setup: ({ dependsOn: (name: keyof OfficialAddons) => voidOn what official addons does this addon depend on?
dependsOn, isKit: anyisKit, unsupported: (reason: string) => voidWhy is this addon not supported?
unsupported }) => {
if (!isKit: anyisKit) 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 the addon. The actual execution of the addon... Add files, edit files, etc.
run: ({ isKit: anyisKit, cancel: (reason: string) => voidCancel the addon at any time!
cancel, sv: SvApiApi to interact with the workspace.
sv, options: OptionValues<{
who: Question<Record<"who", {
readonly question: "To whom should the addon say hello?";
readonly type: "string";
}>>;
}>
Add-on options
options, file: anyfile, language: "ts" | "js"to know if the workspace is using typescript or javascript
language, directory: anydirectory }) => {
// Add "Hello [who]!" to the root page
sv: SvApiApi to interact with the workspace.
sv.file: (path: string, edit: (content: string) => string) => voidEdit a file in the workspace. (will create it if it doesn't exist)
file(
directory: anydirectory.kitRoutes + '/+page.svelte',
import transformstransforms.svelte(({ ast: anyast, svelte: anysvelte }) => {
svelte: anysvelte.addFragment(ast: anyast, `<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
Next steps to display after the addon is run.
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 { import createSetupTestcreateSetupTest } from 'sv/testing';
import * as import vitestvitest from 'vitest';
import import addonaddon from './index.js';
const { const test: anytest, const testCases: anytestCases } = import createSetupTestcreateSetupTest(import vitestvitest)(
{ addon: anyaddon },
{
kinds: {
type: string;
options: {
'your-addon-name': {
who: string;
};
};
}[]
kinds: [
{
type: stringtype: 'default',
options: {
'your-addon-name': {
who: string;
};
}
options: {
'your-addon-name': { who: stringwho: 'World' }
}
}
],
filter: (testCase: any) => anyfilter: (testCase: anytestCase) => testCase: anytestCase.variant.includes('kit'),
browser: booleanbrowser: false
}
);
const test: anytest.concurrent.for(const testCases: anytestCases)('my-addon $kind.type $variant', async (testCase: anytestCase, ctx: anyctx) => {
const const cwd: anycwd = ctx: anyctx.cwd(testCase: anytestCase);
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: anycwd, '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/coreUsers can also ask for a specific version:
npx sv add @my-org/sv@1.2.3When no version is specified, latest is used.
Unscoped 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.