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 | false
function that plugs directly into sv.file().
import{transforms}from'@sveltejs/sv-utils';// use with sv.file() - curried form plugs in directlysv.file(files.viteConfig,transforms.script(({ast,js})=>{js.vite.addPlugin(ast,{code:'kitRoutes()'});}));// standalone usage / testingconstresult=transforms.script(({ast,js})=>{js.imports.addDefault(ast,{as:'foo',from:'foo'});})(fileContent);
If your add-on adds dynamic options via addOption during setup, pass their
types as a type argument:
constaddon=defineAddon<{extra:boolean}>()({...});// Take note of the extra call here: 👆👆// This works around Typescript's lack of partial type argumentsaddon.options.extra.default;// boolean
}from'sv';exportdefaultdefineAddon(alias)defineAddon<"addon-name",{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}>(config:Addon<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;},"addon-name",Record<string,unknown>>):Addon<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;},"addon-name",Record<string,unknown>>(+1overload)importdefineAddon
If your add-on adds dynamic options via addOption during setup, pass their
types as a type argument:
constaddon=defineAddon<{extra:boolean}>()({...});// Take note of the extra call here: 👆👆// This works around Typescript's lack of partial type argumentsaddon.options.extra.default;// boolean
({id(property)id:"addon-name":'addon-name',shortDescription(property)shortDescription?:string|undefined:'a better description of what your addon does ;)',options(property)options:{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}:defineAddonOptions(alias)defineAddonOptions():OptionBuilder<{}>importdefineAddonOptions
Options for an addon.
Will be prompted to the user if there are not answered by args when calling the cli.
().add(method)add<"who",Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>>(key:"who",question:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>):OptionBuilder<Record<"who",Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"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, multiselectdefault:true,// condition: (o) => o.previousOption === 'ok',})
('who',{question(property)question:string:'To whom should the addon say hello?',type(property)type:"string":'string'// boolean | number | select | multiselect}).build(method)build():{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}(),setup(property)setup?:((workspace:Workspace&{dependsOn:(name:keyofOfficialAddons)=>void;unsupported:(reason:string)=>void;runsAfter:(name:keyofOfficialAddons)=>void;addOption:<Kextendsstring>(key:K,question:BaseQuestion<any>&Question<any>)=>void;})=>MaybePromise<void>)|undefined:({dependsOn(parameter)dependsOn:(name:keyofOfficialAddons)=>void
On what official addons does this addon depend on?
On what official addons does this addon depend on?
('vitest'); // dynamically add options (e.g. based on workspace state or fetched data)
// addOption('key', { question: '...', type: 'boolean', default: true });
},run(property)run:(workspace:Workspace&{options:OptionValues<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}>&Record<string,unknown>;sv:SvApi;cancel:(reason:string)=>void;})=>MaybePromise<void>:({isKit(parameter)isKit:boolean,cancel(parameter)cancel:(reason:string)=>void
Cancel the addon at any time!
,sv(parameter)sv:SvApi,options(parameter)options:OptionValues<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}>&Record<string,unknown>
Add-on options (includes dynamically added options from setup)
,file(parameter)file:{viteConfig:"vite.config.js"|"vite.config.ts";typeConfig:"jsconfig.json"|"tsconfig.json"|undefined;stylesheet:`${string}/layout.css`|"src/app.css";package:"package.json";gitignore:".gitignore";getRelative:({from,to}:{from?:string;to:string;})=>string;findUp:(filename:string)=>string;},language(parameter)language:"ts"|"js",directory(parameter)directory:{src:string;lib:string;kitRoutes:string;}})=>{ // Add "Hello [who]!" to the root page
sv(parameter)sv:SvApi.file(property)file:(path:string,edit:FileEdit)=>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.
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 | false
function that plugs directly into sv.file().
import{transforms}from'@sveltejs/sv-utils';// use with sv.file() - curried form plugs in directlysv.file(files.viteConfig,transforms.script(({ast,js})=>{js.vite.addPlugin(ast,{code:'kitRoutes()'});}));// standalone usage / testingconstresult=transforms.script(({ast,js})=>{js.imports.addDefault(ast,{as:'foo',from:'foo'});})(fileContent);
(({ast(parameter)ast:AST.Root,svelte(parameter)svelte:typeofindex_d_exports$4})=>{svelte(parameter)svelte:typeofindex_d_exports$4.addFragment(alias)index_d_exports$4.addFragment(ast:AST.Root,content:string,options?:{mode?:"append"|"prepend";language?:"ts"|"js";}):voidexportindex_d_exports$4.addFragment(ast(parameter)ast:AST.Root,`<p>Hello${options(parameter)options:OptionValues<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}>&Record<string,unknown>
Add-on options (includes dynamically added options from setup)
.who(property)who:unknown}!</p>`);}));},nextSteps(property)nextSteps?:((workspace:Workspace&{options:OptionValues<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}>&Record<string,unknown>;})=>string[])|undefined:({options(parameter)options:OptionValues<{who:Question<Record<"who",{readonlyquestion:"To whom should the addon say hello?";readonlytype:"string";}>>;}>&Record<string,unknown>})=>['enjoy the add-on!']});
The 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:
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.
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.
searchString
search string
position
If position is undefined, 0 is assumed, so as to search all of the String.
For detailed information, see the documentation of the asynchronous version of
this API: fs.readFile().
If the encoding option is specified then this function returns a
string. Otherwise it returns a buffer.
If buffer is provided and no encoding is specified, the returned {Buffer} is
a view over the supplied buffer containing only the bytes read. If the
supplied buffer is too small to contain the entire file, an error will be
thrown.
Similar to fs.readFile(), when the path is a directory, the behavior of
fs.readFileSync() is platform-specific.
import{readFileSync}from'node:fs';// macOS, Linux, and WindowsreadFileSync('<directory>');// => [Error: EISDIR: illegal operation on a directory, read <directory>]// FreeBSDreadFileSync('<directory>');// => <data>
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.
paths
A sequence of paths or path segments.
{TypeError} if any of the arguments is not a string.
And the global test setup script tests/setup/global.js:
importpath(alias)namespacepath(alias)constpath:path.PlatformPathimportpathfrom'node:path';import{setupGlobal(alias)functionsetupGlobal({TEST_DIR,pre,post}:{TEST_DIR:string;pre?:()=>Promise<void>;post?:()=>Promise<void>;}):({provide}:TestProject)=>Promise<()=>Promise<void>>importsetupGlobal}from'sv/testing';// test projects are scaffolded into `<project-root>/.test-output`
constTEST_DIRconstTEST_DIR:string=path(alias)namespacepath(alias)constpath:path.PlatformPathimportpath.resolve(method)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.
paths
A sequence of paths or path segments.
{TypeError} if any of the arguments is not a string.
(import.meta
The type of import.meta.
If you need to declare that a given property exists on import.meta,
this type may be augmented via interface merging.
.dirname(property)ImportMeta.dirname:string
The directory name of the current module.
This is the same as the path.dirname() of the import.meta.filename.
Community add-ons are bundled with tsdown into a single file. Everything is bundled except sv. (It is a peer dependency provided at runtime.)
sv ships its own copy of @sveltejs/sv-utils, so an add-on that leaves it unbundled will still load. Nothing verifies the version: your add-on runs against whatever sv provides, and following its breaking changes is up to you. Bundle it to stay on a version you control.
package.json
Your add-on must have sv as a peer dependency. Any dependencies declared will not be available at runtime, everything must be bundled:
{"name":"@my-org/sv","version":"1.0.0","type":"module",// bundled entry point (tsdown outputs .mjs for ESM)"exports":{".":{"default":"./dist/index.mjs"}},"publishConfig":{"access":"public"},// packages declared here will not be available during runtime, it must be bundled"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 with https://www.npmx.dev/search?q=keyword:sv-add"keywords":["sv-add","svelte","sveltekit"]}
prepublishOnly automatically runs the build before publishing.
Next steps
You can optionally display guidance in the console after your add-on runs:
import{color(alias)constcolor:{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;}importcolor}from'@sveltejs/sv-utils';exportdefaultdefineAddonany({ // ...
nextSteps(property)nextSteps:({options}:{options:any;})=>string[]:({options(parameter)options:any})=>[`Run${color(alias)constcolor:{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;}importcolor.command(property)command:(str:ColorInput)=>string('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.