diff --git a/.changeset/social-boxes-add.md b/.changeset/social-boxes-add.md new file mode 100644 index 000000000..b9acfd41e --- /dev/null +++ b/.changeset/social-boxes-add.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/sv-utils': patch +--- + +feat: add `transform` api to simplify add-on creation diff --git a/documentation/docs/40-api/10-add-on.md b/documentation/docs/40-api/10-add-on.md index 9403a6156..d37b245e0 100644 --- a/documentation/docs/40-api/10-add-on.md +++ b/documentation/docs/40-api/10-add-on.md @@ -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 @@ -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) @@ -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, @@ -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, `
Hello ${options.who}!
`); - - 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: @@ -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 () => { @@ -94,11 +95,19 @@ 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 { @@ -106,15 +115,24 @@ Your add-on must have `sv` as a dependency in `package.json`: "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. @@ -127,7 +145,7 @@ Your package can export the add-on in two ways: ```json { "exports": { - ".": "./dist/index.js" + ".": "./src/index.js" } } ``` @@ -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. diff --git a/documentation/docs/40-api/20-sv-utils.md b/documentation/docs/40-api/20-sv-utils.md index 90f411b1e..25c9ea98b 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -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, '{greet(guest)}
- `; - }); +{greet(guest)}
+ `; + }) + ); - sv.file(`${examplesDir}/Welcome.svelte.spec.${language}`, (content) => { - if (content) return content; + sv.file( + `${examplesDir}/Welcome.svelte.spec.${language}`, + transforms.text(({ content }) => { + if (content) return false; - return dedent` - import { page } from 'vitest/browser'; - import { describe, expect, it } from 'vitest'; - import { render } from 'vitest-browser-svelte'; - import Welcome from './Welcome.svelte'; + return dedent` + import { page } from 'vitest/browser'; + import { describe, expect, it } from 'vitest'; + import { render } from 'vitest-browser-svelte'; + import Welcome from './Welcome.svelte'; - describe('Welcome.svelte', () => { - it('renders greetings for host and guest', async () => { - render(Welcome, { host: 'SvelteKit', guest: 'Vitest' }); + describe('Welcome.svelte', () => { + it('renders greetings for host and guest', async () => { + render(Welcome, { host: 'SvelteKit', guest: 'Vitest' }); - await expect.element(page.getByRole('heading', { level: 1 })).toHaveTextContent('Hello, SvelteKit!'); - await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument(); + await expect.element(page.getByRole('heading', { level: 1 })).toHaveTextContent('Hello, SvelteKit!'); + await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument(); + }); }); - }); - `; - }); + `; + }) + ); } - sv.file(file.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - - const clientObjectExpression = js.object.create({ - extends: `./${file.viteConfig}`, - test: { - name: 'client', - browser: { - enabled: true, - provider: js.functions.createCall({ name: 'playwright', args: [] }), - instances: [{ browser: 'chromium', headless: true }] - }, - include: ['src/**/*.svelte.{test,spec}.{js,ts}'], - exclude: ['src/lib/server/**'] - } - }); - - const serverObjectExpression = js.object.create({ - extends: `./${file.viteConfig}`, - test: { - name: 'server', - environment: 'node', - include: ['src/**/*.{test,spec}.{js,ts}'], - exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] - } - }); - - const viteConfig = js.vite.getConfig(ast); - - const testObject = js.object.property(viteConfig, { - name: 'test', - fallback: js.object.create({ - expect: { - requireAssertions: true + sv.file( + file.viteConfig, + transforms.script(({ ast, js }) => { + const clientObjectExpression = js.object.create({ + extends: `./${file.viteConfig}`, + test: { + name: 'client', + browser: { + enabled: true, + provider: js.functions.createCall({ name: 'playwright', args: [] }), + instances: [{ browser: 'chromium', headless: true }] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'] } - }) - }); - - const workspaceArray = js.object.property(testObject, { - name: 'projects', - fallback: js.array.create() - }); - - if (componentTesting) js.array.append(workspaceArray, clientObjectExpression); - if (unitTesting) js.array.append(workspaceArray, serverObjectExpression); - - // Manage imports - if (componentTesting) - js.imports.addNamed(ast, { imports: ['playwright'], from: '@vitest/browser-playwright' }); - const importName = 'defineConfig'; - const { statement, alias } = js.imports.find(ast, { name: importName, from: 'vite' }); - if (statement) { - // Switch the import from 'vite' to 'vitest/config' (keeping the alias) - js.imports.addNamed(ast, { imports: { defineConfig: alias }, from: 'vitest/config' }); - - // Remove the old import - js.imports.remove(ast, { name: importName, from: 'vite', statement }); - } - - return generateCode(); - }); + }); + + const serverObjectExpression = js.object.create({ + extends: `./${file.viteConfig}`, + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + }); + + const viteConfig = js.vite.getConfig(ast); + + const testObject = js.object.property(viteConfig, { + name: 'test', + fallback: js.object.create({ + expect: { + requireAssertions: true + } + }) + }); + + const workspaceArray = js.object.property(testObject, { + name: 'projects', + fallback: js.array.create() + }); + + if (componentTesting) js.array.append(workspaceArray, clientObjectExpression); + if (unitTesting) js.array.append(workspaceArray, serverObjectExpression); + + // Manage imports + if (componentTesting) + js.imports.addNamed(ast, { imports: ['playwright'], from: '@vitest/browser-playwright' }); + const importName = 'defineConfig'; + const { statement, alias } = js.imports.find(ast, { name: importName, from: 'vite' }); + if (statement) { + // Switch the import from 'vite' to 'vitest/config' (keeping the alias) + js.imports.addNamed(ast, { imports: { defineConfig: alias }, from: 'vitest/config' }); + + // Remove the old import + js.imports.remove(ast, { name: importName, from: 'vite', statement }); + } + }) + ); }, nextSteps: ({ language, options }) => { diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js b/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js index d1755e9e6..abc552237 100644 --- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js +++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js @@ -1,4 +1,4 @@ -import { js, parse, svelte } from '@sveltejs/sv-utils'; +import { transforms } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from 'sv'; const options = defineAddonOptions() @@ -18,34 +18,36 @@ export default defineAddon({ }, run: ({ directory, sv, options, language }) => { - sv.file(`${directory.lib}/@my-org/sv/content.txt`, () => { - return `This is a text file made by the Community Addon Template demo for the add-on: '@my-org/sv'!`; - }); - - sv.file(`${directory.lib}/@my-org/sv/HelloComponent.svelte`, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); - - js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' }); - - svelte.addFragment(ast, '{content}
'); - svelte.addFragment(ast, `{content}
'); + svelte.addFragment(ast, `{content}
'); - svelte.addFragment(ast, `{content}
'); + svelte.addFragment(ast, `