Skip to main content

[create your own]

Les add-ons communautaires sont actuellement expérimentaux. Leur API peut changer. Ne les utilisez pas encore en production !

Ce guide vous explique comment créer, tester, et publier des add-ons communautaires pour sv.

Démarrage rapide

La façon la plus simple de créer un add-on est d'utiliser le template addon :

npx sv create --template addon [path]

Le projet nouvellement créé aura des fichiers README.md et CONTRIBUTING.md pour vous accompagner.

Structure du projet

Un add-on ressemble généralement à ceci :

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().

@example
import { transforms } from '@sveltejs/sv-utils';

// use with sv.file() - curried form plugs in directly
sv.file(files.viteConfig, transforms.script(({ ast, js }) => {
  js.vite.addPlugin(ast, { code: 'kitRoutes()' });
}));

// standalone usage / testing
const result = transforms.script(({ ast, js }) => {
  js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
})(fileContent);
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: "À qui cet add-on doit-il dire bonjour ?";
        readonly type: "string";
    }>>;
}>(config: Addon<{
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        readonly type: "string";
    }>>;
}, "addon-name">): Addon<{
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        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: 'une meilleure description de ce que fait votre add-on ;)',
options: {
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        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: "À qui cet add-on doit-il dire bonjour ?";
    readonly type: "string";
}>>>(key: "who", question: Question<Record<"who", {
    readonly question: "À qui cet add-on doit-il dire bonjour ?";
    readonly type: "string";
}>>): OptionBuilder<Record<"who", Question<Record<"who", {
    readonly question: "À qui cet add-on doit-il dire bonjour ?";
    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: 'À qui cet add-on doit-il dire bonjour ?', type: "string"type: 'string' // boolean | number | select | multiselect }) .
function build(): {
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        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) => void

On what official addons does this addon depend on?

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

Why is this addon not supported?

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

Why is this addon not supported?

unsupported
('Nécessite 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: "À qui cet add-on doit-il dire bonjour ?";
            readonly type: "string";
        }>>;
    }>;
    sv: SvApi;
    cancel: (reason: string) => void;
}) => MaybePromise<void>
run
: ({ isKit: booleanisKit, cancel: (reason: string) => void

Cancel the addon at any time!

cancel
, sv: SvApisv,
options: OptionValues<{
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        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
}) => {
// Ajoute "Bonjour [who] !" sur la page racine sv: SvApisv.file: (path: string, edit: (content: string) => string | false) => void

Edit 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().

@example
import { transforms } from '@sveltejs/sv-utils';

// use with sv.file() - curried form plugs in directly
sv.file(files.viteConfig, transforms.script(({ ast, js }) => {
  js.vite.addPlugin(ast, { code: 'kitRoutes()' });
}));

// standalone usage / testing
const result = transforms.script(({ ast, js }) => {
  js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
})(fileContent);
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>Bonjour ${
options: OptionValues<{
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        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?: ((workspace: Workspace & {
    options: OptionValues<{
        who: Question<Record<"who", {
            readonly question: "À qui cet add-on doit-il dire bonjour ?";
            readonly type: "string";
        }>>;
    }>;
}) => string[]) | undefined
nextSteps
: ({
options: OptionValues<{
    who: Question<Record<"who", {
        readonly question: "À qui cet add-on doit-il dire bonjour ?";
        readonly type: "string";
    }>>;
}>
options
}) => ['profitez de cet add-on!']
});

Le CLI de Svelte est divisé en deux paquets avec un frontière claire :

  • sv = où et quand. Ce paquet gère les paths, la détection de workspace, le suivi de dépendances, et l'I/O de fichier. Le moteur orchestre l'exécution de l'add-on.
  • @sveltejs/sv-utils = quoi. Ce paquet fournit des parsers, de l'outillage de language, et des transformations typées. Tout ici est pur — pas de système de fichiers, pas de conscience du workspace.

Cette séparation implique que les transformations sont testables sans aucun workspace, et composables entre add-ons.

Développement

Vous pouvez exécuter votre add-on localement avec le protocole file: :

cd /path/to/test-project
npx sv add file:../path/to/my-addon

Ceci vous permet d'itérer rapidement sans publier sur npm.

Le protocole file: fonctionne également pour les add-ons privés ou personnalisés que vous n'avez pas l'intention de publier — par exemple, pour standardiser la mise en place d'un projet au sein de votre équipe ou de votre organisation.

Le scipt demo-add compile automatiquement votre add-on avant de l'exécuter.

Tests

Le module sv/testing fournit des utilitaires pour tester votre add-on. createSetupTest est une usine qui prend vos imports vitest et renvoie une fonction setupTest. Elle crée des vrais projets SvelteKit à partir de templates, exécute votre add-on, et vous donne accès aux fichiers ainsi générés.

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, playwright?: PlaywrightContext): <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, playwright?: PlaywrightContext): <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): boolean

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

