Skip to content

Commit 504bce9

Browse files
add transforms API and migrate all addons (#1001)
Co-authored-by: Scott Wu <sw@scottwu.ca>
1 parent 338314d commit 504bce9

24 files changed

Lines changed: 2102 additions & 1424 deletions

File tree

.changeset/social-boxes-add.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/sv-utils': patch
3+
---
4+
5+
feat: add `transform` api to simplify add-on creation

documentation/docs/40-api/10-add-on.md

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ The easiest way to create an add-on is using the addon template:
1313

1414
```sh
1515
npx sv create --template addon my-addon
16-
cd my-addon
1716
```
1817

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

2524
```js
26-
import { parse, svelte } from '@sveltejs/sv-utils';
25+
// @noErrors
26+
import { transforms } from '@sveltejs/sv-utils';
2727
import { defineAddon, defineAddonOptions } from 'sv';
2828

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

4143
options,
4244

@@ -46,21 +48,20 @@ export default defineAddon({
4648
},
4749

4850
// actual execution of the addon
49-
run: ({ kit, cancel, sv, options }) => {
50-
if (!kit) return cancel('SvelteKit is required');
51+
run: ({ isKit, cancel, sv, options, directory }) => {
52+
if (!isKit) return cancel('SvelteKit is required');
5153

5254
// Add "Hello [who]!" to the root page
53-
sv.file(kit.routesDirectory + '/+page.svelte', (content) => {
54-
const { ast, generateCode } = parse.svelte(content);
55-
55+
sv.file(directory.routes + '/+page.svelte', transforms.svelte(({ ast, svelte }) => {
5656
svelte.addFragment(ast, `<p>Hello ${options.who}!</p>`);
57-
58-
return generateCode();
59-
});
57+
}));
6058
}
6159
});
6260
```
6361

62+
> `sv` owns the file system - `sv.file()` resolves the path, reads the file, applies the edit function, and writes the result.
63+
> `@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.
64+
6465
## Development with `file:` protocol
6566

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

7980
```js
80-
import { test, expect } from 'vitest';
8181
import { setupTest } from 'sv/testing';
82+
import { test, expect } from 'vitest';
8283
import addon from './index.js';
8384

8485
test('adds hello message', async () => {
@@ -94,27 +95,44 @@ test('adds hello message', async () => {
9495
});
9596
```
9697

97-
## Publishing to npm
98+
## Building and publishing
99+
100+
### Bundling
101+
102+
Community add-ons are bundled with [tsdown](https://tsdown.dev/) into a single file. Everything is bundled except `sv` (peer dependency, provided at runtime).
103+
104+
```sh
105+
npm run build
106+
```
98107

99108
### Package structure
100109

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

103112
```json
104113
{
105114
"name": "@your-org/sv",
106115
"version": "1.0.0",
107116
"type": "module",
108117
"exports": {
109-
".": "./dist/index.js"
118+
".": "./src/index.js"
119+
},
120+
"publishConfig": {
121+
"access": "public",
122+
"exports": {
123+
".": { "default": "./dist/index.js" }
124+
}
110125
},
111-
"dependencies": {
112-
"sv": "^0.11.0"
126+
"peerDependencies": {
127+
"sv": "^0.13.0"
113128
},
114129
"keywords": ["sv-add"]
115130
}
116131
```
117132

133+
- `exports` points to `./src/index.js` for local development with the `file:` protocol.
134+
- `publishConfig.exports` overrides exports when publishing, pointing to the bundled `./dist/index.js`.
135+
118136
> [!NOTE]
119137
> Add the `sv-add` keyword so users can discover your add-on on npm.
120138
@@ -127,7 +145,7 @@ Your package can export the add-on in two ways:
127145
```json
128146
{
129147
"exports": {
130-
".": "./dist/index.js"
148+
".": "./src/index.js"
131149
}
132150
}
133151
```
@@ -136,17 +154,38 @@ Your package can export the add-on in two ways:
136154
```json
137155
{
138156
"exports": {
139-
".": "./dist/main.js",
140-
"./sv": "./dist/addon.js"
157+
".": "./src/main.js",
158+
"./sv": "./src/addon.js"
141159
}
142160
}
143161
```
144162

145-
### Naming conventions
163+
### Publishing
164+
165+
Community add-ons must be scoped packages (e.g. `@your-org/sv`). Users install with `npx sv add @your-org`.
166+
167+
```sh
168+
npm login
169+
npm publish
170+
```
171+
172+
> `prepublishOnly` automatically runs the build before publishing.
146173
147-
- **Scoped packages**: Use `@your-org/sv` as the package name. Users can then install with just `npx sv add @your-org`.
148-
- **Regular packages**: Any name works. Users install with `npx sv add your-package-name`.
174+
## Next steps
175+
176+
You can optionally display guidance after your add-on runs:
177+
178+
```js
179+
// @noErrors
180+
export default defineAddon({
181+
// ...
182+
nextSteps: ({ options }) => [
183+
`Run ${color.command('npm run dev')} to start developing`,
184+
`Check out the docs at https://...`
185+
]
186+
});
187+
```
149188

150189
## Version compatibility
151190

152-
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.
191+
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.

documentation/docs/40-api/20-sv-utils.md

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,195 @@ title: sv-utils
88
`@sveltejs/sv-utils` provides utilities for parsing, transforming, and generating code in add-ons.
99

1010
```sh
11-
npm install @sveltejs/sv-utils
11+
npm install -D @sveltejs/sv-utils
1212
```
13+
14+
## Architecture
15+
16+
The Svelte CLI is split into two packages with a clear boundary:
17+
18+
- **`sv`** = **where and when** to do it. It owns paths, workspace detection, dependency tracking, and file I/O. The engine orchestrates add-on execution.
19+
- **`@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.
20+
21+
This separation means transforms are testable without a workspace and composable across add-ons.
22+
23+
## Transforms
24+
25+
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.
26+
27+
Each transform injects relevant utilities into the callback, so you only need one import:
28+
29+
```js
30+
import { transforms } from '@sveltejs/sv-utils';
31+
```
32+
33+
### `transforms.script`
34+
35+
Transform a JavaScript/TypeScript file. The callback receives `{ ast, comments, content, js }`.
36+
37+
```js
38+
// @noErrors
39+
import { transforms } from '@sveltejs/sv-utils';
40+
41+
sv.file(files.viteConfig, transforms.script(({ ast, js }) => {
42+
js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
43+
js.vite.addPlugin(ast, { code: 'foo()' });
44+
}));
45+
```
46+
47+
### `transforms.svelte`
48+
49+
Transform a Svelte component. The callback receives `{ ast, content, svelte, js }`.
50+
51+
```js
52+
// @noErrors
53+
import { transforms } from '@sveltejs/sv-utils';
54+
55+
sv.file(layoutPath, transforms.svelte(({ ast, svelte }) => {
56+
svelte.addFragment(ast, '<Foo />');
57+
}));
58+
```
59+
60+
### `transforms.svelteScript`
61+
62+
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.
63+
64+
```js
65+
// @noErrors
66+
import { transforms } from '@sveltejs/sv-utils';
67+
68+
sv.file(layoutPath, transforms.svelteScript({ language }, ({ ast, svelte, js }) => {
69+
js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' });
70+
svelte.addFragment(ast, '<Foo />');
71+
}));
72+
```
73+
74+
### `transforms.css`
75+
76+
Transform a CSS file. The callback receives `{ ast, content, css }`.
77+
78+
```js
79+
// @noErrors
80+
import { transforms } from '@sveltejs/sv-utils';
81+
82+
sv.file(files.stylesheet, transforms.css(({ ast, css }) => {
83+
css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" });
84+
}));
85+
```
86+
87+
### `transforms.json`
88+
89+
Transform a JSON file. Mutate the `data` object directly. The callback receives `{ data, content, json }`.
90+
91+
```js
92+
// @noErrors
93+
import { transforms } from '@sveltejs/sv-utils';
94+
95+
sv.file(files.tsconfig, transforms.json(({ data }) => {
96+
data.compilerOptions ??= {};
97+
data.compilerOptions.strict = true;
98+
}));
99+
```
100+
101+
### `transforms.yaml` / `transforms.toml`
102+
103+
Same pattern as `transforms.json`, for YAML and TOML files respectively. The callback receives `{ data, content }`.
104+
105+
### `transforms.text`
106+
107+
Transform a plain text file (.env, .gitignore, etc.). No parser - string in, string out. The callback receives `{ content, text }`.
108+
109+
```js
110+
// @noErrors
111+
import { transforms } from '@sveltejs/sv-utils';
112+
113+
sv.file('.env', transforms.text(({ content }) => {
114+
return content + '\nDATABASE_URL="file:local.db"';
115+
}));
116+
```
117+
118+
### Aborting a transform
119+
120+
Return `false` from any transform callback to abort - the original content is returned unchanged.
121+
122+
```js
123+
// @noErrors
124+
import { transforms } from '@sveltejs/sv-utils';
125+
126+
sv.file(files.eslintConfig, transforms.script(({ ast, js }) => {
127+
const { value: existing } = js.exports.createDefault(ast, { fallback: myConfig });
128+
if (existing !== myConfig) {
129+
// config already exists, don't touch it
130+
return false;
131+
}
132+
// ... continue modifying ast
133+
}));
134+
```
135+
136+
### Standalone usage & testing
137+
138+
Transforms are curried functions - call them with the callback, then apply to content:
139+
140+
```js
141+
import { transforms } from '@sveltejs/sv-utils';
142+
143+
const result = transforms.script(({ ast, js }) => {
144+
js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
145+
})('export default {}');
146+
```
147+
148+
### Composability
149+
150+
For cases where you need to mix transforms and raw edits, use `sv.file` with a content callback and invoke the curried transform manually:
151+
152+
```js
153+
// @noErrors
154+
sv.file(path, (content) => {
155+
content = transforms.script(({ ast, js }) => {
156+
js.imports.addDefault(ast, { as: 'foo', from: 'foo' });
157+
})(content);
158+
content = content.replace('foo', 'bar');
159+
return content;
160+
});
161+
```
162+
163+
Add-ons can also export reusable transform functions:
164+
165+
```js
166+
// @errors: 7006
167+
import { transforms } from '@sveltejs/sv-utils';
168+
169+
// reusable - export from your package
170+
export const addFooImport = transforms.svelte(({ ast, svelte, js }) => {
171+
svelte.ensureScript(ast, { language });
172+
js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' });
173+
});
174+
```
175+
176+
## Parsers (low-level)
177+
178+
For cases where transforms don't fit (e.g., conditional parsing, error handling around the parser), the `parse` namespace is still available:
179+
180+
```js
181+
// @noErrors
182+
import { parse } from '@sveltejs/sv-utils';
183+
184+
const { ast, generateCode } = parse.script(content);
185+
const { ast, generateCode } = parse.svelte(content);
186+
const { ast, generateCode } = parse.css(content);
187+
const { data, generateCode } = parse.json(content);
188+
const { data, generateCode } = parse.yaml(content);
189+
const { data, generateCode } = parse.toml(content);
190+
const { ast, generateCode } = parse.html(content);
191+
```
192+
193+
## Language tooling
194+
195+
Namespaced helpers for AST manipulation:
196+
197+
- **`js.*`** - imports, exports, objects, arrays, variables, functions, vite config helpers, SvelteKit helpers
198+
- **`css.*`** - rules, declarations, at-rules, imports
199+
- **`svelte.*`** - ensureScript, addSlot, addFragment
200+
- **`json.*`** - arrayUpsert, packageScriptsUpsert
201+
- **`html.*`** - attribute manipulation
202+
- **`text.*`** - upsert lines in flat files (.env, .gitignore)

0 commit comments

Comments
 (0)