Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/social-boxes-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/sv-utils': patch
---

feat: add `transform` api to simplify add-on creation
85 changes: 62 additions & 23 deletions documentation/docs/40-api/10-add-on.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ The easiest way to create an add-on is using the addon template:

```sh
npx sv create --template addon my-addon
cd my-addon
```

## Add-on structure
Expand All @@ -23,7 +22,8 @@ Typically, an add-on looks like this:
_hover keywords in the code to have some more context_

```js
import { parse, svelte } from '@sveltejs/sv-utils';
// @noErrors
import { transforms } from '@sveltejs/sv-utils';
import { defineAddon, defineAddonOptions } from 'sv';

// Define options that will be prompted to the user (or passed as arguments)
Expand All @@ -37,6 +37,8 @@ const options = defineAddonOptions()
// your add-on definition, the entry point
export default defineAddon({
id: 'your-addon-name',
// shortDescription: 'does X', // optional: one-liner shown in prompts
// homepage: 'https://...', // optional: link to docs/repo

options,

Expand All @@ -46,21 +48,20 @@ export default defineAddon({
},

// actual execution of the addon
run: ({ kit, cancel, sv, options }) => {
if (!kit) return cancel('SvelteKit is required');
run: ({ isKit, cancel, sv, options, directory }) => {
if (!isKit) return cancel('SvelteKit is required');

// Add "Hello [who]!" to the root page
sv.file(kit.routesDirectory + '/+page.svelte', (content) => {
const { ast, generateCode } = parse.svelte(content);

sv.file(directory.routes + '/+page.svelte', transforms.svelte(({ ast, svelte }) => {
svelte.addFragment(ast, `<p>Hello ${options.who}!</p>`);

return generateCode();
});
}));
}
});
```

> `sv` owns the file system - `sv.file()` resolves the path, reads the file, applies the edit function, and writes the result.
> `@sveltejs/sv-utils` owns the content - `transforms.svelte()` returns a curried function that handles parsing, gives you the AST and utils, and serializes back. See [sv-utils](/docs/cli/sv-utils) for the full API.

## Development with `file:` protocol

While developing your add-on, you can test it locally using the `file:` protocol:
Expand All @@ -77,8 +78,8 @@ This allows you to iterate quickly without publishing to npm.
The `sv/testing` module provides utilities for testing your add-on:

```js
import { test, expect } from 'vitest';
import { setupTest } from 'sv/testing';
import { test, expect } from 'vitest';
import addon from './index.js';

test('adds hello message', async () => {
Expand All @@ -94,27 +95,44 @@ test('adds hello message', async () => {
});
```

## Publishing to npm
## Building and publishing

### Bundling

Community add-ons are bundled with [tsdown](https://tsdown.dev/) into a single file. Everything is bundled except `sv` (peer dependency, provided at runtime).

```sh
npm run build
```

### Package structure

Your add-on must have `sv` as a dependency in `package.json`:
Your add-on must have `sv` as a peer dependency and **no** `dependencies` in `package.json`:

```json
{
"name": "@your-org/sv",
"version": "1.0.0",
"type": "module",
"exports": {
".": "./dist/index.js"
".": "./src/index.js"
},
"publishConfig": {
"access": "public",
"exports": {
".": { "default": "./dist/index.js" }
}
},
"dependencies": {
"sv": "^0.11.0"
"peerDependencies": {
"sv": "^0.13.0"
},
"keywords": ["sv-add"]
}
```

- `exports` points to `./src/index.js` for local development with the `file:` protocol.
- `publishConfig.exports` overrides exports when publishing, pointing to the bundled `./dist/index.js`.

> [!NOTE]
> Add the `sv-add` keyword so users can discover your add-on on npm.

Expand All @@ -127,7 +145,7 @@ Your package can export the add-on in two ways:
```json
{
"exports": {
".": "./dist/index.js"
".": "./src/index.js"
}
}
```
Expand All @@ -136,17 +154,38 @@ Your package can export the add-on in two ways:
```json
{
"exports": {
".": "./dist/main.js",
"./sv": "./dist/addon.js"
".": "./src/main.js",
"./sv": "./src/addon.js"
}
}
```

### Naming conventions
### Publishing

Community add-ons must be scoped packages (e.g. `@your-org/sv`). Users install with `npx sv add @your-org`.

```sh
npm login
npm publish
```

> `prepublishOnly` automatically runs the build before publishing.

- **Scoped packages**: Use `@your-org/sv` as the package name. Users can then install with just `npx sv add @your-org`.
- **Regular packages**: Any name works. Users install with `npx sv add your-package-name`.
## Next steps

You can optionally display guidance after your add-on runs:

```js
// @noErrors
export default defineAddon({
// ...
nextSteps: ({ options }) => [
`Run ${color.command('npm run dev')} to start developing`,
`Check out the docs at https://...`
]
});
```

## Version compatibility

Your add-on should specify the minimum `sv` version it requires in `package.json`. If a user's `sv` version has a different major version than what your add-on was built for, they will see a compatibility warning.
Your add-on should specify the minimum `sv` version it requires in `peerDependencies`. If a user's `sv` version has a different major version than what your add-on was built for, they will see a compatibility warning.
192 changes: 191 additions & 1 deletion documentation/docs/40-api/20-sv-utils.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,195 @@ title: sv-utils
`@sveltejs/sv-utils` provides utilities for parsing, transforming, and generating code in add-ons.

```sh
npm install @sveltejs/sv-utils
npm install -D @sveltejs/sv-utils
```

## Architecture

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.

## Transforms

Transforms are curried, parser-aware functions that turn `string -> string`. Call a transform with your callback to get a function that plugs directly into `sv.file()`. 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.

Each transform injects relevant utilities into the callback, so you only need one import:

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

### `transforms.script`

Transform a JavaScript/TypeScript file. The callback receives `{ ast, comments, content, js }`.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file(files.viteConfig, transforms.script(({ ast, js }) => {
js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
js.vite.addPlugin(ast, { code: 'foo()' });
}));
```