@param
searchString search string
@param
position If position is undefined, 0 is assumed, so as to search all of the String.
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.

@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: stringcwd, 'src/routes/+page.svelte'), 'utf8');
import expectexpect(const page: stringpage).toContain('Bonjour tout le monde !'); });

Votre fichier vitest.config.js doit contenir la configuration globale de 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']
} });

Le script tests/setup/global.js étant le suivant :

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 });

Publier

Bundling

Les add-on communautaires sont compilés avec tsdown en un seul fichier. Tout est compilé, à l'exception de sv (qui est une dépendance-paire, fournie lors de l'exécution.)

package.json

Votre add-on doit avoir sv comme dépendance-paire et aucune dépendance dans votre package.json :

{
	"name": "@my-org/sv",
	"version": "1.0.0",
	"type": "module",
	// point d'entrée compilé (tsdown génère des .mjs pour les ESM)
	"exports": {
		".": { "default": "./dist/index.mjs" }
	},
	"publishConfig": {
		"access": "public"
	},
	// ne peut pas avoir de dépendances
	"dependencies": {},
	"peerDependencies": {
		// la version minimum requise pour exécuter cet add-on
		"sv": "^0.13.0"
	},
	// Ajoutez le mot-clé "sv-add" afin que les gens puissent trouver votre add-on
	"keywords": ["sv-add", "svelte", "sveltekit"]
}

Conventions de nommage

Noms de paquets

Si vous appelez vos paquet @my-org/sv, les gens pourront l'installer juste en tapant le nom de votre organisation :

npx sv add @my-org

Il est également possible de publier avec un nom comme @my-org/core, les gens devront alors taper le nom complet du paquet :

npx sv add @my-org/core

Il est également possible de demander une version précise :

npx sv add @my-org/sv@1.2.3

Lorsqu'aucune version n'est demandée, latest est utilisé.

Les paquets non scopés ne sont pas encore supportés

Options d'export

sv essaye d'abord d'importer your-package/sv, puis utilise l'export par défaut s'il n'y arrive pas. Ceci signifie que vous avez deux options :

  1. L'export par défaut (pour les paquets d'add-on dédiés) :

    {
    	"exports": {
    		".": "./src/index.js"
    	}
    }
  2. L'export ./sv (pour les paquets qui exportent également d'autres fonctionnalités):

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

Publier sur npm

npm login
npm publish

prepublishOnly exécute automatiquement le build avant de publier.

Prochaines étapes

Vous pouvez de manière optionnelle afficher des indications à afficher lors de l'exécution de votre add-on :

import { 
const color: {
    addon: (str: ColorInput) => string;
    command: (str: ColorInput) => string;
    env: (str: ColorInput) => string;
    path: (str: ColorInput) => string;
    route: (str: ColorInput) => string;
    website: (str: ColorInput) => string;
    optional: (str: ColorInput) => string;
    dim: (str: ColorInput) => string;
    success: (str: ColorInput) => string;
    warning: (str: ColorInput) => string;
    error: (str: ColorInput) => string;
    hidden: (str: ColorInput) => string;
}
color
} from '@sveltejs/sv-utils';
export default defineAddon({ // ...
nextSteps: ({ options }: {
    options: any;
}) => string[]
nextSteps
: ({ options: anyoptions }) => [
`Lancez ${
const color: {
    addon: (str: ColorInput) => string;
    command: (str: ColorInput) => string;
    env: (str: ColorInput) => string;
    path: (str: ColorInput) => string;
    route: (str: ColorInput) => string;
    website: (str: ColorInput) => string;
    optional: (str: ColorInput) => string;
    dim: (str: ColorInput) => string;
    success: (str: ColorInput) => string;
    warning: (str: ColorInput) => string;
    error: (str: ColorInput) => string;
    hidden: (str: ColorInput) => string;
}
color
.command: (str: ColorInput) => stringcommand('npm run dev')} pour commencer le développement`,
`Retrouvez la documentation sur https://...` ] });

Compatibilité de version

Votre add-on doit préciser la version minimale de sv requises dans vos peerDependencies. Si la version de sv d'un utilisateur ou d'un utilisatrice a une majeure différente de celle pour laquelle votre add-on a été conçu, ils et elles verront un avertissement de compatibilité.

Exemples

Consultez le code source des add-ons officiels pour trouver des exemples concrets.

Modifier cette page sur Github llms.txt

précédent suivant