Skip to main content

[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) => void

On what official addons does this addon depend on?

dependsOn
, isKit: anyisKit, unsupported: (reason: string) => void

Why is this addon not supported?

unsupported
}) => {
if (!isKit: anyisKit) unsupported: (reason: string) => void

Why is this addon not supported?

unsupported
('Requires SvelteKit');
dependsOn: (name: keyof OfficialAddons) => void

On 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) => void

Cancel the addon at any time!

cancel
, sv: SvApi

Api 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: SvApi

Api to interact with the workspace.

sv
.file: (path: string, edit: (content: string) => string) => void

Edit 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-addon

This 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-add script 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.

@param
path A path to a file. If a URL is provided, it must use the file: protocol. If a file descriptor is provided, the underlying file will not be closed automatically.
@param
options Either the encoding for the result, or an object that contains the encoding and an optional flag. If a flag is not provided, it defaults to 'r'.
readFileSync
(const path: path.PlatformPathpath.path.PlatformPath.resolve(...paths: string[]): string

The 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.

@param
paths A sequence of paths or path segments.
@throws
TypeError if any of the arguments is not a string.
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 | undefined

Options for Vitest

test
: {
InlineConfig.include?: string[] | undefined

A list of glob patterns that match your test files.

@default
['**/*.{test,spec}.?(c|m)[jt]s?(x)']
include
: ['tests/**/*.test.{js,ts}'],
InlineConfig.globalSetup?: string | string[] | undefined

Path 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): string

This 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)
@since
v10.12.0
@param
url The file URL string or URL object to convert to a path.
@return
The fully-resolved platform-specific Node.js file path.
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): string

This 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)
@since
v10.12.0
@param
url The file URL string or URL object to convert to a path.
@return
The fully-resolved platform-specific Node.js file path.
fileURLToPath
(new var URL: new (url: string | URL, base?: string | URL) => URL

The URL interface is used to parse, construct, normalize, and encode URL.

MDN Reference

URL class is a global reference for import { URL } from 'node:url' https://nodejs.org/api/url.html#the-whatwg-url-api

@since
v10.0.0
URL
('../../.test-output/', import.meta.ImportMeta.url: string

The 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-org

It's also possible to publish like @my-org/core, just users will need to type the full package name.

npx sv add @my-org/core

Users can also ask for a specific version:

npx sv add @my-org/sv@1.2.3

When 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:

  1. Default export (for dedicated add-on packages):

    {
    	"exports": {
    		".": "./src/index.js"
    	}
    }
  2. ./sv export (for packages that also export other functionality):

    {
    	"exports": {
    		".": "./src/main.js",
    		"./sv": "./src/addon.js"
    	}
    }

Publish to npm

npm login
npm publish

prepublishOnly automatically 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.

Edit this page on GitHub llms.txt

previous next