### `transforms.svelte`

Transform a Svelte component. The callback receives `{ ast, content, svelte, js }`.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file(layoutPath, transforms.svelte(({ ast, svelte }) => {
svelte.addFragment(ast, '<Foo />');
}));
```

### `transforms.svelteScript`

Transform a Svelte component with a `<script>` block guaranteed. Pass `{ language }` as the first argument. The callback receives `{ ast, content, svelte, js }` where `ast.instance` is always non-null.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file(layoutPath, transforms.svelteScript({ language }, ({ ast, svelte, js }) => {
js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' });
svelte.addFragment(ast, '<Foo />');
}));
```

### `transforms.css`

Transform a CSS file. The callback receives `{ ast, content, css }`.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file(files.stylesheet, transforms.css(({ ast, css }) => {
css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" });
}));
```

### `transforms.json`

Transform a JSON file. Mutate the `data` object directly. The callback receives `{ data, content, json }`.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file(files.tsconfig, transforms.json(({ data }) => {
data.compilerOptions ??= {};
data.compilerOptions.strict = true;
}));
```

### `transforms.yaml` / `transforms.toml`

Same pattern as `transforms.json`, for YAML and TOML files respectively. The callback receives `{ data, content }`.

### `transforms.text`

Transform a plain text file (.env, .gitignore, etc.). No parser - string in, string out. The callback receives `{ content, text }`.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file('.env', transforms.text(({ content }) => {
return content + '\nDATABASE_URL="file:local.db"';
}));
```

### Aborting a transform

Return `false` from any transform callback to abort - the original content is returned unchanged.

```js
// @noErrors
import { transforms } from '@sveltejs/sv-utils';

sv.file(files.eslintConfig, transforms.script(({ ast, js }) => {
const { value: existing } = js.exports.createDefault(ast, { fallback: myConfig });
if (existing !== myConfig) {
// config already exists, don't touch it
return false;
}
// ... continue modifying ast
}));
```

### Standalone usage & testing

Transforms are curried functions - call them with the callback, then apply to content:

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

const result = transforms.script(({ ast, js }) => {
js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
})('export default {}');
```

### Composability

For cases where you need to mix transforms and raw edits, use `sv.file` with a content callback and invoke the curried transform manually:

```js
// @noErrors
sv.file(path, (content) => {
content = transforms.script(({ ast, js }) => {
js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
})(content);
content = content.replace('foo', 'bar');
return content;
});
```

Add-ons can also export reusable transform functions:

```js
// @errors: 7006
import { transforms } from '@sveltejs/sv-utils';

// reusable - export from your package
export const addFooImport = transforms.svelte(({ ast, svelte, js }) => {
svelte.ensureScript(ast, { language });
js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' });
});
```

## Parsers (low-level)

For cases where transforms don't fit (e.g., conditional parsing, error handling around the parser), the `parse` namespace is still available:

```js
// @noErrors
import { parse } from '@sveltejs/sv-utils';

const { ast, generateCode } = parse.script(content);
const { ast, generateCode } = parse.svelte(content);
const { ast, generateCode } = parse.css(content);
const { data, generateCode } = parse.json(content);
const { data, generateCode } = parse.yaml(content);
const { data, generateCode } = parse.toml(content);
const { ast, generateCode } = parse.html(content);
```

## Language tooling

Namespaced helpers for AST manipulation:

- **`js.*`** - imports, exports, objects, arrays, variables, functions, vite config helpers, SvelteKit helpers
- **`css.*`** - rules, declarations, at-rules, imports
- **`svelte.*`** - ensureScript, addSlot, addFragment
- **`json.*`** - arrayUpsert, packageScriptsUpsert
- **`html.*`** - attribute manipulation
- **`text.*`** - upsert lines in flat files (.env, .gitignore)
Loading
Loading