diff --git a/documentation/docs/20-commands/20-sv-add.md b/documentation/docs/20-commands/20-sv-add.md index 27a8b4b10..b6d3b4705 100644 --- a/documentation/docs/20-commands/20-sv-add.md +++ b/documentation/docs/20-commands/20-sv-add.md @@ -71,7 +71,7 @@ Prevents installing dependencies > [!NOTE] > Svelte maintainers have not reviewed community add-ons for malicious code! -You can find [community add-ons on npm](https://www.npmjs.com/search?q=keywords%3Asv-add) by searching for `keywords:sv-add`. +You can find [community add-ons](https://npmx.dev/search?q=keyword:sv-add) by searching for the keyword `sv-add`. ### How to install a community add-on @@ -99,9 +99,6 @@ npx sv create --add eslint "@supacool" # Scoped package: @org (preferred), we will look for @org/sv npx sv add "@supacool" -# Regular npm package (with or without scope) -npx sv add my-cool-addon - # Local add-on npx sv add file:../path/to/my-addon ``` diff --git a/documentation/docs/40-api/10-add-on.md b/documentation/docs/40-api/10-add-on.md index 9403a6156..4e4e0010a 100644 --- a/documentation/docs/40-api/10-add-on.md +++ b/documentation/docs/40-api/10-add-on.md @@ -23,7 +23,7 @@ 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'; +import { transforms, svelte } 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,23 @@ 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); - - svelte.addFragment(ast, `

Hello ${options.who}!

`); - - return generateCode(); - }); + sv.file( + directory.routes + '/+page.svelte', + transforms.svelte((ast) => { + svelte.addFragment(ast, `

Hello ${options.who}!

`); + }) + ); } }); ``` +> `sv` owns the file system — `sv.file()` resolves the path, reads the file, applies the transform, and writes the result. +> `@sveltejs/sv-utils` owns the content — `transforms.svelte()` handles parsing, gives you the AST, 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 +81,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 +98,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 +118,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 +148,7 @@ Your package can export the add-on in two ways: ```json { "exports": { - ".": "./dist/index.js" + ".": "./src/index.js" } } ``` @@ -136,17 +157,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 +// @errors: 2304 7031 +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..94e4a7db4 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -8,5 +8,164 @@ 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 typed, parser-aware functions that turn `string -> string`. 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. + +```js +import { transforms, js, svelte, css, json } from '@sveltejs/sv-utils'; +``` + +### `transforms.script` + +Transform a JavaScript/TypeScript file. The callback receives the AST, comments, and a context with `language`. + +```js +import { transforms, js } from '@sveltejs/sv-utils'; + +const addVitePlugin = transforms.script((ast, comments, { language }) => { + js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); + js.vite.addPlugin(ast, { code: 'foo()' }); +}); +``` + +### `transforms.svelte` + +Transform a Svelte component. The engine injects `language` automatically via the context. + +```js +import { transforms, js, svelte } from '@sveltejs/sv-utils'; + +const addFooComponent = transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); + js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' }); + svelte.addFragment(ast, ''); +}); +``` + +### `transforms.css` + +Transform a CSS file. The callback receives the AST and a context with `language`. + +```js +import { transforms, css } from '@sveltejs/sv-utils'; + +const addTailwind = transforms.css((ast, { language }) => { + css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" }); +}); +``` + +### `transforms.json` + +Transform a JSON file. Mutate the `data` object directly. The callback also receives a context with `language`. + +```js +import { transforms } from '@sveltejs/sv-utils'; + +const enableStrict = transforms.json((data, { language }) => { + data.compilerOptions ??= {}; + data.compilerOptions.strict = true; +}); +``` + +### `transforms.yaml` / `transforms.toml` + +Same pattern as `transforms.json`, for YAML and TOML files respectively. All callbacks receive a context with `language`. + +### `transforms.text` + +Transform a plain text file (.env, .gitignore, etc.). No parser — string in, string out. The callback also receives a context with `language`. + +```js +import { transforms } from '@sveltejs/sv-utils'; + +const addDbUrl = transforms.text((content, { language }) => { + 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 +import { transforms, js } from '@sveltejs/sv-utils'; + +const myConfig = '{}'; +const setupEslint = transforms.script((ast) => { + 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 just functions — they work without the `sv` engine. Pass content directly, with an optional context: + +```js +import { transforms, js } from '@sveltejs/sv-utils'; + +const addPlugin = transforms.script((ast) => { + js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); +}); + +// use standalone — pass content and context directly +const result = addPlugin('export default {}', { language: 'ts' }); +``` + +### Composability + +Add-ons can export reusable transforms that other add-ons consume: + +```js +import { transforms, js, svelte } from '@sveltejs/sv-utils'; + +// reusable transform — export from your package +export const addFooImport = transforms.svelte((ast, { language }) => { + 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 +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) diff --git a/packages/sv-utils/package.json b/packages/sv-utils/package.json index 83a31a026..4eafe637f 100644 --- a/packages/sv-utils/package.json +++ b/packages/sv-utils/package.json @@ -38,7 +38,6 @@ }, "keywords": [ "sv", - "sv-add", "svelte", "sveltekit" ] diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index 13e736a88..0e3de0512 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -28,21 +28,31 @@ export * as text from './tooling/text.ts'; export * as json from './tooling/json.ts'; export * as svelte from './tooling/svelte/index.ts'; +// Transforms — sv-utils = what to do to content, sv = where and when to do it. +export { + transforms, + isTransform, + type TransformFn, + type TransformContext +} from './tooling/transforms.ts'; + /** - * Will help you `parse` code into an `ast` from all supported languages. - * Then manipulate the `ast` as you want, - * and finally `generateCode()` to write it back to the file. + * Low-level parsers. Prefer `transforms` for add-on file edits — it picks the + * right parser for you and handles `generateCode()` automatically. + * + * Use `parse` directly when you need error handling around parsing or + * conditional parser selection at runtime. * * ```ts * import { parse } from '@sveltejs/sv-utils'; * - * const { ast, generateCode } = parse.css('body { color: red; }'); - * const { ast, generateCode } = parse.html('
Hello, world!
'); - * const { ast, generateCode } = parse.json('{ "name": "John", "age": 30 }'); * const { ast, generateCode } = parse.script('function add(a, b) { return a + b; }'); * const { ast, generateCode } = parse.svelte('
Hello, world!
'); - * const { ast, generateCode } = parse.toml('name = "John"'); - * const { ast, generateCode } = parse.yaml('name: John'); + * const { ast, generateCode } = parse.css('body { color: red; }'); + * const { data, generateCode } = parse.json('{ "name": "John", "age": 30 }'); + * const { data, generateCode } = parse.yaml('name: John'); + * const { data, generateCode } = parse.toml('name = "John"'); + * const { ast, generateCode } = parse.html('
Hello, world!
'); * ``` */ export const parse = { diff --git a/packages/sv-utils/src/tooling/transforms.ts b/packages/sv-utils/src/tooling/transforms.ts new file mode 100644 index 000000000..ca56c1291 --- /dev/null +++ b/packages/sv-utils/src/tooling/transforms.ts @@ -0,0 +1,174 @@ +import type { TomlTable } from 'smol-toml'; +import type { Comments, SvelteAst } from './index.ts'; +import type { TsEstree } from './js/ts-estree.ts'; +import { parseCss, parseJson, parseScript, parseSvelte, parseToml, parseYaml } from './parsers.ts'; + +/** + * Context injected by the `sv` engine when running a transform via `sv.file()`. + * Can also be passed manually for standalone usage or testing. + */ +export type TransformContext = { + language: 'ts' | 'js'; +}; + +const TRANSFORM_KEY = '__transform' as const; + +export type TransformType = 'script' | 'css' | 'svelte' | 'json' | 'yaml' | 'toml' | 'text'; + +export type TransformFn = { + (content: string, ctx?: TransformContext): string; + [TRANSFORM_KEY]: TransformType; +}; + +export function isTransform( + fn: (content: string, ctx?: TransformContext) => string +): fn is TransformFn { + return TRANSFORM_KEY in fn; +} + +/** + * 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) -> 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. + * + * @example + * ```ts + * import { transforms } from '@sveltejs/sv-utils'; + * + * // returns a transform function (content: string) => string + * const addPlugin = transforms.script((ast) => { + * js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); + * }); + * + * // use with sv.file() — the engine injects context automatically + * sv.file(files.viteConfig, transforms.script((ast) => { + * js.vite.addPlugin(ast, { code: 'kitRoutes()' }); + * })); + * + * // standalone usage / testing — pass context manually + * const result = addPlugin(fileContent, { language: 'ts' }); + * ``` + */ +export const transforms = { + /** + * Transform a JavaScript/TypeScript file. + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + script( + cb: (ast: TsEstree.Program, comments: Comments, ctx: TransformContext) => void | false + ): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { ast, comments, generateCode } = parseScript(content); + const result = cb(ast, comments, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'script'; + return fn; + }, + + /** + * Transform a Svelte component file. + * Receives `language` from the engine context automatically. + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + svelte(cb: (ast: SvelteAst.Root, ctx: TransformContext) => void | false): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { ast, generateCode } = parseSvelte(content); + const result = cb(ast, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'svelte'; + return fn; + }, + + /** + * Transform a CSS file. + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + css( + cb: ( + ast: Omit, + ctx: TransformContext + ) => void | false + ): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { ast, generateCode } = parseCss(content); + const result = cb(ast, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'css'; + return fn; + }, + + /** + * Transform a JSON file. + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + json(cb: (data: T, ctx: TransformContext) => void | false): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { data, generateCode } = parseJson(content); + const result = cb(data as T, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'json'; + return fn; + }, + + /** + * Transform a YAML file. + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + yaml( + cb: (data: ReturnType['data'], ctx: TransformContext) => void | false + ): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { data, generateCode } = parseYaml(content); + const result = cb(data, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'yaml'; + return fn; + }, + + /** + * Transform a TOML file. + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + toml(cb: (data: TomlTable, ctx: TransformContext) => void | false): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { data, generateCode } = parseToml(content); + const result = cb(data, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'toml'; + return fn; + }, + + /** + * Transform a plain text file (.env, .gitignore, etc.). + * No parsing — just string in, string out. + */ + text(cb: (content: string, ctx: TransformContext) => string): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + return cb(content, ctx ?? { language: 'ts' }); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'text'; + return fn; + } +}; diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index f38dc58f3..e06828184 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -7,7 +7,7 @@ import { text, js, json, - parse, + transforms, resolveCommand, createPrinter } from '@sveltejs/sv-utils'; @@ -42,7 +42,7 @@ export default defineAddon({ runsAfter('sveltekitAdapter'); runsAfter('tailwindcss'); }, - run: ({ sv, language, options, kit, dependencyVersion, files }) => { + run: ({ sv, language, options, kit, dependencyVersion, file }) => { if (!kit) throw new Error('SvelteKit is required'); const demoPassword = options.demo.includes('password'); @@ -55,74 +55,75 @@ export default defineAddon({ sv.devDependency('better-auth', '~1.4.21'); sv.devDependency('@better-auth/cli', '~1.4.21'); - sv.file(`drizzle.config.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); - const isProp = (name: string, node: AstTypes.Property) => - node.key.type === 'Identifier' && node.key.name === name; - - // tsgo can't infer visitor node types from zimmerframe's distributive conditional - Walker.walk(ast as AstTypes.Node, null, { - Property(node: AstTypes.Property) { - if ( - isProp('dialect', node) && - node.value.type === 'Literal' && - typeof node.value.value === 'string' - ) { - drizzleDialect = node.value.value as Dialect; - } - if ( - isProp('driver', node) && - node.value.type === 'Literal' && - node.value.value === 'd1-http' - ) { - d1 = true; + sv.file( + `drizzle.config.${language}`, + transforms.script((ast) => { + const isProp = (name: string, node: AstTypes.Property) => + node.key.type === 'Identifier' && node.key.name === name; + + // tsgo can't infer visitor node types from zimmerframe's distributive conditional + Walker.walk(ast as AstTypes.Node, null, { + Property(node: AstTypes.Property) { + if ( + isProp('dialect', node) && + node.value.type === 'Literal' && + typeof node.value.value === 'string' + ) { + drizzleDialect = node.value.value as Dialect; + } + if ( + isProp('driver', node) && + node.value.type === 'Literal' && + node.value.value === 'd1-http' + ) { + d1 = true; + } } - } - }); + }); - if (!drizzleDialect) { - throw new Error('Failed to detect DB dialect in your `drizzle.config.[js|ts]` file'); - } - return generateCode(); - }); + if (!drizzleDialect) { + throw new Error('Failed to detect DB dialect in your `drizzle.config.[js|ts]` file'); + } + }) + ); sv.file('.env', (content) => generateEnvFileContent(content, demoGithub, false)); sv.file('.env.example', (content) => generateEnvFileContent(content, demoGithub, true)); - sv.file(`${kit?.libDirectory}/server/auth.${language}`, (content) => { - const { ast, generateCode, comments } = parse.script(content); + sv.file( + `${kit?.libDirectory}/server/auth.${language}`, + transforms.script((ast, comments) => { + js.imports.addNamed(ast, { from: '$lib/server/db', imports: [d1 ? 'getDb' : 'db'] }); + js.imports.addNamed(ast, { from: '$app/server', imports: ['getRequestEvent'] }); + js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); + js.imports.addNamed(ast, { from: 'better-auth/svelte-kit', imports: ['sveltekitCookies'] }); + js.imports.addNamed(ast, { + from: 'better-auth/adapters/drizzle', + imports: ['drizzleAdapter'] + }); + js.imports.addNamed(ast, { from: 'better-auth/minimal', imports: ['betterAuth'] }); + + const dialectMap: Record = { + mysql: 'mysql', + postgresql: 'pg', + sqlite: 'sqlite', + turso: 'sqlite' + }; + const provider = dialectMap[drizzleDialect]; - js.imports.addNamed(ast, { from: '$lib/server/db', imports: [d1 ? 'getDb' : 'db'] }); - js.imports.addNamed(ast, { from: '$app/server', imports: ['getRequestEvent'] }); - js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); - js.imports.addNamed(ast, { from: 'better-auth/svelte-kit', imports: ['sveltekitCookies'] }); - js.imports.addNamed(ast, { - from: 'better-auth/adapters/drizzle', - imports: ['drizzleAdapter'] - }); - js.imports.addNamed(ast, { from: 'better-auth/minimal', imports: ['betterAuth'] }); - - const dialectMap: Record = { - mysql: 'mysql', - postgresql: 'pg', - sqlite: 'sqlite', - turso: 'sqlite' - }; - const provider = dialectMap[drizzleDialect]; - - const githubProvider = demoGithub - ? ` + const githubProvider = demoGithub + ? ` socialProviders: { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET, }, },` - : ''; + : ''; - let authConfig = ''; - if (d1) { - authConfig = dedent` + let authConfig = ''; + if (d1) { + authConfig = dedent` const authConfig = { baseURL: env.ORIGIN, secret: env.BETTER_AUTH_SECRET, @@ -146,8 +147,8 @@ export default defineAddon({ * To access \`auth\` at runtime, use \`event.locals.auth\`. */ export const auth = createAuth(${language === 'ts' ? 'null!' : 'null'});`; - } else { - authConfig = dedent` + } else { + authConfig = dedent` export const auth = betterAuth({ baseURL: env.ORIGIN, secret: env.BETTER_AUTH_SECRET, @@ -159,24 +160,24 @@ export default defineAddon({ sveltekitCookies(getRequestEvent) // make sure this is the last plugin in the array ], });`; - } - js.common.appendFromString(ast, { code: authConfig, comments }); - - return generateCode(); - }); + } + js.common.appendFromString(ast, { code: authConfig, comments }); + }) + ); const authConfigPath = `${kit?.libDirectory}/server/auth.${language}`; const authSchemaPath = `${kit?.libDirectory}/server/db/auth.schema.${language}`; - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - json.packageScriptsUpsert( - data, - 'auth:schema', - `better-auth generate --config ${authConfigPath} --output ${authSchemaPath} --yes` - ); - return generateCode(); - }); + sv.file( + file.package, + transforms.json((data) => { + json.packageScriptsUpsert( + data, + 'auth:schema', + `better-auth generate --config ${authConfigPath} --output ${authSchemaPath} --yes` + ); + }) + ); sv.file(`${kit?.libDirectory}/server/db/auth.schema.${language}`, (content) => { if (content) return content; @@ -185,71 +186,73 @@ export default defineAddon({ `; }); - sv.file(`${kit?.libDirectory}/server/db/schema.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); - - js.exports.addNamespace(ast, { from: './auth.schema' }); - - return generateCode(); - }); - - sv.file('src/app.d.ts', (content) => { - const { ast, comments, generateCode } = parse.script(content); - - if (d1) js.imports.addNamed(ast, { imports: ['createAuth'], from: '$lib/server/auth' }); - js.imports.addNamed(ast, { - imports: ['User', 'Session'], - from: 'better-auth/minimal', - isType: true - }); - - const locals = js.kit.addGlobalAppInterface(ast, { name: 'Locals' }); - if (!locals) { - throw new Error('Failed detecting `locals` interface in `src/app.d.ts`'); - } + sv.file( + `${kit?.libDirectory}/server/db/schema.${language}`, + transforms.script((ast) => { + js.exports.addNamespace(ast, { from: './auth.schema' }); + }) + ); + + sv.file( + 'src/app.d.ts', + transforms.script((ast, comments) => { + if (d1) js.imports.addNamed(ast, { imports: ['createAuth'], from: '$lib/server/auth' }); + js.imports.addNamed(ast, { + imports: ['User', 'Session'], + from: 'better-auth/minimal', + isType: true + }); + + const locals = js.kit.addGlobalAppInterface(ast, { name: 'Locals' }); + if (!locals) { + throw new Error('Failed detecting `locals` interface in `src/app.d.ts`'); + } - // remove the commented out placeholder since we're adding the real one - comments.remove((c) => c.type === 'Line' && c.value.trim() === 'interface Locals {}'); + // remove the commented out placeholder since we're adding the real one + comments.remove((c) => c.type === 'Line' && c.value.trim() === 'interface Locals {}'); - const user = locals.body.body.find((prop) => - js.common.hasTypeProperty(prop, { name: 'user' }) - ); - const session = locals.body.body.find((prop) => - js.common.hasTypeProperty(prop, { name: 'session' }) - ); - const auth = locals.body.body.find((prop) => - js.common.hasTypeProperty(prop, { name: 'auth' }) - ); - - if (!user) { - locals.body.body.push(js.common.createTypeProperty('user', 'User', true)); - } - if (!session) { - locals.body.body.push(js.common.createTypeProperty('session', 'Session', true)); - } - if (d1 && !auth) { - locals.body.body.push( - js.common.createTypeProperty('auth', 'ReturnType', false) + const user = locals.body.body.find((prop) => + js.common.hasTypeProperty(prop, { name: 'user' }) + ); + const session = locals.body.body.find((prop) => + js.common.hasTypeProperty(prop, { name: 'session' }) + ); + const auth = locals.body.body.find((prop) => + js.common.hasTypeProperty(prop, { name: 'auth' }) ); - } - return generateCode(); - }); - - sv.file(`src/hooks.server.${language}`, (content) => { - const { ast, generateCode, comments } = parse.script(content); - - js.imports.addNamed(ast, { imports: ['svelteKitHandler'], from: 'better-auth/svelte-kit' }); - js.imports.addNamed(ast, { imports: [d1 ? 'createAuth' : 'auth'], from: '$lib/server/auth' }); - js.imports.addNamed(ast, { imports: ['building'], from: '$app/environment' }); - const d1HandleSetup = d1 - ? dedent` + if (!user) { + locals.body.body.push(js.common.createTypeProperty('user', 'User', true)); + } + if (!session) { + locals.body.body.push(js.common.createTypeProperty('session', 'Session', true)); + } + if (d1 && !auth) { + locals.body.body.push( + js.common.createTypeProperty('auth', 'ReturnType', false) + ); + } + }) + ); + + sv.file( + `src/hooks.server.${language}`, + transforms.script((ast, comments) => { + js.imports.addNamed(ast, { imports: ['svelteKitHandler'], from: 'better-auth/svelte-kit' }); + js.imports.addNamed(ast, { + imports: [d1 ? 'createAuth' : 'auth'], + from: '$lib/server/auth' + }); + js.imports.addNamed(ast, { imports: ['building'], from: '$app/environment' }); + + const d1HandleSetup = d1 + ? dedent` if (!event.platform?.env?.DB) throw new Error('D1 binding "DB" not found — are you running with wrangler?'); event.locals.auth = createAuth(event.platform.env.DB); const { auth } = event.locals;\n` - : ''; + : ''; - const handleContent = dedent` + const handleContent = dedent` async ({ event, resolve }) => {${d1HandleSetup} // Fetch current session from Better Auth const session = await auth.api.getSession({ @@ -265,20 +268,17 @@ export default defineAddon({ export const handle = sequence(handleBetterAuth, handleSession); `; - js.kit.addHooksHandle(ast, { - language, - newHandleName: 'handleBetterAuth', - handleContent, - comments - }); - - return generateCode(); - }); + js.kit.addHooksHandle(ast, { + language, + newHandleName: 'handleBetterAuth', + handleContent, + comments + }); + }) + ); if (hasDemo) { - sv.file(`${kit?.routesDirectory}/demo/+page.svelte`, (content) => { - return addToDemoPage(content, 'better-auth', language); - }); + sv.file(`${kit?.routesDirectory}/demo/+page.svelte`, addToDemoPage('better-auth')); sv.file( `${kit!.routesDirectory}/demo/better-auth/login/+page.server.${language}`, diff --git a/packages/sv/src/addons/common.ts b/packages/sv/src/addons/common.ts index 23605fe11..e56d96528 100644 --- a/packages/sv/src/addons/common.ts +++ b/packages/sv/src/addons/common.ts @@ -1,9 +1,7 @@ -import { type SvelteAst, js, parse, svelte } from '@sveltejs/sv-utils'; +import { type SvelteAst, type TransformFn, js, svelte, transforms } from '@sveltejs/sv-utils'; import process from 'node:process'; -export function addEslintConfigPrettier(content: string): string { - const { ast, generateCode } = parse.script(content); - +export const addEslintConfigPrettier = transforms.script((ast) => { // if a default import for `eslint-plugin-svelte` already exists, then we'll use their specifier's name instead const importNodes = ast.body.filter((n) => n.type === 'ImportDeclaration'); const sveltePluginImport = importNodes.find( @@ -28,7 +26,7 @@ export function addEslintConfigPrettier(content: string): string { const defaultExport = js.exports.createDefault(ast, { fallback: fallbackConfig }); const eslintConfig = defaultExport.value; if (eslintConfig.type !== 'ArrayExpression' && eslintConfig.type !== 'CallExpression') - return content; + return false; const prettier = js.common.parseExpression('prettier'); const sveltePrettierConfig = js.common.parseExpression(`${svelteImportName}.configs.prettier`); @@ -57,43 +55,35 @@ export function addEslintConfigPrettier(content: string): string { // append to the end as a fallback elements.push(...nodesToInsert); } - - return generateCode(); -} - -export function addToDemoPage( - existingContent: string, - path: string, - language: 'ts' | 'js' -): string { - const { ast, generateCode } = parse.svelte(existingContent); - - for (const node of ast.fragment.nodes) { - if (node.type === 'RegularElement') { - const hrefAttribute = node.attributes.find( - (x) => x.type === 'Attribute' && x.name === 'href' - ) as SvelteAst.Attribute; - if (!hrefAttribute || !hrefAttribute.value) continue; - - if (!Array.isArray(hrefAttribute.value)) continue; - - const hasDemo = hrefAttribute.value.some( - // we use includes as it could be "/demo/${path}" or "resolve("demo/${path}")" or "resolve('demo/${path}')" - (x) => x.type === 'Text' && x.data.includes(`/demo/${path}`) - ); - if (hasDemo) { - return existingContent; +}); + +export function addToDemoPage(path: string): TransformFn { + return transforms.svelte((ast, { language }) => { + for (const node of ast.fragment.nodes) { + if (node.type === 'RegularElement') { + const hrefAttribute = node.attributes.find( + (x) => x.type === 'Attribute' && x.name === 'href' + ) as SvelteAst.Attribute; + if (!hrefAttribute || !hrefAttribute.value) continue; + + if (!Array.isArray(hrefAttribute.value)) continue; + + const hasDemo = hrefAttribute.value.some( + // we use includes as it could be "/demo/${path}" or "resolve("demo/${path}")" or "resolve('demo/${path}')" + (x) => x.type === 'Text' && x.data.includes(`/demo/${path}`) + ); + if (hasDemo) { + return false; + } } } - } - - svelte.ensureScript(ast, { language }); - js.imports.addNamed(ast.instance.content, { imports: ['resolve'], from: '$app/paths' }); - svelte.addFragment(ast, `${path}`, { mode: 'prepend' }); - ast.fragment.nodes.unshift(); + svelte.ensureScript(ast, { language }); + js.imports.addNamed(ast.instance.content, { imports: ['resolve'], from: '$app/paths' }); - return generateCode(); + svelte.addFragment(ast, `${path}`, { mode: 'prepend' }); + ast.fragment.nodes.unshift(); + }); } /** diff --git a/packages/sv/src/addons/devtools-json.ts b/packages/sv/src/addons/devtools-json.ts index e4d8dc46a..7b82096ca 100644 --- a/packages/sv/src/addons/devtools-json.ts +++ b/packages/sv/src/addons/devtools-json.ts @@ -1,4 +1,4 @@ -import { js, parse } from '@sveltejs/sv-utils'; +import { js, transforms } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; export default defineAddon({ @@ -7,18 +7,17 @@ export default defineAddon({ homepage: 'https://github.com/ChromeDevTools/vite-plugin-devtools-json', options: {}, - run: ({ sv, files }) => { + run: ({ sv, file }) => { sv.devDependency('vite-plugin-devtools-json', '^1.0.0'); // add the vite plugin - sv.file(files.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - - const vitePluginName = 'devtoolsJson'; - js.imports.addDefault(ast, { as: vitePluginName, from: 'vite-plugin-devtools-json' }); - js.vite.addPlugin(ast, { code: `${vitePluginName}()` }); - - return generateCode(); - }); + sv.file( + file.viteConfig, + transforms.script((ast) => { + const vitePluginName = 'devtoolsJson'; + js.imports.addDefault(ast, { as: vitePluginName, from: 'vite-plugin-devtools-json' }); + js.vite.addPlugin(ast, { code: `${vitePluginName}()` }); + }) + ); } }); diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 9a242e1be..ceb8d4801 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -1,4 +1,4 @@ -import { color, dedent, text, js, parse, resolveCommand, json } from '@sveltejs/sv-utils'; +import { color, dedent, text, js, transforms, resolveCommand, json } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; @@ -82,7 +82,7 @@ export default defineAddon({ if (!kit) return unsupported('Requires SvelteKit'); }, - run: ({ sv, language, options, kit, dependencyVersion, cwd, cancel, files }) => { + run: ({ sv, language, options, kit, dependencyVersion, cwd, cancel, file }) => { if (!kit) throw new Error('SvelteKit is required'); if (options.database === 'd1' && !dependencyVersion('@sveltejs/adapter-cloudflare')) { @@ -186,81 +186,83 @@ export default defineAddon({ }); } - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - - if (options.docker) json.packageScriptsUpsert(data, 'db:start', 'docker compose up'); - json.packageScriptsUpsert(data, 'db:push', 'drizzle-kit push'); - json.packageScriptsUpsert(data, 'db:generate', 'drizzle-kit generate'); - json.packageScriptsUpsert(data, 'db:migrate', 'drizzle-kit migrate'); - json.packageScriptsUpsert(data, 'db:studio', 'drizzle-kit studio'); - - return generateCode(); - }); + sv.file( + file.package, + transforms.json((data) => { + if (options.docker) json.packageScriptsUpsert(data, 'db:start', 'docker compose up'); + json.packageScriptsUpsert(data, 'db:push', 'drizzle-kit push'); + json.packageScriptsUpsert(data, 'db:generate', 'drizzle-kit generate'); + json.packageScriptsUpsert(data, 'db:migrate', 'drizzle-kit migrate'); + json.packageScriptsUpsert(data, 'db:studio', 'drizzle-kit studio'); + }) + ); const hasPrettier = Boolean(dependencyVersion('prettier')); if (hasPrettier) { - sv.file(files.prettierignore, (content) => { + sv.file(file.prettierignore, (content) => { return text.upsert(content, '/drizzle/'); }); } if (options.database === 'sqlite') { - sv.file(files.gitignore, (content) => { + sv.file(file.gitignore, (content) => { if (content.length === 0) return content; return text.upsert(content, '*.db', { comment: 'SQLite' }); }); } - sv.file(paths['drizzle config'], (content) => { - const d1 = options.database === 'd1'; - const turso = options.sqlite === 'turso'; - - const { ast, generateCode } = parse.script(content); - - js.imports.addNamed(ast, { from: 'drizzle-kit', imports: { defineConfig: 'defineConfig' } }); - - if (d1) { - ast.body.push( - js.common.parseStatement( - "if (!process.env.CLOUDFLARE_ACCOUNT_ID) throw new Error('CLOUDFLARE_ACCOUNT_ID is not set');" - ), - js.common.parseStatement( - "if (!process.env.CLOUDFLARE_DATABASE_ID) throw new Error('CLOUDFLARE_DATABASE_ID is not set');" - ), - js.common.parseStatement( - "if (!process.env.CLOUDFLARE_D1_TOKEN) throw new Error('CLOUDFLARE_D1_TOKEN is not set');" - ) - ); - } else { - ast.body.push( - js.common.parseStatement( - "if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" - ) - ); - } + sv.file( + paths['drizzle config'], + transforms.script((ast) => { + const d1 = options.database === 'd1'; + const turso = options.sqlite === 'turso'; - const getDialect = (): string => { - if (d1) return 'sqlite'; - if (turso) return 'turso'; - return options.database; - }; + js.imports.addNamed(ast, { + from: 'drizzle-kit', + imports: { defineConfig: 'defineConfig' } + }); - const getCredentials = (): string => { - const creds: string[] = []; if (d1) { - creds.push('accountId: process.env.CLOUDFLARE_ACCOUNT_ID,'); - creds.push('databaseId: process.env.CLOUDFLARE_DATABASE_ID,'); - creds.push('token: process.env.CLOUDFLARE_D1_TOKEN,'); + ast.body.push( + js.common.parseStatement( + "if (!process.env.CLOUDFLARE_ACCOUNT_ID) throw new Error('CLOUDFLARE_ACCOUNT_ID is not set');" + ), + js.common.parseStatement( + "if (!process.env.CLOUDFLARE_DATABASE_ID) throw new Error('CLOUDFLARE_DATABASE_ID is not set');" + ), + js.common.parseStatement( + "if (!process.env.CLOUDFLARE_D1_TOKEN) throw new Error('CLOUDFLARE_D1_TOKEN is not set');" + ) + ); + } else { + ast.body.push( + js.common.parseStatement( + "if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" + ) + ); } - if (turso) creds.push('authToken: process.env.DATABASE_AUTH_TOKEN,'); - if (!d1) creds.push('url: process.env.DATABASE_URL,'); - - return creds.join('\n'); - }; - js.exports.createDefault(ast, { - fallback: js.common.parseExpression(` + const getDialect = (): string => { + if (d1) return 'sqlite'; + if (turso) return 'turso'; + return options.database; + }; + + const getCredentials = (): string => { + const creds: string[] = []; + if (d1) { + creds.push('accountId: process.env.CLOUDFLARE_ACCOUNT_ID,'); + creds.push('databaseId: process.env.CLOUDFLARE_DATABASE_ID,'); + creds.push('token: process.env.CLOUDFLARE_D1_TOKEN,'); + } + if (turso) creds.push('authToken: process.env.DATABASE_AUTH_TOKEN,'); + if (!d1) creds.push('url: process.env.DATABASE_URL,'); + + return creds.join('\n'); + }; + + js.exports.createDefault(ast, { + fallback: js.common.parseExpression(` defineConfig({ schema: "./src/lib/server/db/schema.${language}", dialect: "${getDialect()}", @@ -272,200 +274,197 @@ export default defineAddon({ strict: true }) `) - }); - - return generateCode(); - }); - - sv.file(paths['database schema'], (content) => { - const { ast, generateCode } = parse.script(content); - - let taskSchemaExpression; - if (options.database === 'sqlite' || options.database === 'd1') { - js.imports.addNamed(ast, { - from: 'drizzle-orm/sqlite-core', - imports: ['integer', 'sqliteTable', 'text'] }); + }) + ); - taskSchemaExpression = js.common.parseExpression(`sqliteTable('task', { + sv.file( + paths['database schema'], + transforms.script((ast) => { + let taskSchemaExpression; + if (options.database === 'sqlite' || options.database === 'd1') { + js.imports.addNamed(ast, { + from: 'drizzle-orm/sqlite-core', + imports: ['integer', 'sqliteTable', 'text'] + }); + + taskSchemaExpression = js.common.parseExpression(`sqliteTable('task', { id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), title: text('title').notNull(), priority: integer('priority').notNull().default(1) })`); - } - if (options.database === 'mysql') { - js.imports.addNamed(ast, { - from: 'drizzle-orm/mysql-core', - imports: ['mysqlTable', 'serial', 'int', 'text'] - }); + } + if (options.database === 'mysql') { + js.imports.addNamed(ast, { + from: 'drizzle-orm/mysql-core', + imports: ['mysqlTable', 'serial', 'int', 'text'] + }); - taskSchemaExpression = js.common.parseExpression(`mysqlTable('task', { + taskSchemaExpression = js.common.parseExpression(`mysqlTable('task', { id: serial('id').primaryKey(), title: text('title').notNull(), priority: int('priority').notNull().default(1) })`); - } - if (options.database === 'postgresql') { - js.imports.addNamed(ast, { - from: 'drizzle-orm/pg-core', - imports: ['pgTable', 'serial', 'integer', 'text'] - }); + } + if (options.database === 'postgresql') { + js.imports.addNamed(ast, { + from: 'drizzle-orm/pg-core', + imports: ['pgTable', 'serial', 'integer', 'text'] + }); - taskSchemaExpression = js.common.parseExpression(`pgTable('task', { + taskSchemaExpression = js.common.parseExpression(`pgTable('task', { id: serial('id').primaryKey(), title: text('title').notNull(), priority: integer('priority').notNull().default(1) })`); - } + } - if (!taskSchemaExpression) throw new Error('unreachable state...'); - const taskIdentifier = js.variables.declaration(ast, { - kind: 'const', - name: 'task', - value: taskSchemaExpression - }); - js.exports.createNamed(ast, { - name: 'task', - fallback: taskIdentifier - }); + if (!taskSchemaExpression) throw new Error('unreachable state...'); + const taskIdentifier = js.variables.declaration(ast, { + kind: 'const', + name: 'task', + value: taskSchemaExpression + }); + js.exports.createNamed(ast, { + name: 'task', + fallback: taskIdentifier + }); + }) + ); - return generateCode(); - }); + sv.file( + paths.database, + transforms.script((ast) => { + if (options.database === 'd1') { + js.imports.addNamespace(ast, { from: './schema', as: 'schema' }); + js.imports.addNamed(ast, { from: 'drizzle-orm/d1', imports: ['drizzle'] }); - sv.file(paths.database, (content) => { - const { ast, generateCode } = parse.script(content); + const getDbFn = js.common.parseStatement( + `export const getDb = (d1${typescript ? ': D1Database' : ''}) => drizzle(d1, { schema });` + ); - if (options.database === 'd1') { - js.imports.addNamespace(ast, { from: './schema', as: 'schema' }); - js.imports.addNamed(ast, { from: 'drizzle-orm/d1', imports: ['drizzle'] }); + ast.body.push(getDbFn); - const getDbFn = js.common.parseStatement( - `export const getDb = (d1${typescript ? ': D1Database' : ''}) => drizzle(d1, { schema });` - ); + return; + } - ast.body.push(getDbFn); + js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); + js.imports.addNamespace(ast, { from: './schema', as: 'schema' }); - return generateCode(); - } + // env var checks + const dbURLCheck = js.common.parseStatement( + "if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" + ); + ast.body.push(dbURLCheck); + + let clientExpression; + // SQLite + if (options.sqlite === 'better-sqlite3') { + js.imports.addDefault(ast, { from: 'better-sqlite3', as: 'Database' }); + js.imports.addNamed(ast, { + from: 'drizzle-orm/better-sqlite3', + imports: ['drizzle'] + }); + + clientExpression = js.common.parseExpression('new Database(env.DATABASE_URL)'); + } + if (options.sqlite === 'libsql' || options.sqlite === 'turso') { + js.imports.addNamed(ast, { + from: '@libsql/client', + imports: ['createClient'] + }); + js.imports.addNamed(ast, { + from: 'drizzle-orm/libsql', + imports: ['drizzle'] + }); + + if (options.sqlite === 'turso') { + ast.body.push( + js.common.parseStatement( + "if (!env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');" + ) + ); + clientExpression = js.common.parseExpression( + 'createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN })' + ); + } else { + clientExpression = js.common.parseExpression('createClient({ url: env.DATABASE_URL })'); + } + } + // MySQL + if (options.mysql === 'mysql2' || options.mysql === 'planetscale') { + js.imports.addDefault(ast, { from: 'mysql2/promise', as: 'mysql' }); + js.imports.addNamed(ast, { + from: 'drizzle-orm/mysql2', + imports: ['drizzle'] + }); + + clientExpression = js.common.parseExpression('mysql.createPool(env.DATABASE_URL)'); + } + // PostgreSQL + if (options.postgresql === 'neon') { + js.imports.addNamed(ast, { + from: '@neondatabase/serverless', + imports: ['neon'] + }); + js.imports.addNamed(ast, { + from: 'drizzle-orm/neon-http', + imports: ['drizzle'] + }); + + clientExpression = js.common.parseExpression('neon(env.DATABASE_URL)'); + } + if (options.postgresql === 'postgres.js') { + js.imports.addDefault(ast, { from: 'postgres', as: 'postgres' }); + js.imports.addNamed(ast, { + from: 'drizzle-orm/postgres-js', + imports: ['drizzle'] + }); - js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); - js.imports.addNamespace(ast, { from: './schema', as: 'schema' }); + clientExpression = js.common.parseExpression('postgres(env.DATABASE_URL)'); + } - // env var checks - const dbURLCheck = js.common.parseStatement( - "if (!env.DATABASE_URL) throw new Error('DATABASE_URL is not set');" - ); - ast.body.push(dbURLCheck); + if (!clientExpression) throw new Error('unreachable state...'); + ast.body.push( + js.variables.declaration(ast, { + kind: 'const', + name: 'client', + value: clientExpression + }) + ); - let clientExpression; - // SQLite - if (options.sqlite === 'better-sqlite3') { - js.imports.addDefault(ast, { from: 'better-sqlite3', as: 'Database' }); - js.imports.addNamed(ast, { - from: 'drizzle-orm/better-sqlite3', - imports: ['drizzle'] + // create drizzle function call + const drizzleCall = js.functions.createCall({ + name: 'drizzle', + args: ['client'], + useIdentifiers: true }); - clientExpression = js.common.parseExpression('new Database(env.DATABASE_URL)'); - } - if (options.sqlite === 'libsql' || options.sqlite === 'turso') { - js.imports.addNamed(ast, { - from: '@libsql/client', - imports: ['createClient'] - }); - js.imports.addNamed(ast, { - from: 'drizzle-orm/libsql', - imports: ['drizzle'] + // add schema to support `db.query` + const paramObject = js.object.create({ + schema: js.variables.createIdentifier('schema') }); - - if (options.sqlite === 'turso') { - ast.body.push( - js.common.parseStatement( - "if (!env.DATABASE_AUTH_TOKEN) throw new Error('DATABASE_AUTH_TOKEN is not set');" - ) - ); - clientExpression = js.common.parseExpression( - 'createClient({ url: env.DATABASE_URL, authToken: env.DATABASE_AUTH_TOKEN })' - ); - } else { - clientExpression = js.common.parseExpression('createClient({ url: env.DATABASE_URL })'); + if (options.database === 'mysql') { + const mode = options.mysql === 'planetscale' ? 'planetscale' : 'default'; + js.object.property(paramObject, { + name: 'mode', + fallback: js.common.createLiteral(mode) + }); } - } - // MySQL - if (options.mysql === 'mysql2' || options.mysql === 'planetscale') { - js.imports.addDefault(ast, { from: 'mysql2/promise', as: 'mysql' }); - js.imports.addNamed(ast, { - from: 'drizzle-orm/mysql2', - imports: ['drizzle'] - }); - - clientExpression = js.common.parseExpression('mysql.createPool(env.DATABASE_URL)'); - } - // PostgreSQL - if (options.postgresql === 'neon') { - js.imports.addNamed(ast, { - from: '@neondatabase/serverless', - imports: ['neon'] - }); - js.imports.addNamed(ast, { - from: 'drizzle-orm/neon-http', - imports: ['drizzle'] - }); - - clientExpression = js.common.parseExpression('neon(env.DATABASE_URL)'); - } - if (options.postgresql === 'postgres.js') { - js.imports.addDefault(ast, { from: 'postgres', as: 'postgres' }); - js.imports.addNamed(ast, { - from: 'drizzle-orm/postgres-js', - imports: ['drizzle'] - }); - - clientExpression = js.common.parseExpression('postgres(env.DATABASE_URL)'); - } + drizzleCall.arguments.push(paramObject); - if (!clientExpression) throw new Error('unreachable state...'); - ast.body.push( - js.variables.declaration(ast, { + // create `db` export + const db = js.variables.declaration(ast, { kind: 'const', - name: 'client', - value: clientExpression - }) - ); - - // create drizzle function call - const drizzleCall = js.functions.createCall({ - name: 'drizzle', - args: ['client'], - useIdentifiers: true - }); - - // add schema to support `db.query` - const paramObject = js.object.create({ - schema: js.variables.createIdentifier('schema') - }); - if (options.database === 'mysql') { - const mode = options.mysql === 'planetscale' ? 'planetscale' : 'default'; - js.object.property(paramObject, { - name: 'mode', - fallback: js.common.createLiteral(mode) + name: 'db', + value: drizzleCall }); - } - drizzleCall.arguments.push(paramObject); - - // create `db` export - const db = js.variables.declaration(ast, { - kind: 'const', - name: 'db', - value: drizzleCall - }); - js.exports.createNamed(ast, { - name: 'db', - fallback: db - }); - - return generateCode(); - }); + js.exports.createNamed(ast, { + name: 'db', + fallback: db + }); + }) + ); }, nextSteps: ({ options, packageManager, cwd }) => { diff --git a/packages/sv/src/addons/eslint.ts b/packages/sv/src/addons/eslint.ts index f57eec50f..7743004d1 100644 --- a/packages/sv/src/addons/eslint.ts +++ b/packages/sv/src/addons/eslint.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { type AstTypes, js, parse, json } from '@sveltejs/sv-utils'; +import { type AstTypes, js, transforms, json } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; import { addEslintConfigPrettier, getNodeTypesVersion } from './common.ts'; @@ -8,7 +8,7 @@ export default defineAddon({ shortDescription: 'linter', homepage: 'https://eslint.org', options: {}, - run: ({ sv, language, dependencyVersion, files }) => { + run: ({ sv, language, dependencyVersion, file }) => { const typescript = language === 'ts'; const prettierInstalled = Boolean(dependencyVersion('prettier')); @@ -23,131 +23,130 @@ export default defineAddon({ if (prettierInstalled) sv.devDependency('eslint-config-prettier', '^10.1.8'); - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - - json.packageScriptsUpsert(data, 'lint', 'eslint .'); - - return generateCode(); - }); - - sv.file(files.eslintConfig, (content) => { - const { ast, comments, generateCode } = parse.script(content); - - const eslintConfigs: Array = []; - js.imports.addDefault(ast, { from: './svelte.config.js', as: 'svelteConfig' }); - const gitIgnorePathStatement = js.common.parseStatement( - "\nconst gitignorePath = path.resolve(import.meta.dirname, '.gitignore');" - ); - js.common.appendStatement(ast, { statement: gitIgnorePathStatement }); - - const ignoresConfig = js.common.parseExpression('includeIgnoreFile(gitignorePath)'); - eslintConfigs.push(ignoresConfig); - - const jsConfig = js.common.parseExpression('js.configs.recommended'); - eslintConfigs.push(jsConfig); - - if (typescript) { - const tsConfig = js.common.parseExpression('ts.configs.recommended'); - eslintConfigs.push(tsConfig); - } - - const svelteConfig = js.common.parseExpression('svelte.configs.recommended'); - eslintConfigs.push(svelteConfig); - - const globalsBrowser = js.common.createSpread(js.common.parseExpression('globals.browser')); - const globalsNode = js.common.createSpread(js.common.parseExpression('globals.node')); - const globalsObjLiteral = js.object.create({}); - globalsObjLiteral.properties = [globalsBrowser, globalsNode]; - const rules = js.object.create({ '"no-undef"': 'off' }); - - if (rules.properties[0].type !== 'Property') { - throw new Error('rules.properties[0].type !== "Property"'); - } - comments.add(rules.properties[0].key, { - type: 'Line', - value: - ' typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.' - }); - comments.add(rules.properties[0].key, { - type: 'Line', - value: - ' see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors' - }); - - const globalsConfig = js.object.create({ - languageOptions: { - globals: globalsObjLiteral - }, - rules: typescript ? rules : undefined - }); - - eslintConfigs.push(globalsConfig); - - if (typescript) { - const svelteTSParserConfig = js.object.create({ - files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], - languageOptions: { - parserOptions: { - projectService: true, - extraFileExtensions: ['.svelte'], - parser: js.variables.createIdentifier('ts.parser'), - svelteConfig: js.variables.createIdentifier('svelteConfig') - } - } + sv.file( + file.package, + transforms.json((data) => { + json.packageScriptsUpsert(data, 'lint', 'eslint .'); + }) + ); + + sv.file( + file.eslintConfig, + transforms.script((ast, comments) => { + const eslintConfigs: Array = []; + js.imports.addDefault(ast, { from: './svelte.config.js', as: 'svelteConfig' }); + const gitIgnorePathStatement = js.common.parseStatement( + "\nconst gitignorePath = path.resolve(import.meta.dirname, '.gitignore');" + ); + js.common.appendStatement(ast, { statement: gitIgnorePathStatement }); + + const ignoresConfig = js.common.parseExpression('includeIgnoreFile(gitignorePath)'); + eslintConfigs.push(ignoresConfig); + + const jsConfig = js.common.parseExpression('js.configs.recommended'); + eslintConfigs.push(jsConfig); + + if (typescript) { + const tsConfig = js.common.parseExpression('ts.configs.recommended'); + eslintConfigs.push(tsConfig); + } + + const svelteConfig = js.common.parseExpression('svelte.configs.recommended'); + eslintConfigs.push(svelteConfig); + + const globalsBrowser = js.common.createSpread(js.common.parseExpression('globals.browser')); + const globalsNode = js.common.createSpread(js.common.parseExpression('globals.node')); + const globalsObjLiteral = js.object.create({}); + globalsObjLiteral.properties = [globalsBrowser, globalsNode]; + const rules = js.object.create({ '"no-undef"': 'off' }); + + if (rules.properties[0].type !== 'Property') { + throw new Error('rules.properties[0].type !== "Property"'); + } + comments.add(rules.properties[0].key, { + type: 'Line', + value: + ' typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.' + }); + comments.add(rules.properties[0].key, { + type: 'Line', + value: + ' see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors' }); - eslintConfigs.push(svelteTSParserConfig); - } else { - const svelteTSParserConfig = js.object.create({ - files: ['**/*.svelte', '**/*.svelte.js'], + + const globalsConfig = js.object.create({ languageOptions: { - parserOptions: { - svelteConfig: js.variables.createIdentifier('svelteConfig') + globals: globalsObjLiteral + }, + rules: typescript ? rules : undefined + }); + + eslintConfigs.push(globalsConfig); + + if (typescript) { + const svelteTSParserConfig = js.object.create({ + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: js.variables.createIdentifier('ts.parser'), + svelteConfig: js.variables.createIdentifier('svelteConfig') + } + } + }); + eslintConfigs.push(svelteTSParserConfig); + } else { + const svelteTSParserConfig = js.object.create({ + files: ['**/*.svelte', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + svelteConfig: js.variables.createIdentifier('svelteConfig') + } } - } + }); + eslintConfigs.push(svelteTSParserConfig); + } + + const exportExpression = js.functions.createCall({ name: 'defineConfig', args: [] }); + if (typescript) { + exportExpression.arguments.push(...eslintConfigs); + } else { + const eslintArray = js.array.create(); + eslintConfigs.map((x) => js.array.append(eslintArray, x)); + exportExpression.arguments.push(eslintArray); + } + const { value: defaultExport } = js.exports.createDefault(ast, { + fallback: exportExpression }); - eslintConfigs.push(svelteTSParserConfig); - } - - const exportExpression = js.functions.createCall({ name: 'defineConfig', args: [] }); - if (typescript) { - exportExpression.arguments.push(...eslintConfigs); - } else { - const eslintArray = js.array.create(); - eslintConfigs.map((x) => js.array.append(eslintArray, x)); - exportExpression.arguments.push(eslintArray); - } - const { value: defaultExport } = js.exports.createDefault(ast, { - fallback: exportExpression - }); - // if it's not the config we created, then we'll leave it alone and exit out - if (defaultExport !== exportExpression) { - log.warn('An eslint config is already defined. Skipping initialization.'); - return content; - } - - if (typescript) js.imports.addDefault(ast, { from: 'typescript-eslint', as: 'ts' }); - js.imports.addDefault(ast, { from: 'globals', as: 'globals' }); - js.imports.addNamed(ast, { from: 'eslint/config', imports: ['defineConfig'] }); - js.imports.addDefault(ast, { from: 'eslint-plugin-svelte', as: 'svelte' }); - js.imports.addDefault(ast, { from: '@eslint/js', as: 'js' }); - js.imports.addNamed(ast, { - from: '@eslint/compat', - imports: ['includeIgnoreFile'] - }); - js.imports.addDefault(ast, { from: 'node:path', as: 'path' }); - - return generateCode(); - }); - - sv.file(files.vscodeExtensions, (content) => { - const { data, generateCode } = parse.json(content); - json.arrayUpsert(data, 'recommendations', 'dbaeumer.vscode-eslint'); - return generateCode(); - }); + // if it's not the config we created, then we'll leave it alone and exit out + if (defaultExport !== exportExpression) { + log.warn('An eslint config is already defined. Skipping initialization.'); + return false; + } + + if (typescript) js.imports.addDefault(ast, { from: 'typescript-eslint', as: 'ts' }); + js.imports.addDefault(ast, { from: 'globals', as: 'globals' }); + js.imports.addNamed(ast, { from: 'eslint/config', imports: ['defineConfig'] }); + js.imports.addDefault(ast, { from: 'eslint-plugin-svelte', as: 'svelte' }); + js.imports.addDefault(ast, { from: '@eslint/js', as: 'js' }); + js.imports.addNamed(ast, { + from: '@eslint/compat', + imports: ['includeIgnoreFile'] + }); + js.imports.addDefault(ast, { from: 'node:path', as: 'path' }); + }) + ); + + sv.file( + file.vscodeExtensions, + transforms.json((data) => { + json.arrayUpsert(data, 'recommendations', 'dbaeumer.vscode-eslint'); + }) + ); if (prettierInstalled) { - sv.file(files.eslintConfig, addEslintConfigPrettier); + sv.file(file.eslintConfig, addEslintConfigPrettier); } } }); diff --git a/packages/sv/src/addons/mcp.ts b/packages/sv/src/addons/mcp.ts index 4d10915d7..04e943c9b 100644 --- a/packages/sv/src/addons/mcp.ts +++ b/packages/sv/src/addons/mcp.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { color, parse } from '@sveltejs/sv-utils'; +import { color, transforms } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; import { getSharedFiles } from '../create/utils.ts'; @@ -150,37 +150,38 @@ export default defineAddon({ }); } - sv.file(configPath, (content) => { - const { data, generateCode } = parse.json(content); - - if (schema) { - data['$schema'] = schema; - } + sv.file( + configPath, + transforms.json((data) => { + if (schema) { + data['$schema'] = schema; + } - if (customData) { - for (const [key, value] of Object.entries(customData)) { - data[key] = value; + if (customData) { + for (const [key, value] of Object.entries(customData)) { + data[key] = value; + } } - } - if (mcpOptions) { - const key = mcpOptions.serversKey ?? 'mcpServers'; - data[key] ??= {}; - data[key].svelte = - options.setup === 'local' ? getLocalConfig(mcpOptions) : getRemoteConfig(mcpOptions); - } - return generateCode(); - }); + if (mcpOptions) { + const key = mcpOptions.serversKey ?? 'mcpServers'; + data[key] ??= {}; + data[key].svelte = + options.setup === 'local' ? getLocalConfig(mcpOptions) : getRemoteConfig(mcpOptions); + } + }) + ); if (extraFiles) { for (const extra of extraFiles) { - sv.file(extra.path, (content) => { - const { data, generateCode } = parse.json(content); - for (const [key, value] of Object.entries(extra.data)) { - data[key] = value; - } - return generateCode(); - }); + sv.file( + extra.path, + transforms.json((data) => { + for (const [key, value] of Object.entries(extra.data)) { + data[key] = value; + } + }) + ); } } } diff --git a/packages/sv/src/addons/mdsvex.ts b/packages/sv/src/addons/mdsvex.ts index d329b9c30..cc62711e7 100644 --- a/packages/sv/src/addons/mdsvex.ts +++ b/packages/sv/src/addons/mdsvex.ts @@ -1,4 +1,4 @@ -import { js, parse } from '@sveltejs/sv-utils'; +import { js, transforms } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; export default defineAddon({ @@ -6,48 +6,47 @@ export default defineAddon({ shortDescription: 'svelte + markdown', homepage: 'https://mdsvex.pngwn.io', options: {}, - run: ({ sv, files }) => { + run: ({ sv, file }) => { sv.devDependency('mdsvex', '^0.12.6'); - sv.file(files.svelteConfig, (content) => { - const { ast, generateCode } = parse.script(content); + sv.file( + file.svelteConfig, + transforms.script((ast) => { + js.imports.addNamed(ast, { from: 'mdsvex', imports: ['mdsvex'] }); - js.imports.addNamed(ast, { from: 'mdsvex', imports: ['mdsvex'] }); - - const { value: exportDefault } = js.exports.createDefault(ast, { - fallback: js.object.create({}) - }); - - // preprocess - let preprocessorArray = js.object.property(exportDefault, { - name: 'preprocess', - fallback: js.array.create() - }); - const isArray = preprocessorArray.type === 'ArrayExpression'; - - if (!isArray) { - const previousElement = preprocessorArray; - preprocessorArray = js.array.create(); - js.array.append(preprocessorArray, previousElement); - js.object.overrideProperties(exportDefault, { - preprocess: preprocessorArray + const { value: exportDefault } = js.exports.createDefault(ast, { + fallback: js.object.create({}) }); - } - - const mdsvexCall = js.functions.createCall({ name: 'mdsvex', args: [] }); - mdsvexCall.arguments.push(js.object.create({ extensions: ['.svx', '.md'] })); - js.array.append(preprocessorArray, mdsvexCall); - // extensions - const extensionsArray = js.object.property(exportDefault, { - name: 'extensions', - fallback: js.array.create() - }); - js.array.append(extensionsArray, '.svelte'); - js.array.append(extensionsArray, '.svx'); - js.array.append(extensionsArray, '.md'); - - return generateCode(); - }); + // preprocess + let preprocessorArray = js.object.property(exportDefault, { + name: 'preprocess', + fallback: js.array.create() + }); + const isArray = preprocessorArray.type === 'ArrayExpression'; + + if (!isArray) { + const previousElement = preprocessorArray; + preprocessorArray = js.array.create(); + js.array.append(preprocessorArray, previousElement); + js.object.overrideProperties(exportDefault, { + preprocess: preprocessorArray + }); + } + + const mdsvexCall = js.functions.createCall({ name: 'mdsvex', args: [] }); + mdsvexCall.arguments.push(js.object.create({ extensions: ['.svx', '.md'] })); + js.array.append(preprocessorArray, mdsvexCall); + + // extensions + const extensionsArray = js.object.property(exportDefault, { + name: 'extensions', + fallback: js.array.create() + }); + js.array.append(extensionsArray, '.svelte'); + js.array.append(extensionsArray, '.svx'); + js.array.append(extensionsArray, '.md'); + }) + ); } }); diff --git a/packages/sv/src/addons/paraglide.ts b/packages/sv/src/addons/paraglide.ts index 6bcde3224..2e23cd4fe 100644 --- a/packages/sv/src/addons/paraglide.ts +++ b/packages/sv/src/addons/paraglide.ts @@ -1,5 +1,14 @@ import { log } from '@clack/prompts'; -import { color, html, js, parse, svelte, type SvelteAst, text } from '@sveltejs/sv-utils'; +import { + color, + html, + js, + parse, + svelte, + type SvelteAst, + text, + transforms +} from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -53,7 +62,7 @@ export default defineAddon({ setup: ({ kit, unsupported }) => { if (!kit) unsupported('Requires SvelteKit'); }, - run: ({ sv, options, files, language, kit }) => { + run: ({ sv, options, file, language, kit }) => { if (!kit) throw new Error('SvelteKit is required'); const paraglideOutDir = 'src/lib/paraglide'; @@ -61,76 +70,75 @@ export default defineAddon({ sv.devDependency('@inlang/paraglide-js', '^2.10.0'); // add the vite plugin - sv.file(files.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - - const vitePluginName = 'paraglideVitePlugin'; - js.imports.addNamed(ast, { imports: [vitePluginName], from: '@inlang/paraglide-js' }); - js.vite.addPlugin(ast, { - code: `${vitePluginName}({ - project: './project.inlang', - outdir: './${paraglideOutDir}' + sv.file( + file.viteConfig, + transforms.script((ast) => { + const vitePluginName = 'paraglideVitePlugin'; + js.imports.addNamed(ast, { imports: [vitePluginName], from: '@inlang/paraglide-js' }); + js.vite.addPlugin(ast, { + code: `${vitePluginName}({ + project: './project.inlang', + outdir: './${paraglideOutDir}' })` - }); - - return generateCode(); - }); + }); + }) + ); // reroute hook - sv.file(`src/hooks.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); - js.imports.addNamed(ast, { - from: '$lib/paraglide/runtime', - imports: ['deLocalizeUrl'] - }); - - const expression = js.common.parseExpression( - '(request) => deLocalizeUrl(request.url).pathname' - ); - const rerouteIdentifier = js.variables.declaration(ast, { - kind: 'const', - name: 'reroute', - value: expression - }); - - const existingExport = js.exports.createNamed(ast, { - name: 'reroute', - fallback: rerouteIdentifier - }); - if (existingExport.declaration !== rerouteIdentifier) { - log.warn('Adding the reroute hook automatically failed. Add it manually'); - } + sv.file( + `src/hooks.${language}`, + transforms.script((ast) => { + js.imports.addNamed(ast, { + from: '$lib/paraglide/runtime', + imports: ['deLocalizeUrl'] + }); - return generateCode(); - }); + const expression = js.common.parseExpression( + '(request) => deLocalizeUrl(request.url).pathname' + ); + const rerouteIdentifier = js.variables.declaration(ast, { + kind: 'const', + name: 'reroute', + value: expression + }); + + const existingExport = js.exports.createNamed(ast, { + name: 'reroute', + fallback: rerouteIdentifier + }); + if (existingExport.declaration !== rerouteIdentifier) { + log.warn('Adding the reroute hook automatically failed. Add it manually'); + } + }) + ); // handle hook - sv.file(`src/hooks.server.${language}`, (content) => { - const { ast, generateCode, comments } = parse.script(content); - js.imports.addNamed(ast, { - from: '$lib/paraglide/server', - imports: ['paraglideMiddleware'] - }); - js.imports.addNamed(ast, { - from: '$lib/paraglide/runtime', - imports: ['getTextDirection'] - }); - - const hookHandleContent = `({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { + sv.file( + `src/hooks.server.${language}`, + transforms.script((ast, comments) => { + js.imports.addNamed(ast, { + from: '$lib/paraglide/server', + imports: ['paraglideMiddleware'] + }); + js.imports.addNamed(ast, { + from: '$lib/paraglide/runtime', + imports: ['getTextDirection'] + }); + + const hookHandleContent = `({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { event.request = request; return resolve(event, { transformPageChunk: ({ html }) => html.replace('%paraglide.lang%', locale).replace('%paraglide.dir%', getTextDirection(locale)) }); });`; - js.kit.addHooksHandle(ast, { - language, - newHandleName: 'handleParaglide', - handleContent: hookHandleContent, - comments - }); - - return generateCode(); - }); + js.kit.addHooksHandle(ast, { + language, + newHandleName: 'handleParaglide', + handleContent: hookHandleContent, + comments + }); + }) + ); // add the lang and dir attributes placeholder to app.html sv.file('src/app.html', (content) => { @@ -152,7 +160,7 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.gitignore, (content) => { + sv.file(file.gitignore, (content) => { if (!content) return content; content = text.upsert(content, paraglideOutDir, { comment: 'Paraglide' }); @@ -164,88 +172,86 @@ export default defineAddon({ sv.file('project.inlang/settings.json', (content) => { if (content) return content; - const { data, generateCode } = parse.json(content); - - for (const key in DEFAULT_INLANG_PROJECT) { - data[key] = DEFAULT_INLANG_PROJECT[key as keyof typeof DEFAULT_INLANG_PROJECT]; - } - const { validLanguageTags } = parseLanguageTagInput(options.languageTags); - const baseLocale = validLanguageTags[0]; - - data.baseLocale = baseLocale; - data.locales = validLanguageTags; + return transforms.json((data) => { + for (const key in DEFAULT_INLANG_PROJECT) { + data[key] = DEFAULT_INLANG_PROJECT[key as keyof typeof DEFAULT_INLANG_PROJECT]; + } + const { validLanguageTags } = parseLanguageTagInput(options.languageTags); + const baseLocale = validLanguageTags[0]; - return generateCode(); + data.baseLocale = baseLocale; + data.locales = validLanguageTags; + })(content); }); - sv.file(`${kit.routesDirectory}/+layout.svelte`, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); - js.imports.addNamed(ast.instance.content, { - imports: ['locales', 'localizeHref'], - from: '$lib/paraglide/runtime' - }); - js.imports.addNamed(ast.instance.content, { imports: ['page'], from: '$app/state' }); - svelte.addFragment( - ast, - `
+ sv.file( + `${kit.routesDirectory}/+layout.svelte`, + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); + js.imports.addNamed(ast.instance.content, { + imports: ['locales', 'localizeHref'], + from: '$lib/paraglide/runtime' + }); + js.imports.addNamed(ast.instance.content, { imports: ['page'], from: '$app/state' }); + svelte.addFragment( + ast, + `
{#each locales as locale} {locale} {/each}
` - ); - return generateCode(); - }); + ); + }) + ); if (options.demo) { - sv.file(`${kit.routesDirectory}/demo/+page.svelte`, (content) => { - return addToDemoPage(content, 'paraglide', language); - }); + sv.file(`${kit.routesDirectory}/demo/+page.svelte`, addToDemoPage('paraglide')); // add usage example - sv.file(`${kit.routesDirectory}/demo/paraglide/+page.svelte`, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); - - js.imports.addNamed(ast.instance.content, { - imports: { m: 'm' }, - from: '$lib/paraglide/messages.js' - }); - js.imports.addNamed(ast.instance.content, { - imports: { - setLocale: 'setLocale' - }, - from: '$lib/paraglide/runtime' - }); - - // add localized message - let templateCode = "

{m.hello_world({ name: 'SvelteKit User' })}

"; - - // add links to other localized pages, the first one is the default - // language, thus it does not require any localized route - const { validLanguageTags } = parseLanguageTagInput(options.languageTags); - const links = validLanguageTags - .map((x) => ``) - .join(''); - templateCode += `
${links}
`; - - templateCode += - '

If you use VSCode, install the Sherlock i18n extension for a better i18n experience.

'; - - svelte.addFragment(ast, templateCode); - - return generateCode(); - }); + sv.file( + `${kit.routesDirectory}/demo/paraglide/+page.svelte`, + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); + + js.imports.addNamed(ast.instance.content, { + imports: { m: 'm' }, + from: '$lib/paraglide/messages.js' + }); + js.imports.addNamed(ast.instance.content, { + imports: { + setLocale: 'setLocale' + }, + from: '$lib/paraglide/runtime' + }); + + // add localized message + let templateCode = "

{m.hello_world({ name: 'SvelteKit User' })}

"; + + // add links to other localized pages, the first one is the default + // language, thus it does not require any localized route + const { validLanguageTags } = parseLanguageTagInput(options.languageTags); + const links = validLanguageTags + .map((x) => ``) + .join(''); + templateCode += `
${links}
`; + + templateCode += + '

If you use VSCode, install the Sherlock i18n extension for a better i18n experience.

'; + + svelte.addFragment(ast, templateCode); + }) + ); } const { validLanguageTags } = parseLanguageTagInput(options.languageTags); for (const languageTag of validLanguageTags) { - sv.file(`messages/${languageTag}.json`, (content) => { - const { data, generateCode } = parse.json(content); - data['$schema'] = 'https://inlang.com/schema/inlang-message-format'; - data.hello_world = `Hello, {name} from ${languageTag}!`; - return generateCode(); - }); + sv.file( + `messages/${languageTag}.json`, + transforms.json((data) => { + data['$schema'] = 'https://inlang.com/schema/inlang-message-format'; + data.hello_world = `Hello, {name} from ${languageTag}!`; + }) + ); } }, diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts index 75216fe83..512629141 100644 --- a/packages/sv/src/addons/playwright.ts +++ b/packages/sv/src/addons/playwright.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { color, dedent, js, parse, json, text } from '@sveltejs/sv-utils'; +import { color, dedent, js, transforms, json, text } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -8,19 +8,18 @@ export default defineAddon({ shortDescription: 'browser testing', homepage: 'https://playwright.dev', options: {}, - run: ({ sv, language, files, kit }) => { + run: ({ sv, language, file, kit }) => { sv.devDependency('@playwright/test', '^1.58.2'); - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); + sv.file( + file.package, + transforms.json((data) => { + json.packageScriptsUpsert(data, 'test:e2e', 'playwright test'); + json.packageScriptsUpsert(data, 'test', 'npm run test:e2e'); + }) + ); - json.packageScriptsUpsert(data, 'test:e2e', 'playwright test'); - json.packageScriptsUpsert(data, 'test', 'npm run test:e2e'); - - return generateCode(); - }); - - sv.file(files.gitignore, (content) => { + sv.file(file.gitignore, (content) => { if (!content) return content; return text.upsert(content, 'test-results', { comment: 'Playwright' }); }); @@ -29,9 +28,7 @@ export default defineAddon({ const testRoute = kit ? '/demo/playwright' : '/'; if (kit) { - sv.file(`${kit.routesDirectory}/demo/+page.svelte`, (content) => { - return addToDemoPage(content, 'playwright', language); - }); + sv.file(`${kit.routesDirectory}/demo/+page.svelte`, addToDemoPage('playwright')); sv.file(`${testDir}/+page.svelte`, (content) => { if (content) return content; @@ -55,32 +52,33 @@ export default defineAddon({ `; }); - sv.file(`playwright.config.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); - const defineConfig = js.common.parseExpression('defineConfig({})'); - const { value: defaultExport } = js.exports.createDefault(ast, { fallback: defineConfig }); - - const config = { - webServer: { - command: 'npm run build && npm run preview', - port: 4173 - }, - testMatch: '**/*.e2e.{ts,js}' - }; - - if ( - defaultExport.type === 'CallExpression' && - defaultExport.arguments[0]?.type === 'ObjectExpression' - ) { - js.imports.addNamed(ast, { imports: ['defineConfig'], from: '@playwright/test' }); - js.object.overrideProperties(defaultExport.arguments[0], config); - } else if (defaultExport.type === 'ObjectExpression') { - js.object.overrideProperties(defaultExport, config); - } else { - log.warn('Unexpected playwright config for playwright add-on. Could not update.'); - } - return generateCode(); - }); + sv.file( + `playwright.config.${language}`, + transforms.script((ast) => { + const defineConfig = js.common.parseExpression('defineConfig({})'); + const { value: defaultExport } = js.exports.createDefault(ast, { fallback: defineConfig }); + + const config = { + webServer: { + command: 'npm run build && npm run preview', + port: 4173 + }, + testMatch: '**/*.e2e.{ts,js}' + }; + + if ( + defaultExport.type === 'CallExpression' && + defaultExport.arguments[0]?.type === 'ObjectExpression' + ) { + js.imports.addNamed(ast, { imports: ['defineConfig'], from: '@playwright/test' }); + js.object.overrideProperties(defaultExport.arguments[0], config); + } else if (defaultExport.type === 'ObjectExpression') { + js.object.overrideProperties(defaultExport, config); + } else { + log.warn('Unexpected playwright config for playwright add-on. Could not update.'); + } + }) + ); }, nextSteps: ({ kit }) => { diff --git a/packages/sv/src/addons/prettier.ts b/packages/sv/src/addons/prettier.ts index c1c7d307a..df56a213a 100644 --- a/packages/sv/src/addons/prettier.ts +++ b/packages/sv/src/addons/prettier.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { color, dedent, parse, json } from '@sveltejs/sv-utils'; +import { color, dedent, parse, json, transforms } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; import { addEslintConfigPrettier } from './common.ts'; @@ -8,14 +8,14 @@ export default defineAddon({ shortDescription: 'formatter', homepage: 'https://prettier.io', options: {}, - run: ({ sv, dependencyVersion, files }) => { + run: ({ sv, dependencyVersion, file }) => { const tailwindcssInstalled = Boolean(dependencyVersion('tailwindcss')); if (tailwindcssInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.7.2'); sv.devDependency('prettier', '^3.8.1'); sv.devDependency('prettier-plugin-svelte', '^3.4.1'); - sv.file(files.prettierignore, (content) => { + sv.file(file.prettierignore, (content) => { if (content) return content; return dedent` # Package Managers @@ -30,7 +30,7 @@ export default defineAddon({ `; }); - sv.file(files.prettierrc, (content) => { + sv.file(file.prettierrc, (content) => { let data, generateCode; try { ({ data, generateCode } = parse.json(content)); @@ -52,7 +52,7 @@ export default defineAddon({ if (tailwindcssInstalled) { json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss'); - data.tailwindStylesheet ??= files.getRelative({ to: files.stylesheet }); + data.tailwindStylesheet ??= file.getRelative({ to: file.stylesheet }); } data.overrides ??= []; @@ -68,20 +68,20 @@ export default defineAddon({ const eslintVersion = dependencyVersion('eslint'); const eslintInstalled = hasEslint(eslintVersion); - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - - json.packageScriptsUpsert(data, 'lint', 'prettier --check .', { mode: 'prepend' }); - json.packageScriptsUpsert(data, 'format', 'prettier --write .'); - - return generateCode(); - }); - - sv.file(files.vscodeExtensions, (content) => { - const { data, generateCode } = parse.json(content); - json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode'); - return generateCode(); - }); + sv.file( + file.package, + transforms.json((data) => { + json.packageScriptsUpsert(data, 'lint', 'prettier --check .', { mode: 'prepend' }); + json.packageScriptsUpsert(data, 'format', 'prettier --write .'); + }) + ); + + sv.file( + file.vscodeExtensions, + transforms.json((data) => { + json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode'); + }) + ); if (eslintVersion?.startsWith(SUPPORTED_ESLINT_VERSION) === false) { log.warn( @@ -93,7 +93,7 @@ export default defineAddon({ if (eslintInstalled) { sv.devDependency('eslint-config-prettier', '^10.1.8'); - sv.file(files.eslintConfig, addEslintConfigPrettier); + sv.file(file.eslintConfig, addEslintConfigPrettier); } } }); diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index a05057391..7d3eeb613 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,4 +1,13 @@ -import { color, js, resolveCommand, json, sanitizeName, text, parse } from '@sveltejs/sv-utils'; +import { + color, + js, + resolveCommand, + json, + sanitizeName, + text, + parse, + transforms +} from '@sveltejs/sv-utils'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; @@ -41,85 +50,85 @@ export default defineAddon({ setup: ({ kit, unsupported }) => { if (!kit) unsupported('Requires SvelteKit'); }, - run: ({ sv, options, files, cwd, language }) => { + run: ({ sv, options, file, cwd, language }) => { const adapter = adapters.find((a) => a.id === options.adapter)!; // removes previously installed adapters - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - const devDeps = data['devDependencies']; - - for (const pkg of Object.keys(devDeps)) { - if (pkg.startsWith('@sveltejs/adapter-')) { - delete devDeps[pkg]; + sv.file( + file.package, + transforms.json((data) => { + const devDeps = data['devDependencies']; + + for (const pkg of Object.keys(devDeps)) { + if (pkg.startsWith('@sveltejs/adapter-')) { + delete devDeps[pkg]; + } } - } - - // in sk 3, we will keep "preview": "vite preview" like any other adapter - if (options.adapter === 'cloudflare') { - const preview = - options.cfTarget === 'workers' - ? 'wrangler dev .svelte-kit/cloudflare/_worker.js --port 4173' - : 'wrangler pages dev .svelte-kit/cloudflare --port 4173'; - data.scripts.preview = preview; - } - return generateCode(); - }); + // in sk 3, we will keep "preview": "vite preview" like any other adapter + if (options.adapter === 'cloudflare') { + const preview = + options.cfTarget === 'workers' + ? 'wrangler dev .svelte-kit/cloudflare/_worker.js --port 4173' + : 'wrangler pages dev .svelte-kit/cloudflare --port 4173'; + data.scripts.preview = preview; + } + }) + ); sv.devDependency(adapter.package, adapter.version); - sv.file(files.svelteConfig, (content) => { - const { ast, comments, generateCode } = parse.script(content); + sv.file( + file.svelteConfig, + transforms.script((ast, comments) => { + // finds any existing adapter's import declaration + const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration'); + const adapterImportDecl = importDecls.find( + (importDecl) => + typeof importDecl.source.value === 'string' && + importDecl.source.value.startsWith('@sveltejs/adapter-') && + importDecl.importKind === 'value' + ); - // finds any existing adapter's import declaration - const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration'); - const adapterImportDecl = importDecls.find( - (importDecl) => - typeof importDecl.source.value === 'string' && - importDecl.source.value.startsWith('@sveltejs/adapter-') && - importDecl.importKind === 'value' - ); + let adapterName = 'adapter'; + if (adapterImportDecl) { + // replaces the import's source with the new adapter + adapterImportDecl.source.value = adapter.package; + // reset raw value, so that the string is re-generated + adapterImportDecl.source.raw = undefined; + + adapterName = adapterImportDecl.specifiers?.find( + (s) => s.type === 'ImportDefaultSpecifier' + )?.local?.name as string; + } else { + js.imports.addDefault(ast, { from: adapter.package, as: adapterName }); + } - let adapterName = 'adapter'; - if (adapterImportDecl) { - // replaces the import's source with the new adapter - adapterImportDecl.source.value = adapter.package; - // reset raw value, so that the string is re-generated - adapterImportDecl.source.raw = undefined; - - adapterName = adapterImportDecl.specifiers?.find((s) => s.type === 'ImportDefaultSpecifier') - ?.local?.name as string; - } else { - js.imports.addDefault(ast, { from: adapter.package, as: adapterName }); - } + const { value: config } = js.exports.createDefault(ast, { fallback: js.object.create({}) }); - const { value: config } = js.exports.createDefault(ast, { fallback: js.object.create({}) }); + // override the adapter property + js.object.overrideProperties(config, { + kit: { + adapter: js.functions.createCall({ name: adapterName, args: [], useIdentifiers: true }) + } + }); - // override the adapter property - js.object.overrideProperties(config, { - kit: { - adapter: js.functions.createCall({ name: adapterName, args: [], useIdentifiers: true }) + // reset the comment for non-auto adapters + if (adapter.package !== '@sveltejs/adapter-auto') { + const fallback = js.object.create({}); + const cfgKitValue = js.object.property(config, { name: 'kit', fallback }); + + // removes any existing adapter auto comments + comments.remove( + (c) => + c.loc && + cfgKitValue.loc && + c.loc.start.line >= cfgKitValue.loc.start.line && + c.loc.end.line <= cfgKitValue.loc.end.line + ); } - }); - - // reset the comment for non-auto adapters - if (adapter.package !== '@sveltejs/adapter-auto') { - const fallback = js.object.create({}); - const cfgKitValue = js.object.property(config, { name: 'kit', fallback }); - - // removes any existing adapter auto comments - comments.remove( - (c) => - c.loc && - cfgKitValue.loc && - c.loc.start.line >= cfgKitValue.loc.start.line && - c.loc.end.line <= cfgKitValue.loc.end.line - ); - } - - return generateCode(); - }); + }) + ); if (adapter.package === '@sveltejs/adapter-cloudflare') { sv.devDependency('wrangler', '^4.63.0'); @@ -136,7 +145,7 @@ export default defineAddon({ } if (!data.name) { - const pkg = parse.json(readFileSync(join(cwd, files.package), 'utf-8')); + const pkg = parse.json(readFileSync(join(cwd, file.package), 'utf-8')); data.name = sanitizeName(pkg.data.name, 'wrangler'); } @@ -172,7 +181,7 @@ export default defineAddon({ const typeChecked = language === 'ts' || jsconfig; if (typeChecked) { - sv.file(files.gitignore, (content) => { + sv.file(file.gitignore, (content) => { if (content.length === 0) return content; return text.upsert(content, '/worker-configuration.d.ts', { comment: 'Cloudflare Types' @@ -180,45 +189,42 @@ export default defineAddon({ }); // Setup wrangler types command - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - - json.packageScriptsUpsert(data, 'gen', 'wrangler types'); - - return generateCode(); - }); + sv.file( + file.package, + transforms.json((data) => { + json.packageScriptsUpsert(data, 'gen', 'wrangler types'); + }) + ); // Add Cloudflare generated types to tsconfig - sv.file(`${jsconfig ? 'jsconfig' : 'tsconfig'}.json`, (content) => { - const { data, generateCode } = parse.json(content); - - data.compilerOptions ??= {}; - data.compilerOptions.types ??= []; - data.compilerOptions.types.push('./worker-configuration.d.ts'); - - return generateCode(); - }); - - sv.file('src/app.d.ts', (content) => { - const { ast, comments, generateCode } = parse.script(content); - - const platform = js.kit.addGlobalAppInterface(ast, { name: 'Platform' }); - if (!platform) { - throw new Error('Failed detecting `platform` interface in `src/app.d.ts`'); - } - - // remove the commented out placeholder since we're adding the real one - comments.remove((c) => c.type === 'Line' && c.value.trim() === 'interface Platform {}'); - - platform.body.body.push( - js.common.createTypeProperty('env', 'Env'), - js.common.createTypeProperty('ctx', 'ExecutionContext'), - js.common.createTypeProperty('caches', 'CacheStorage'), - js.common.createTypeProperty('cf', 'IncomingRequestCfProperties', true) - ); + sv.file( + `${jsconfig ? 'jsconfig' : 'tsconfig'}.json`, + transforms.json((data) => { + data.compilerOptions ??= {}; + data.compilerOptions.types ??= []; + data.compilerOptions.types.push('./worker-configuration.d.ts'); + }) + ); - return generateCode(); - }); + sv.file( + 'src/app.d.ts', + transforms.script((ast, comments) => { + const platform = js.kit.addGlobalAppInterface(ast, { name: 'Platform' }); + if (!platform) { + throw new Error('Failed detecting `platform` interface in `src/app.d.ts`'); + } + + // remove the commented out placeholder since we're adding the real one + comments.remove((c) => c.type === 'Line' && c.value.trim() === 'interface Platform {}'); + + platform.body.body.push( + js.common.createTypeProperty('env', 'Env'), + js.common.createTypeProperty('ctx', 'ExecutionContext'), + js.common.createTypeProperty('caches', 'CacheStorage'), + js.common.createTypeProperty('cf', 'IncomingRequestCfProperties', true) + ); + }) + ); } } }, diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts index 00941ab25..7abf74e74 100644 --- a/packages/sv/src/addons/tailwindcss.ts +++ b/packages/sv/src/addons/tailwindcss.ts @@ -1,4 +1,4 @@ -import { css, js, parse, svelte, json } from '@sveltejs/sv-utils'; +import { css, js, transforms, svelte, json } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; const plugins = [ @@ -30,7 +30,7 @@ export default defineAddon({ shortDescription: 'css framework', homepage: 'https://tailwindcss.com', options, - run: ({ sv, options, files, kit, dependencyVersion, language }) => { + run: ({ sv, options, file, kit, dependencyVersion }) => { const prettierInstalled = Boolean(dependencyVersion('prettier')); sv.devDependency('tailwindcss', '^4.1.18'); @@ -46,94 +46,93 @@ export default defineAddon({ } // add the vite plugin - sv.file(files.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - - const vitePluginName = 'tailwindcss'; - js.imports.addDefault(ast, { as: vitePluginName, from: '@tailwindcss/vite' }); - js.vite.addPlugin(ast, { code: `${vitePluginName}()`, mode: 'prepend' }); - - return generateCode(); - }); - - sv.file(files.stylesheet, (content) => { - const { ast, generateCode } = parse.css(content); - - // since we are prepending all the `AtRule` let's add them in reverse order, - // so they appear in the expected order in the final file - - for (const plugin of plugins) { - if (!options.plugins.includes(plugin.id)) continue; + sv.file( + file.viteConfig, + transforms.script((ast) => { + const vitePluginName = 'tailwindcss'; + js.imports.addDefault(ast, { as: vitePluginName, from: '@tailwindcss/vite' }); + js.vite.addPlugin(ast, { code: `${vitePluginName}()`, mode: 'prepend' }); + }) + ); + + sv.file( + file.stylesheet, + transforms.css((ast) => { + // since we are prepending all the `AtRule` let's add them in reverse order, + // so they appear in the expected order in the final file + + for (const plugin of plugins) { + if (!options.plugins.includes(plugin.id)) continue; + + css.addAtRule(ast, { + name: 'plugin', + params: `'${plugin.package}'`, + append: false + }); + } css.addAtRule(ast, { - name: 'plugin', - params: `'${plugin.package}'`, + name: 'import', + params: `'tailwindcss'`, append: false }); - } - - css.addAtRule(ast, { - name: 'import', - params: `'tailwindcss'`, - append: false - }); - - return generateCode(); - }); + }) + ); if (!kit) { const appSvelte = 'src/App.svelte'; - const stylesheetRelative = files.getRelative({ from: appSvelte, to: files.stylesheet }); - sv.file(appSvelte, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); - js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative }); - return generateCode(); - }); + const stylesheetRelative = file.getRelative({ from: appSvelte, to: file.stylesheet }); + sv.file( + appSvelte, + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); + js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative }); + }) + ); } else { const layoutSvelte = `${kit?.routesDirectory}/+layout.svelte`; - const stylesheetRelative = files.getRelative({ from: layoutSvelte, to: files.stylesheet }); - sv.file(layoutSvelte, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); - js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative }); - - if (content.length === 0) { - const svelteVersion = dependencyVersion('svelte'); - if (!svelteVersion) throw new Error('Failed to determine svelte version'); - svelte.addSlot(ast, { - svelteVersion - }); - } - - return generateCode(); - }); + const stylesheetRelative = file.getRelative({ from: layoutSvelte, to: file.stylesheet }); + sv.file( + layoutSvelte, + transforms.svelte((ast, { language }) => { + const isEmpty = ast.fragment.nodes.length === 0; + svelte.ensureScript(ast, { language }); + js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative }); + + if (isEmpty) { + const svelteVersion = dependencyVersion('svelte'); + if (!svelteVersion) throw new Error('Failed to determine svelte version'); + svelte.addSlot(ast, { + svelteVersion + }); + } + }) + ); } - sv.file(files.vscodeSettings, (content) => { - const { data, generateCode } = parse.json(content); - - data['files.associations'] ??= {}; - data['files.associations']['*.css'] = 'tailwindcss'; - - return generateCode(); - }); - - sv.file(files.vscodeExtensions, (content) => { - const { data, generateCode } = parse.json(content); - json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss'); - return generateCode(); - }); + sv.file( + file.vscodeSettings, + transforms.json((data) => { + data['files.associations'] ??= {}; + data['files.associations']['*.css'] = 'tailwindcss'; + }) + ); + + sv.file( + file.vscodeExtensions, + transforms.json((data) => { + json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss'); + }) + ); if (prettierInstalled) { - sv.file(files.prettierrc, (content) => { - const { data, generateCode } = parse.json(content); - - json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss'); - data.tailwindStylesheet ??= files.getRelative({ to: files.stylesheet }); - - return generateCode(); - }); + sv.file( + file.prettierrc, + transforms.json((data) => { + json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss'); + data.tailwindStylesheet ??= file.getRelative({ to: file.stylesheet }); + }) + ); } } }); diff --git a/packages/sv/src/addons/tests/_setup/suite.ts b/packages/sv/src/addons/tests/_setup/suite.ts index b93ca2e60..eebe41415 100644 --- a/packages/sv/src/addons/tests/_setup/suite.ts +++ b/packages/sv/src/addons/tests/_setup/suite.ts @@ -1,117 +1,4 @@ -import { chromium } from '@playwright/test'; -import { execSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { add } from 'sv'; -import { - createProject, - addPnpmBuildDependencies, - prepareServer, - type AddonTestCase, - type Fixtures, - type SetupTestOptions -} from 'sv/testing'; -import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest'; -import type { AddonMap } from '../../../core/engine.ts'; +import * as vitest from 'vitest'; +import { createSetupTest } from '../../../testing.ts'; -const cwd = inject('testDir'); -const templatesDir = inject('templatesDir'); -const variants = inject('variants'); - -export function setupTest( - addons: Addons, - options?: SetupTestOptions -) { - const test = vitestTest.extend({} as any); - - const withBrowser = options?.browser ?? true; - - let create: ReturnType; - let browser: Awaited>; - - if (withBrowser) { - beforeAll(async () => { - browser = await chromium.launch(); - return async () => { - await browser.close(); - }; - }); - } - - const testCases: Array> = []; - for (const kind of options?.kinds ?? []) { - for (const variant of variants) { - const addonTestCase = { variant, kind }; - if (options?.filter === undefined || options.filter(addonTestCase)) { - testCases.push(addonTestCase); - } - } - } - let testName: string; - test.beforeAll(async (_ctx, suite) => { - testName = path.dirname(suite.file.filepath).split('/').at(-1)!; - - // constructs a builder to create test projects - create = createProject({ cwd, templatesDir, testName }); - - // creates a pnpm workspace in each addon dir - fs.writeFileSync( - path.resolve(cwd, testName, 'pnpm-workspace.yaml'), - "packages:\n - '**/*'", - 'utf8' - ); - - // creates a barebones package.json in each addon dir - fs.writeFileSync( - path.resolve(cwd, testName, 'package.json'), - JSON.stringify({ - name: `${testName}-workspace-root`, - private: true - }) - ); - - for (const addonTestCase of testCases) { - const { variant, kind } = addonTestCase; - const cwd = create({ testId: `${kind.type}-${variant}`, variant }); - - // test metadata - const metaPath = path.resolve(cwd, 'meta.json'); - fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8'); - - if (options?.preAdd) { - await options.preAdd({ addonTestCase, cwd }); - } - const { pnpmBuildDependencies } = await add({ - cwd, - addons, - options: kind.options, - packageManager: 'pnpm' - }); - await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]); - } - - execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' }); - }); - - // runs before each test case - beforeEach(async (ctx) => { - let browserCtx: Awaited>; - if (withBrowser) { - browserCtx = await browser.newContext(); - ctx.page = await browserCtx.newPage(); - } - - ctx.cwd = (addonTestCase) => { - return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`); - }; - - return async () => { - if (withBrowser) { - await browserCtx.close(); - } - // ...other tear downs - }; - }); - - return { test, testCases, prepareServer }; -} +export const setupTest = createSetupTest(vitest); diff --git a/packages/sv/src/addons/vitest-addon.ts b/packages/sv/src/addons/vitest-addon.ts index 50f632980..26f8d0c46 100644 --- a/packages/sv/src/addons/vitest-addon.ts +++ b/packages/sv/src/addons/vitest-addon.ts @@ -1,4 +1,4 @@ -import { color, dedent, js, parse, json } from '@sveltejs/sv-utils'; +import { color, dedent, js, transforms, json } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; const options = defineAddonOptions() @@ -23,7 +23,7 @@ export default defineAddon({ homepage: 'https://vitest.dev', options, - run: ({ sv, files, language, kit, options, dependencyVersion }) => { + run: ({ sv, file, language, kit, options, dependencyVersion }) => { const unitTesting = options.usages.includes('unit'); const componentTesting = options.usages.includes('component'); @@ -40,15 +40,14 @@ export default defineAddon({ sv.devDependency('playwright', '^1.58.2'); } - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - - json.packageScriptsUpsert(data, 'test:unit', 'vitest'); - // we use `--run` so that vitest doesn't run in watch mode when running `npm run test` - json.packageScriptsUpsert(data, 'test', 'npm run test:unit -- --run', { mode: 'prepend' }); - - return generateCode(); - }); + sv.file( + file.package, + transforms.json((data) => { + json.packageScriptsUpsert(data, 'test:unit', 'vitest'); + // we use `--run` so that vitest doesn't run in watch mode when running `npm run test` + json.packageScriptsUpsert(data, 'test', 'npm run test:unit -- --run', { mode: 'prepend' }); + }) + ); const examplesDir = (kit ? kit.libDirectory : 'src/lib') + '/vitest-examples'; const typed = language === 'ts'; @@ -119,67 +118,66 @@ export default defineAddon({ }); } - sv.file(files.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - - const clientObjectExpression = js.object.create({ - extends: `./${files.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: `./${files.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) => { + 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/create.ts b/packages/sv/src/cli/create.ts index 1ea4e63db..d95ac9073 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -263,6 +263,14 @@ async function createProject(cwd: ProjectPath, options: Options) { const parentDirName = path.basename(path.dirname(projectPath)); const projectName = parentDirName.startsWith('@') ? `${parentDirName}/${basename}` : basename; + if (template === 'addon' && !projectName.startsWith('@')) { + // At this stage, we don't support un-scoped add-ons + // FYI: a demo exists for `npx sv add my-cool-addon` + common.errorAndExit( + `Community add-ons must be published under an npm org (e.g. ${color.command('@my-org/sv')}). Unscoped package names are not supported at this stage.` + ); + } + if (template === 'addon' && options.add.length > 0) { common.errorAndExit( `The ${color.command('--add')} flag cannot be used with the ${color.command('addon')} template.` @@ -454,8 +462,8 @@ export async function createVirtualWorkspace({ const virtualWorkspace: Workspace = { ...tentativeWorkspace, language: type === 'typescript' ? 'ts' : 'js', - files: { - ...tentativeWorkspace.files, + file: { + ...tentativeWorkspace.file, viteConfig: type === 'typescript' ? commonFilePaths.viteConfigTS : commonFilePaths.viteConfig, svelteConfig: commonFilePaths.svelteConfig // currently we always use js files, never typescript files } diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index fe56c126f..5cd980ec7 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -123,8 +123,9 @@ describe('cli', () => { // replace sv and sv-utils versions in package.json for tests const packageJsonPath = path.resolve(testOutputPath, 'package.json'); const { data: packageJson } = parse.json(fs.readFileSync(packageJsonPath, 'utf-8')); - packageJson.dependencies['sv'] = 'file:../../../..'; - packageJson.dependencies['@sveltejs/sv-utils'] = 'file:../../../../sv-utils'; + packageJson.peerDependencies['sv'] = 'file:../../../..'; + packageJson.devDependencies['sv'] = 'file:../../../..'; + packageJson.devDependencies['@sveltejs/sv-utils'] = 'file:../../../../sv-utils'; fs.writeFileSync( packageJsonPath, JSON.stringify(packageJson, null, 3).replaceAll(' ', '\t') @@ -135,6 +136,7 @@ describe('cli', () => { ['i'], ['run', 'demo-create'], ['run', 'demo-add:ci'], + ['run', 'build'], ['run', 'test'] ]; for (const cmd of cmds) { diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md b/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md index 20e629e3c..d5da26c73 100644 --- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md +++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md @@ -24,15 +24,25 @@ Your `add-on` should: - export a function that returns a `defineAddon` object. - have a `package.json` with an `exports` field that points to the main entry point of the add-on. -## Sharing your add-on +## Building -When you're ready to publish your add-on to npm, run: +Your add-on is bundled with [tsdown](https://tsdown.dev/) into a single file in `dist/`. This bundles everything except `sv` (which is a peer dependency provided at runtime). -```shell +```sh +npm run build +``` + +## Publishing + +When you're ready to publish your add-on to npm: + +```sh npm login npm publish ``` +> `prepublishOnly` will automatically run the build before publishing. + ## Things to be aware of -Community add-ons are **not permitted** to have any external dependencies outside of `sv`. If the use of a dependency is absolutely necessary, then they can be bundled using a bundler of your choosing (e.g. Rollup, Rolldown, tsup, etc.). +Community add-ons must have `sv` as a `peerDependency` and should **not** have any `dependencies`. Everything else (including `@sveltejs/sv-utils`) is bundled at build time by tsdown. diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json b/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json index b124bfdca..03ae9cd99 100644 --- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json +++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json @@ -8,30 +8,37 @@ "demo-create": "sv create demo --types ts --template minimal --no-add-ons --no-install", "demo-add": "sv add file:../ --cwd demo --no-git-check --no-install", "demo-add:ci": "sv add file:../=who:you --cwd demo --no-git-check --no-download-check --no-install", + "build": "tsdown", + "prepublishOnly": "npm run build", "test": "vitest run" }, "files": [ - "src", - "!src/**/*.test.*" + "dist" ], "exports": { ".": { "default": "./src/index.js" } }, - "dependencies": { - "@sveltejs/sv-utils": "latest", + "publishConfig": { + "access": "public", + "exports": { + ".": { + "default": "./dist/index.js" + } + } + }, + "peerDependencies": { "sv": "latest" }, "devDependencies": { "@playwright/test": "^1.58.2", + "@sveltejs/sv-utils": "latest", + "sv": "latest", + "tsdown": "^0.21.4", "vitest": "^4.1.0" }, "keywords": [ "sv-add" - ], - "publishConfig": { - "directory": "dist", - "access": "public" - } + ] } 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 bf1d89251..8fcc274ff 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 { js, transforms, svelte } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from 'sv'; const options = defineAddonOptions() @@ -17,37 +17,37 @@ export default defineAddon({ if (!kit) unsupported('Requires SvelteKit'); }, - run: ({ kit, sv, options, language, cancel }) => { + run: ({ kit, sv, options, cancel }) => { if (!kit) return cancel('SvelteKit is required'); sv.file(`src/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(`src/lib/@my-org/sv/HelloComponent.svelte`, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); + sv.file( + `src/lib/@my-org/sv/HelloComponent.svelte`, + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); - js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' }); + js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' }); - svelte.addFragment(ast, '

{content}

'); - svelte.addFragment(ast, `

Hello ${options.who}!

`); + svelte.addFragment(ast, '

{content}

'); + svelte.addFragment(ast, `

Hello ${options.who}!

`); + }) + ); - return generateCode(); - }); - - sv.file(kit.routesDirectory + '/+page.svelte', (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); + sv.file( + kit.routesDirectory + '/+page.svelte', + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); - js.imports.addDefault(ast.instance.content, { - as: 'HelloComponent', - from: `$lib/@my-org/sv/HelloComponent.svelte` - }); + js.imports.addDefault(ast.instance.content, { + as: 'HelloComponent', + from: `$lib/@my-org/sv/HelloComponent.svelte` + }); - svelte.addFragment(ast, ''); - - return generateCode(); - }); + svelte.addFragment(ast, ''); + }) + ); } }); diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js index 8749c9916..7622928fe 100644 --- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js +++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js @@ -1,129 +1,4 @@ -import { chromium } from '@playwright/test'; -import { execSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { add } from 'sv'; -import { createProject, addPnpmBuildDependencies, prepareServer } from 'sv/testing'; -import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest'; +import { createSetupTest } from 'sv/testing'; +import * as vitest from 'vitest'; -const cwd = inject('testDir'); -const templatesDir = inject('templatesDir'); -const variants = inject('variants'); - -/** - * @template {import('sv').AddonMap} AddonMap - * @param {AddonMap} addons - * @param {import('sv/testing').SetupTestOptions} [options] - * @returns {{ test: ReturnType>, testCases: Array>, prepareServer: typeof prepareServer }} - */ -export function setupTest(addons, options) { - /** @type {ReturnType>} */ - // @ts-ignore - vitest.extend expects fixtures object but we provide it in beforeEach - const test = vitestTest.extend({}); - - const withBrowser = options?.browser ?? true; - - /** @type {ReturnType} */ - let create; - /** @type {Awaited>} */ - let browser; - - if (withBrowser) { - beforeAll(async () => { - browser = await chromium.launch(); - return async () => { - await browser.close(); - }; - }); - } - - /** @type {Array>} */ - const testCases = []; - for (const kind of options?.kinds ?? []) { - for (const variant of variants) { - const addonTestCase = { variant, kind }; - if (options?.filter === undefined || options.filter(addonTestCase)) { - testCases.push(addonTestCase); - } - } - } - /** @type {string} */ - let testName; - test.beforeAll(async (_ctx, suite) => { - testName = path.dirname(suite.name).split('/').at(-1) ?? ''; - - // constructs a builder to create test projects - create = createProject({ cwd, templatesDir, testName }); - - // creates a pnpm workspace in each addon dir - fs.writeFileSync( - path.resolve(cwd, testName, 'pnpm-workspace.yaml'), - "packages:\n - '**/*'", - 'utf8' - ); - - // creates a barebones package.json in each addon dir - fs.writeFileSync( - path.resolve(cwd, testName, 'package.json'), - JSON.stringify({ - name: `${testName}-workspace-root`, - private: true - }) - ); - - for (const addonTestCase of testCases) { - const { variant, kind } = addonTestCase; - const cwd = create({ testId: `${kind.type}-${variant}`, variant }); - - // test metadata - const metaPath = path.resolve(cwd, 'meta.json'); - fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8'); - - if (options?.preAdd) { - await options.preAdd({ addonTestCase, cwd }); - } - const { pnpmBuildDependencies } = await add({ - cwd, - addons, - options: kind.options, - packageManager: 'pnpm' - }); - await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]); - } - - execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' }); - }); - - // runs before each test case - /** - * @param {import('sv/testing').Fixtures & import('vitest').TestContext} ctx - */ - beforeEach(async (ctx) => { - /** @type {Awaited>} */ - let browserCtx; - if (withBrowser) { - browserCtx = await browser.newContext(); - /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).page = - await browserCtx.newPage(); - } - - /** - * @param {import('sv/testing').AddonTestCase} addonTestCase - * @returns {string} - */ - /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).cwd = ( - addonTestCase - ) => { - return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`); - }; - - return async () => { - if (withBrowser) { - await browserCtx.close(); - } - // ...other tear downs - }; - }); - - return { test, testCases, prepareServer }; -} +export const setupTest = createSetupTest(vitest); diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js new file mode 100644 index 000000000..7f45e3cce --- /dev/null +++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.js'], + format: 'esm' +}); diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts index 5ebd7fd0c..4a76004c8 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -1,3 +1,4 @@ +import type { TransformFn } from '@sveltejs/sv-utils'; import type { officialAddons } from '../addons/index.ts'; import type { OptionDefinition, OptionValues, Question } from './options.ts'; import type { Workspace, WorkspaceOptions } from './workspace.ts'; @@ -29,8 +30,26 @@ export type SvApi = { devDependency: (pkg: string, version: string) => void; /** Execute a command in the workspace. */ execute: (args: string[], stdio: 'inherit' | 'pipe') => Promise; - /** Edit a file in the workspace. (will create it if it doesn't exist) */ - file: (path: string, edit: (content: string) => string) => void; + /** Edit a file in the workspace. (will create it if it doesn't exist) + * + * Accepts either a raw edit function or a typed transform from `@sveltejs/sv-utils`. + * When using a transform, the engine automatically injects workspace context (language, etc.). + * + * @example + * ```ts + * // Using a typed transform (recommended) + * sv.file(files.viteConfig, transforms.script((ast) => { + * js.vite.addPlugin(ast, { code: 'kitRoutes()' }); + * })); + * + * // Using a raw edit function + * sv.file('.env', (content) => content + '\nKEY=value'); + * ``` + */ + file: { + (path: string, edit: TransformFn): void; + (path: string, edit: (content: string) => string): void; + }; }; export type Addon = { diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 0d081a737..672e4f463 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts'; -import { color, resolveCommand, type AgentName } from '@sveltejs/sv-utils'; +import { color, isTransform, resolveCommand, type AgentName } from '@sveltejs/sv-utils'; import { NonZeroExitError, exec } from 'tinyexec'; import { createLoadedAddon } from '../cli/add.ts'; import { @@ -171,12 +171,14 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } const dependencies: Array<{ pkg: string; version: string; dev: boolean }> = []; const pnpmBuildDependencies: string[] = []; const sv: SvApi = { - file: (path, content) => { + file: (path, edit) => { try { const exists = fileExists(workspace.cwd, path); let fileContent = exists ? readFile(workspace.cwd, path) : ''; - // process file - fileContent = content(fileContent); + // process file — inject workspace context for typed transforms + fileContent = isTransform(edit) + ? edit(fileContent, { language: workspace.language }) + : edit(fileContent); if (!fileContent) return fileContent; writeFile(workspace, path, fileContent); diff --git a/packages/sv/src/core/fetch-packages.ts b/packages/sv/src/core/fetch-packages.ts index 02e240c68..0952a72a8 100644 --- a/packages/sv/src/core/fetch-packages.ts +++ b/packages/sv/src/core/fetch-packages.ts @@ -14,22 +14,21 @@ import type { AddonDefinition, AddonReference } from './config.ts'; const NODE_MODULES = fileURLToPath(new URL('../../node_modules', import.meta.url)); function verifyPackage(addonPkg: Record, specifier: string): string | undefined { - // We should look only for dependencies, not devDependencies or peerDependencies + const peerDeps = { ...addonPkg.peerDependencies }; const deps = { ...addonPkg.dependencies }; - // valid addons should always have a dependency on `sv` - const addonSvVersion = deps['sv']; + // valid addons should always have `sv` as a peerDependency + const addonSvVersion = peerDeps['sv']; if (!addonSvVersion) { throw new Error( - `Invalid add-on package specified: '${specifier}' is missing a dependency on 'sv' in its 'package.json'` + `Invalid add-on package specified: '${specifier}' is missing 'sv' in its 'peerDependencies'` ); } - // addons should never have any external dependencies outside of `sv` and `@sveltejs/sv-utils` - for (const dep of Object.keys(deps)) { - if (dep === 'sv' || dep === '@sveltejs/sv-utils') continue; + // addons should not have any dependencies (everything should be bundled) + if (Object.keys(deps).length > 0) { throw new Error( - `Invalid add-on package detected: '${specifier}'\nCommunity addons should not have any external 'dependencies' besides 'sv'. Consider bundling your dependencies if they are necessary` + `Invalid add-on package detected: '${specifier}'\nCommunity add-ons should not have any 'dependencies'. Use 'peerDependencies' for 'sv' and bundle everything else` ); } diff --git a/packages/sv/src/core/workspace.ts b/packages/sv/src/core/workspace.ts index 7c0e6717e..9ee6422c8 100644 --- a/packages/sv/src/core/workspace.ts +++ b/packages/sv/src/core/workspace.ts @@ -21,7 +21,7 @@ export type Workspace = { dependencyVersion: (pkg: string) => string | undefined; /** to know if the workspace is using typescript or javascript */ language: 'ts' | 'js'; - files: { + file: { viteConfig: 'vite.config.js' | 'vite.config.ts'; svelteConfig: 'svelte.config.js' | 'svelte.config.ts'; /** `${kit.routesDirectory}/layout.css` or `src/app.css` */ @@ -119,7 +119,7 @@ export async function createWorkspace({ cwd: resolvedCwd, packageManager: packageManager ?? (await detectPackageManager(cwd)), language: typescript ? 'ts' : 'js', - files: { + file: { viteConfig, svelteConfig, stylesheet, diff --git a/packages/sv/src/create/shared/+addon/CONTRIBUTING.md b/packages/sv/src/create/shared/+addon/CONTRIBUTING.md index 20e629e3c..d5da26c73 100644 --- a/packages/sv/src/create/shared/+addon/CONTRIBUTING.md +++ b/packages/sv/src/create/shared/+addon/CONTRIBUTING.md @@ -24,15 +24,25 @@ Your `add-on` should: - export a function that returns a `defineAddon` object. - have a `package.json` with an `exports` field that points to the main entry point of the add-on. -## Sharing your add-on +## Building -When you're ready to publish your add-on to npm, run: +Your add-on is bundled with [tsdown](https://tsdown.dev/) into a single file in `dist/`. This bundles everything except `sv` (which is a peer dependency provided at runtime). -```shell +```sh +npm run build +``` + +## Publishing + +When you're ready to publish your add-on to npm: + +```sh npm login npm publish ``` +> `prepublishOnly` will automatically run the build before publishing. + ## Things to be aware of -Community add-ons are **not permitted** to have any external dependencies outside of `sv`. If the use of a dependency is absolutely necessary, then they can be bundled using a bundler of your choosing (e.g. Rollup, Rolldown, tsup, etc.). +Community add-ons must have `sv` as a `peerDependency` and should **not** have any `dependencies`. Everything else (including `@sveltejs/sv-utils`) is bundled at build time by tsdown. diff --git a/packages/sv/src/create/templates/addon/package.template.json b/packages/sv/src/create/templates/addon/package.template.json index 5e4bcf870..cfb672e72 100644 --- a/packages/sv/src/create/templates/addon/package.template.json +++ b/packages/sv/src/create/templates/addon/package.template.json @@ -8,26 +8,34 @@ "demo-create": "sv create demo --types ts --template minimal --no-add-ons --no-install", "demo-add": "sv add file:../ --cwd demo --no-git-check --no-install", "demo-add:ci": "sv add file:../=who:you --cwd demo --no-git-check --no-download-check --no-install", + "build": "tsdown", + "prepublishOnly": "npm run build", "test": "vitest run" }, - "files": ["src", "!src/**/*.test.*"], + "files": ["dist"], "exports": { ".": { "default": "./src/index.js" } }, - "dependencies": { - "@sveltejs/sv-utils": "workspace:*", + "publishConfig": { + "access": "public", + "exports": { + ".": { + "default": "./dist/index.js" + } + } + }, + "peerDependencies": { "sv": "workspace:*" }, "devDependencies": { + "sv": "workspace:*", + "@sveltejs/sv-utils": "workspace:*", "@playwright/test": "^1.58.2", "@types/node": "^25.2.1", + "tsdown": "^0.21.4", "vitest": "^4.1.0" }, - "keywords": ["sv-add"], - "publishConfig": { - "directory": "dist", - "access": "public" - } + "keywords": ["sv-add"] } diff --git a/packages/sv/src/create/templates/addon/src/index.js b/packages/sv/src/create/templates/addon/src/index.js index 889aab0af..149210413 100644 --- a/packages/sv/src/create/templates/addon/src/index.js +++ b/packages/sv/src/create/templates/addon/src/index.js @@ -1,4 +1,4 @@ -import { js, parse, svelte } from '@sveltejs/sv-utils'; +import { js, transforms, svelte } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from 'sv'; const options = defineAddonOptions() @@ -17,37 +17,37 @@ export default defineAddon({ if (!kit) unsupported('Requires SvelteKit'); }, - run: ({ kit, sv, options, language, cancel }) => { + run: ({ kit, sv, options, cancel }) => { if (!kit) return cancel('SvelteKit is required'); sv.file(`src/lib/~SV-NAME-TODO~/content.txt`, () => { return `This is a text file made by the Community Addon Template demo for the add-on: '~SV-NAME-TODO~'!`; }); - sv.file(`src/lib/~SV-NAME-TODO~/HelloComponent.svelte`, (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); + sv.file( + `src/lib/~SV-NAME-TODO~/HelloComponent.svelte`, + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); - js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' }); + js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' }); - svelte.addFragment(ast, '

{content}

'); - svelte.addFragment(ast, `

Hello ${options.who}!

`); + svelte.addFragment(ast, '

{content}

'); + svelte.addFragment(ast, `

Hello ${options.who}!

`); + }) + ); - return generateCode(); - }); - - sv.file(kit.routesDirectory + '/+page.svelte', (content) => { - const { ast, generateCode } = parse.svelte(content); - svelte.ensureScript(ast, { language }); + sv.file( + kit.routesDirectory + '/+page.svelte', + transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); - js.imports.addDefault(ast.instance.content, { - as: 'HelloComponent', - from: `$lib/~SV-NAME-TODO~/HelloComponent.svelte` - }); + js.imports.addDefault(ast.instance.content, { + as: 'HelloComponent', + from: `$lib/~SV-NAME-TODO~/HelloComponent.svelte` + }); - svelte.addFragment(ast, ''); - - return generateCode(); - }); + svelte.addFragment(ast, ''); + }) + ); } }); diff --git a/packages/sv/src/create/templates/addon/tests/setup/suite.js b/packages/sv/src/create/templates/addon/tests/setup/suite.js index 8749c9916..7622928fe 100644 --- a/packages/sv/src/create/templates/addon/tests/setup/suite.js +++ b/packages/sv/src/create/templates/addon/tests/setup/suite.js @@ -1,129 +1,4 @@ -import { chromium } from '@playwright/test'; -import { execSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; -import { add } from 'sv'; -import { createProject, addPnpmBuildDependencies, prepareServer } from 'sv/testing'; -import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest'; +import { createSetupTest } from 'sv/testing'; +import * as vitest from 'vitest'; -const cwd = inject('testDir'); -const templatesDir = inject('templatesDir'); -const variants = inject('variants'); - -/** - * @template {import('sv').AddonMap} AddonMap - * @param {AddonMap} addons - * @param {import('sv/testing').SetupTestOptions} [options] - * @returns {{ test: ReturnType>, testCases: Array>, prepareServer: typeof prepareServer }} - */ -export function setupTest(addons, options) { - /** @type {ReturnType>} */ - // @ts-ignore - vitest.extend expects fixtures object but we provide it in beforeEach - const test = vitestTest.extend({}); - - const withBrowser = options?.browser ?? true; - - /** @type {ReturnType} */ - let create; - /** @type {Awaited>} */ - let browser; - - if (withBrowser) { - beforeAll(async () => { - browser = await chromium.launch(); - return async () => { - await browser.close(); - }; - }); - } - - /** @type {Array>} */ - const testCases = []; - for (const kind of options?.kinds ?? []) { - for (const variant of variants) { - const addonTestCase = { variant, kind }; - if (options?.filter === undefined || options.filter(addonTestCase)) { - testCases.push(addonTestCase); - } - } - } - /** @type {string} */ - let testName; - test.beforeAll(async (_ctx, suite) => { - testName = path.dirname(suite.name).split('/').at(-1) ?? ''; - - // constructs a builder to create test projects - create = createProject({ cwd, templatesDir, testName }); - - // creates a pnpm workspace in each addon dir - fs.writeFileSync( - path.resolve(cwd, testName, 'pnpm-workspace.yaml'), - "packages:\n - '**/*'", - 'utf8' - ); - - // creates a barebones package.json in each addon dir - fs.writeFileSync( - path.resolve(cwd, testName, 'package.json'), - JSON.stringify({ - name: `${testName}-workspace-root`, - private: true - }) - ); - - for (const addonTestCase of testCases) { - const { variant, kind } = addonTestCase; - const cwd = create({ testId: `${kind.type}-${variant}`, variant }); - - // test metadata - const metaPath = path.resolve(cwd, 'meta.json'); - fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8'); - - if (options?.preAdd) { - await options.preAdd({ addonTestCase, cwd }); - } - const { pnpmBuildDependencies } = await add({ - cwd, - addons, - options: kind.options, - packageManager: 'pnpm' - }); - await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]); - } - - execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' }); - }); - - // runs before each test case - /** - * @param {import('sv/testing').Fixtures & import('vitest').TestContext} ctx - */ - beforeEach(async (ctx) => { - /** @type {Awaited>} */ - let browserCtx; - if (withBrowser) { - browserCtx = await browser.newContext(); - /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).page = - await browserCtx.newPage(); - } - - /** - * @param {import('sv/testing').AddonTestCase} addonTestCase - * @returns {string} - */ - /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).cwd = ( - addonTestCase - ) => { - return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`); - }; - - return async () => { - if (withBrowser) { - await browserCtx.close(); - } - // ...other tear downs - }; - }); - - return { test, testCases, prepareServer }; -} +export const setupTest = createSetupTest(vitest); diff --git a/packages/sv/src/create/templates/addon/tsdown.config.js b/packages/sv/src/create/templates/addon/tsdown.config.js new file mode 100644 index 000000000..7f45e3cce --- /dev/null +++ b/packages/sv/src/create/templates/addon/tsdown.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['src/index.js'], + format: 'esm' +}); diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts index b7bf5564d..cab3e45f2 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -6,10 +6,11 @@ import process from 'node:process'; import pstree, { type PS } from 'ps-tree'; import { exec, x } from 'tinyexec'; import type { TestProject } from 'vitest/node'; -import type { AddonMap, OptionMap } from './core/engine.ts'; +import { add, type AddonMap, type OptionMap } from './core/engine.ts'; +import { addPnpmBuildDependencies } from './core/package-manager.ts'; import { create } from './create/index.ts'; -export { addPnpmBuildDependencies } from './core/package-manager.ts'; +export { addPnpmBuildDependencies }; export type ProjectVariant = 'kit-js' | 'kit-ts' | 'vite-js' | 'vite-ts'; export const variants: ProjectVariant[] = ['kit-js', 'kit-ts', 'vite-js', 'vite-ts']; @@ -237,3 +238,124 @@ export async function prepareServer({ return { url, close }; } + +export type VitestContext = Pick< + typeof import('vitest'), + 'inject' | 'test' | 'beforeAll' | 'beforeEach' +>; + +export function createSetupTest(vitest: VitestContext): ( + addons: Addons, + options?: SetupTestOptions +) => { + test: import('vitest').TestAPI; + testCases: Array>; + prepareServer: typeof prepareServer; +} { + return function setupTest( + addons: Addons, + options?: SetupTestOptions + ) { + const { inject, test: vitestTest, beforeAll, beforeEach } = vitest; + + const test = vitestTest.extend({}) as unknown as import('vitest').TestAPI; + + const cwd = inject('testDir'); + const templatesDir = inject('templatesDir'); + const variants = inject('variants'); + + const withBrowser = options?.browser ?? true; + + let create: ReturnType; + let browser: Awaited>; + + if (withBrowser) { + beforeAll(async () => { + let chromium: Awaited['chromium']; + try { + ({ chromium } = await import('@playwright/test')); + } catch { + throw new Error( + 'Browser testing requires @playwright/test. Install it with: pnpm add -D @playwright/test' + ); + } + browser = await chromium.launch(); + return async () => { + await browser.close(); + }; + }); + } + + const testCases: Array> = []; + for (const kind of options?.kinds ?? []) { + for (const variant of variants) { + const addonTestCase = { variant, kind }; + if (options?.filter === undefined || options.filter(addonTestCase)) { + testCases.push(addonTestCase); + } + } + } + + let testName: string; + test.beforeAll(async (_ctx, suite) => { + testName = path.dirname(suite.name).split('/').at(-1) ?? ''; + + create = createProject({ cwd, templatesDir, testName }); + + fs.writeFileSync( + path.resolve(cwd, testName, 'pnpm-workspace.yaml'), + "packages:\n - '**/*'", + 'utf8' + ); + + fs.writeFileSync( + path.resolve(cwd, testName, 'package.json'), + JSON.stringify({ + name: `${testName}-workspace-root`, + private: true + }) + ); + + for (const addonTestCase of testCases) { + const { variant, kind } = addonTestCase; + const cwd = create({ testId: `${kind.type}-${variant}`, variant }); + + const metaPath = path.resolve(cwd, 'meta.json'); + fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8'); + + if (options?.preAdd) { + await options.preAdd({ addonTestCase, cwd }); + } + const { pnpmBuildDependencies } = await add({ + cwd, + addons, + options: kind.options, + packageManager: 'pnpm' + }); + await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]); + } + + execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' }); + }); + + beforeEach(async (ctx) => { + let browserCtx: Awaited>; + if (withBrowser) { + browserCtx = await browser.newContext(); + (ctx as unknown as Fixtures).page = await browserCtx.newPage(); + } + + (ctx as unknown as Fixtures).cwd = (addonTestCase) => { + return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`); + }; + + return async () => { + if (withBrowser) { + await browserCtx.close(); + } + }; + }); + + return { test, testCases, prepareServer }; + }; +}