From 79bab7656d77f643717e5c00c3bee1be5be7ce6c Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 18:10:35 +0100 Subject: [PATCH 01/17] rename workspace `files` to `file` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Singular form is more consistent — `file.viteConfig` reads better than `files.viteConfig` since each property refers to a single file. Prep for community add-on improvements. --- packages/sv/src/addons/better-auth.ts | 4 ++-- packages/sv/src/addons/devtools-json.ts | 4 ++-- packages/sv/src/addons/drizzle.ts | 8 ++++---- packages/sv/src/addons/eslint.ts | 10 +++++----- packages/sv/src/addons/mdsvex.ts | 4 ++-- packages/sv/src/addons/paraglide.ts | 6 +++--- packages/sv/src/addons/playwright.ts | 6 +++--- packages/sv/src/addons/prettier.ts | 14 +++++++------- packages/sv/src/addons/sveltekit-adapter.ts | 12 ++++++------ packages/sv/src/addons/tailwindcss.ts | 18 +++++++++--------- packages/sv/src/addons/vitest-addon.ts | 10 +++++----- packages/sv/src/cli/create.ts | 4 ++-- packages/sv/src/core/workspace.ts | 4 ++-- 13 files changed, 52 insertions(+), 52 deletions(-) diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index f38dc58f3..60ee914ac 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -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'); @@ -168,7 +168,7 @@ export default defineAddon({ const authConfigPath = `${kit?.libDirectory}/server/auth.${language}`; const authSchemaPath = `${kit?.libDirectory}/server/db/auth.schema.${language}`; - sv.file(files.package, (content) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); json.packageScriptsUpsert( data, diff --git a/packages/sv/src/addons/devtools-json.ts b/packages/sv/src/addons/devtools-json.ts index e4d8dc46a..156edd16a 100644 --- a/packages/sv/src/addons/devtools-json.ts +++ b/packages/sv/src/addons/devtools-json.ts @@ -7,11 +7,11 @@ 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) => { + sv.file(file.viteConfig, (content) => { const { ast, generateCode } = parse.script(content); const vitePluginName = 'devtoolsJson'; diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 9a242e1be..ae0137b10 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -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,7 +186,7 @@ export default defineAddon({ }); } - sv.file(files.package, (content) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); if (options.docker) json.packageScriptsUpsert(data, 'db:start', 'docker compose up'); @@ -200,13 +200,13 @@ export default defineAddon({ 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' }); }); diff --git a/packages/sv/src/addons/eslint.ts b/packages/sv/src/addons/eslint.ts index f57eec50f..feea56524 100644 --- a/packages/sv/src/addons/eslint.ts +++ b/packages/sv/src/addons/eslint.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,7 +23,7 @@ export default defineAddon({ if (prettierInstalled) sv.devDependency('eslint-config-prettier', '^10.1.8'); - sv.file(files.package, (content) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); json.packageScriptsUpsert(data, 'lint', 'eslint .'); @@ -31,7 +31,7 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.eslintConfig, (content) => { + sv.file(file.eslintConfig, (content) => { const { ast, comments, generateCode } = parse.script(content); const eslintConfigs: Array = []; @@ -140,14 +140,14 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.vscodeExtensions, (content) => { + sv.file(file.vscodeExtensions, (content) => { const { data, generateCode } = parse.json(content); json.arrayUpsert(data, 'recommendations', 'dbaeumer.vscode-eslint'); return generateCode(); }); if (prettierInstalled) { - sv.file(files.eslintConfig, addEslintConfigPrettier); + sv.file(file.eslintConfig, addEslintConfigPrettier); } } }); diff --git a/packages/sv/src/addons/mdsvex.ts b/packages/sv/src/addons/mdsvex.ts index 30583f07a..644b30c6e 100644 --- a/packages/sv/src/addons/mdsvex.ts +++ b/packages/sv/src/addons/mdsvex.ts @@ -6,10 +6,10 @@ 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) => { + sv.file(file.svelteConfig, (content) => { const { ast, generateCode } = parse.script(content); js.imports.addNamed(ast, { from: 'mdsvex', imports: ['mdsvex'] }); diff --git a/packages/sv/src/addons/paraglide.ts b/packages/sv/src/addons/paraglide.ts index 6bcde3224..143216076 100644 --- a/packages/sv/src/addons/paraglide.ts +++ b/packages/sv/src/addons/paraglide.ts @@ -53,7 +53,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,7 +61,7 @@ export default defineAddon({ sv.devDependency('@inlang/paraglide-js', '^2.10.0'); // add the vite plugin - sv.file(files.viteConfig, (content) => { + sv.file(file.viteConfig, (content) => { const { ast, generateCode } = parse.script(content); const vitePluginName = 'paraglideVitePlugin'; @@ -152,7 +152,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' }); diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts index 75216fe83..83b86cf2f 100644 --- a/packages/sv/src/addons/playwright.ts +++ b/packages/sv/src/addons/playwright.ts @@ -8,10 +8,10 @@ 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) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); json.packageScriptsUpsert(data, 'test:e2e', 'playwright test'); @@ -20,7 +20,7 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.gitignore, (content) => { + sv.file(file.gitignore, (content) => { if (!content) return content; return text.upsert(content, 'test-results', { comment: 'Playwright' }); }); diff --git a/packages/sv/src/addons/prettier.ts b/packages/sv/src/addons/prettier.ts index c1c7d307a..6d1af4a2a 100644 --- a/packages/sv/src/addons/prettier.ts +++ b/packages/sv/src/addons/prettier.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,7 +68,7 @@ export default defineAddon({ const eslintVersion = dependencyVersion('eslint'); const eslintInstalled = hasEslint(eslintVersion); - sv.file(files.package, (content) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); json.packageScriptsUpsert(data, 'lint', 'prettier --check .', { mode: 'prepend' }); @@ -77,7 +77,7 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.vscodeExtensions, (content) => { + sv.file(file.vscodeExtensions, (content) => { const { data, generateCode } = parse.json(content); json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode'); return generateCode(); @@ -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..c2e5a7ae7 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -41,11 +41,11 @@ 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) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); const devDeps = data['devDependencies']; @@ -69,7 +69,7 @@ export default defineAddon({ sv.devDependency(adapter.package, adapter.version); - sv.file(files.svelteConfig, (content) => { + sv.file(file.svelteConfig, (content) => { const { ast, comments, generateCode } = parse.script(content); // finds any existing adapter's import declaration @@ -136,7 +136,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 +172,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,7 +180,7 @@ export default defineAddon({ }); // Setup wrangler types command - sv.file(files.package, (content) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); json.packageScriptsUpsert(data, 'gen', 'wrangler types'); diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts index 00941ab25..9cb63d0d5 100644 --- a/packages/sv/src/addons/tailwindcss.ts +++ b/packages/sv/src/addons/tailwindcss.ts @@ -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, language }) => { const prettierInstalled = Boolean(dependencyVersion('prettier')); sv.devDependency('tailwindcss', '^4.1.18'); @@ -46,7 +46,7 @@ export default defineAddon({ } // add the vite plugin - sv.file(files.viteConfig, (content) => { + sv.file(file.viteConfig, (content) => { const { ast, generateCode } = parse.script(content); const vitePluginName = 'tailwindcss'; @@ -56,7 +56,7 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.stylesheet, (content) => { + sv.file(file.stylesheet, (content) => { const { ast, generateCode } = parse.css(content); // since we are prepending all the `AtRule` let's add them in reverse order, @@ -83,7 +83,7 @@ export default defineAddon({ if (!kit) { const appSvelte = 'src/App.svelte'; - const stylesheetRelative = files.getRelative({ from: appSvelte, to: files.stylesheet }); + const stylesheetRelative = file.getRelative({ from: appSvelte, to: file.stylesheet }); sv.file(appSvelte, (content) => { const { ast, generateCode } = parse.svelte(content); svelte.ensureScript(ast, { language }); @@ -92,7 +92,7 @@ export default defineAddon({ }); } else { const layoutSvelte = `${kit?.routesDirectory}/+layout.svelte`; - const stylesheetRelative = files.getRelative({ from: layoutSvelte, to: files.stylesheet }); + const stylesheetRelative = file.getRelative({ from: layoutSvelte, to: file.stylesheet }); sv.file(layoutSvelte, (content) => { const { ast, generateCode } = parse.svelte(content); svelte.ensureScript(ast, { language }); @@ -110,7 +110,7 @@ export default defineAddon({ }); } - sv.file(files.vscodeSettings, (content) => { + sv.file(file.vscodeSettings, (content) => { const { data, generateCode } = parse.json(content); data['files.associations'] ??= {}; @@ -119,18 +119,18 @@ export default defineAddon({ return generateCode(); }); - sv.file(files.vscodeExtensions, (content) => { + sv.file(file.vscodeExtensions, (content) => { const { data, generateCode } = parse.json(content); json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss'); return generateCode(); }); if (prettierInstalled) { - sv.file(files.prettierrc, (content) => { + sv.file(file.prettierrc, (content) => { const { data, generateCode } = parse.json(content); json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss'); - data.tailwindStylesheet ??= files.getRelative({ to: files.stylesheet }); + data.tailwindStylesheet ??= file.getRelative({ to: file.stylesheet }); return generateCode(); }); diff --git a/packages/sv/src/addons/vitest-addon.ts b/packages/sv/src/addons/vitest-addon.ts index 50f632980..68948abf7 100644 --- a/packages/sv/src/addons/vitest-addon.ts +++ b/packages/sv/src/addons/vitest-addon.ts @@ -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,7 +40,7 @@ export default defineAddon({ sv.devDependency('playwright', '^1.58.2'); } - sv.file(files.package, (content) => { + sv.file(file.package, (content) => { const { data, generateCode } = parse.json(content); json.packageScriptsUpsert(data, 'test:unit', 'vitest'); @@ -119,11 +119,11 @@ export default defineAddon({ }); } - sv.file(files.viteConfig, (content) => { + sv.file(file.viteConfig, (content) => { const { ast, generateCode } = parse.script(content); const clientObjectExpression = js.object.create({ - extends: `./${files.viteConfig}`, + extends: `./${file.viteConfig}`, test: { name: 'client', browser: { @@ -137,7 +137,7 @@ export default defineAddon({ }); const serverObjectExpression = js.object.create({ - extends: `./${files.viteConfig}`, + extends: `./${file.viteConfig}`, test: { name: 'server', environment: 'node', diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 1ea4e63db..40c272e65 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -454,8 +454,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/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, From 7c061235e92e0031b3a53ff3359ce7ab5a383fb7 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 18:12:19 +0100 Subject: [PATCH 02/17] extract `createSetupTest` from internal test suite Moves the setupTest boilerplate into `sv/testing` as a reusable `createSetupTest(vitest)` factory. Internal tests now consume it the same way community add-ons will. Prep for community add-on improvements. --- packages/sv/src/addons/tests/_setup/suite.ts | 119 +----------------- packages/sv/src/testing.ts | 124 ++++++++++++++++++- 2 files changed, 126 insertions(+), 117 deletions(-) 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/testing.ts b/packages/sv/src/testing.ts index b7bf5564d..14bfb309a 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -6,7 +6,8 @@ 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'; @@ -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.file.filepath).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.page = await browserCtx.newPage(); + } + + ctx.cwd = (addonTestCase) => { + return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`); + }; + + return async () => { + if (withBrowser) { + await browserCtx.close(); + } + }; + }); + + return { test, testCases, prepareServer }; + }; +} From b24d394956a266a8f3595b8158d9f2030067acb7 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 18:22:25 +0100 Subject: [PATCH 03/17] update addon template and snapshot to use createSetupTest --- .../snapshots/@my-org/sv/tests/setup/suite.js | 131 +----------------- .../templates/addon/tests/setup/suite.js | 131 +----------------- 2 files changed, 6 insertions(+), 256 deletions(-) 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/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); From aafd1c2d4e775214538890fe49ca5c4a75123cdc Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 18:49:00 +0100 Subject: [PATCH 04/17] add `transforms` API to sv-utils Typed, parser-aware `string -> string` functions that wrap parse -> callback(ast/data) -> generateCode(). Includes transforms for script, svelte, css, json, yaml, toml, html, and text. The engine detects transforms via `isTransform()` and injects workspace context (language) automatically. --- packages/sv-utils/src/index.ts | 14 +- packages/sv-utils/src/tooling/transforms.ts | 200 ++++++++++++++++++++ packages/sv/src/core/config.ts | 12 +- packages/sv/src/core/engine.ts | 10 +- 4 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 packages/sv-utils/src/tooling/transforms.ts diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index 13e736a88..7829d7ac1 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -28,8 +28,20 @@ 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. + * 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. * Then manipulate the `ast` as you want, * and finally `generateCode()` to write it back to the file. * diff --git a/packages/sv-utils/src/tooling/transforms.ts b/packages/sv-utils/src/tooling/transforms.ts new file mode 100644 index 000000000..9a0756d4b --- /dev/null +++ b/packages/sv-utils/src/tooling/transforms.ts @@ -0,0 +1,200 @@ +import type { TomlTable } from 'smol-toml'; +import type { Comments, SvelteAst } from './index.ts'; +import type { TsEstree } from './js/ts-estree.ts'; +import { + parseCss, + parseHtml, + 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' | 'html'; + +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 an HTML file (e.g. app.html). + * + * Return `false` from the callback to abort — the original content is returned unchanged. + */ + html( + cb: (ast: SvelteAst.Fragment, ctx: TransformContext) => void | false + ): TransformFn { + const fn = ((content: string, ctx?: TransformContext) => { + const { ast, generateCode } = parseHtml(content); + const result = cb(ast, ctx ?? { language: 'ts' }); + if (result === false) return content; + return generateCode(); + }) as TransformFn; + fn[TRANSFORM_KEY] = 'html'; + 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/core/config.ts b/packages/sv/src/core/config.ts index 5ebd7fd0c..1cae858f4 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,15 @@ 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.). + */ + 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); From 3e3760cffb8c37f6b66e32b31d2c3527cc486ee6 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 18:55:16 +0100 Subject: [PATCH 05/17] migrate all addons from `parse` to `transforms` Mechanical migration: every `sv.file()` callback that followed the parse -> mutate -> generateCode pattern now uses the typed transform. `parse` is retained only where transforms don't fit: - prettier.ts: try/catch around JSON parsing - sveltekit-adapter.ts: conditional parser (json vs toml) and standalone parse outside sv.file --- packages/sv/src/addons/better-auth.ts | 49 ++++-------- packages/sv/src/addons/common.ts | 65 +++++++--------- packages/sv/src/addons/devtools-json.ts | 19 +++-- packages/sv/src/addons/drizzle.ts | 36 +++------ packages/sv/src/addons/eslint.ts | 26 ++----- packages/sv/src/addons/mcp.ts | 53 ++++++------- packages/sv/src/addons/mdsvex.ts | 71 +++++++++--------- packages/sv/src/addons/paraglide.ts | 83 +++++++-------------- packages/sv/src/addons/playwright.ts | 20 ++--- packages/sv/src/addons/prettier.ts | 16 ++-- packages/sv/src/addons/sveltekit-adapter.ts | 41 +++------- packages/sv/src/addons/tailwindcss.ts | 57 +++++--------- packages/sv/src/addons/vitest-addon.ts | 18 ++--- 13 files changed, 203 insertions(+), 351 deletions(-) diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index f38dc58f3..18c9fe19b 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'; @@ -55,8 +55,7 @@ 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); + sv.file(`drizzle.config.${language}`, transforms.script((ast) => { const isProp = (name: string, node: AstTypes.Property) => node.key.type === 'Identifier' && node.key.name === name; @@ -83,15 +82,12 @@ export default defineAddon({ if (!drizzleDialect) { throw new Error('Failed to detect DB dialect in your `drizzle.config.[js|ts]` file'); } - return generateCode(); - }); + })); 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'] }); @@ -161,22 +157,18 @@ export default defineAddon({ });`; } js.common.appendFromString(ast, { code: authConfig, comments }); - - return generateCode(); - }); + })); 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); + sv.file(files.package, transforms.json((data) => { json.packageScriptsUpsert( data, 'auth:schema', `better-auth generate --config ${authConfigPath} --output ${authSchemaPath} --yes` ); - return generateCode(); - }); + })); sv.file(`${kit?.libDirectory}/server/db/auth.schema.${language}`, (content) => { if (content) return content; @@ -185,17 +177,11 @@ export default defineAddon({ `; }); - sv.file(`${kit?.libDirectory}/server/db/schema.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); - + sv.file(`${kit?.libDirectory}/server/db/schema.${language}`, transforms.script((ast) => { js.exports.addNamespace(ast, { from: './auth.schema' }); + })); - return generateCode(); - }); - - sv.file('src/app.d.ts', (content) => { - const { ast, comments, generateCode } = parse.script(content); - + 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'], @@ -232,12 +218,9 @@ export default defineAddon({ js.common.createTypeProperty('auth', 'ReturnType', false) ); } - return generateCode(); - }); - - sv.file(`src/hooks.server.${language}`, (content) => { - const { ast, generateCode, comments } = parse.script(content); + })); + 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' }); @@ -271,14 +254,10 @@ export default defineAddon({ handleContent, comments }); - - return generateCode(); - }); + })); 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..d39401988 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,34 @@ 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' }); + }); } /** diff --git a/packages/sv/src/addons/devtools-json.ts b/packages/sv/src/addons/devtools-json.ts index e4d8dc46a..3772aa5a3 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({ @@ -11,14 +11,13 @@ export default defineAddon({ 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( + files.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..06c9c120b 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'; @@ -186,17 +186,13 @@ export default defineAddon({ }); } - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.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'); - - return generateCode(); - }); + })); const hasPrettier = Boolean(dependencyVersion('prettier')); if (hasPrettier) { @@ -212,12 +208,10 @@ export default defineAddon({ }); } - sv.file(paths['drizzle config'], (content) => { + sv.file(paths['drizzle config'], transforms.script((ast) => { 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) { @@ -273,13 +267,9 @@ export default defineAddon({ }) `) }); + })); - return generateCode(); - }); - - sv.file(paths['database schema'], (content) => { - const { ast, generateCode } = parse.script(content); - + sv.file(paths['database schema'], transforms.script((ast) => { let taskSchemaExpression; if (options.database === 'sqlite' || options.database === 'd1') { js.imports.addNamed(ast, { @@ -328,13 +318,9 @@ export default defineAddon({ name: 'task', fallback: taskIdentifier }); + })); - return generateCode(); - }); - - sv.file(paths.database, (content) => { - const { ast, generateCode } = parse.script(content); - + 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'] }); @@ -345,7 +331,7 @@ export default defineAddon({ ast.body.push(getDbFn); - return generateCode(); + return; } js.imports.addNamed(ast, { from: '$env/dynamic/private', imports: ['env'] }); @@ -463,9 +449,7 @@ export default defineAddon({ name: 'db', fallback: db }); - - return generateCode(); - }); + })); }, nextSteps: ({ options, packageManager, cwd }) => { diff --git a/packages/sv/src/addons/eslint.ts b/packages/sv/src/addons/eslint.ts index f57eec50f..358d0fa26 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'; @@ -23,17 +23,11 @@ export default defineAddon({ if (prettierInstalled) sv.devDependency('eslint-config-prettier', '^10.1.8'); - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.package, transforms.json((data) => { json.packageScriptsUpsert(data, 'lint', 'eslint .'); + })); - return generateCode(); - }); - - sv.file(files.eslintConfig, (content) => { - const { ast, comments, generateCode } = parse.script(content); - + sv.file(files.eslintConfig, transforms.script((ast, comments) => { const eslintConfigs: Array = []; js.imports.addDefault(ast, { from: './svelte.config.js', as: 'svelteConfig' }); const gitIgnorePathStatement = js.common.parseStatement( @@ -123,7 +117,7 @@ export default defineAddon({ // 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; + return false; } if (typescript) js.imports.addDefault(ast, { from: 'typescript-eslint', as: 'ts' }); @@ -136,15 +130,11 @@ export default defineAddon({ imports: ['includeIgnoreFile'] }); js.imports.addDefault(ast, { from: 'node:path', as: 'path' }); + })); - return generateCode(); - }); - - sv.file(files.vscodeExtensions, (content) => { - const { data, generateCode } = parse.json(content); + sv.file(files.vscodeExtensions, transforms.json((data) => { json.arrayUpsert(data, 'recommendations', 'dbaeumer.vscode-eslint'); - return generateCode(); - }); + })); if (prettierInstalled) { sv.file(files.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 30583f07a..3b3b5b534 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({ @@ -9,43 +9,42 @@ export default defineAddon({ run: ({ sv, files }) => { sv.devDependency('mdsvex', '^0.12.6'); - sv.file(files.svelteConfig, (content) => { - const { ast, generateCode } = parse.script(content); + sv.file( + files.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: [] }); - 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'); - - 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: [] }); + 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'); + }) + ); } }); diff --git a/packages/sv/src/addons/paraglide.ts b/packages/sv/src/addons/paraglide.ts index 6bcde3224..11d1dea3f 100644 --- a/packages/sv/src/addons/paraglide.ts +++ b/packages/sv/src/addons/paraglide.ts @@ -1,5 +1,5 @@ import { log } from '@clack/prompts'; -import { color, html, js, parse, svelte, type SvelteAst, text } from '@sveltejs/sv-utils'; +import { color, html, js, svelte, type SvelteAst, text, transforms } from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -61,24 +61,19 @@ 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); - + sv.file(files.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}' + code: `${vitePluginName}({ + project: './project.inlang', + outdir: './${paraglideOutDir}' })` }); - - return generateCode(); - }); + })); // reroute hook - sv.file(`src/hooks.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); + sv.file(`src/hooks.${language}`, transforms.script((ast) => { js.imports.addNamed(ast, { from: '$lib/paraglide/runtime', imports: ['deLocalizeUrl'] @@ -100,13 +95,10 @@ export default defineAddon({ if (existingExport.declaration !== rerouteIdentifier) { log.warn('Adding the reroute hook automatically failed. Add it manually'); } - - return generateCode(); - }); + })); // handle hook - sv.file(`src/hooks.server.${language}`, (content) => { - const { ast, generateCode, comments } = parse.script(content); + sv.file(`src/hooks.server.${language}`, transforms.script((ast, comments) => { js.imports.addNamed(ast, { from: '$lib/paraglide/server', imports: ['paraglideMiddleware'] @@ -128,14 +120,10 @@ export default defineAddon({ handleContent: hookHandleContent, comments }); - - return generateCode(); - }); + })); // add the lang and dir attributes placeholder to app.html - sv.file('src/app.html', (content) => { - const { ast, generateCode } = parse.html(content); - + sv.file('src/app.html', transforms.html((ast) => { const htmlNode = ast.nodes.find( (child): child is SvelteAst.RegularElement => child.type === 'RegularElement' && child.name === 'html' @@ -144,13 +132,11 @@ export default defineAddon({ log.warn( "Could not find node in app.html. You'll need to add the language placeholder manually" ); - return generateCode(); + return; } html.addAttribute(htmlNode, 'lang', '%paraglide.lang%'); html.addAttribute(htmlNode, 'dir', '%paraglide.dir%'); - - return generateCode(); - }); + })); sv.file(files.gitignore, (content) => { if (!content) return content; @@ -164,22 +150,19 @@ 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); + sv.file(`${kit.routesDirectory}/+layout.svelte`, transforms.svelte((ast, { language }) => { svelte.ensureScript(ast, { language }); js.imports.addNamed(ast.instance.content, { imports: ['locales', 'localizeHref'], @@ -194,17 +177,13 @@ export default defineAddon({ {/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); + sv.file(`${kit.routesDirectory}/demo/paraglide/+page.svelte`, transforms.svelte((ast, { language }) => { svelte.ensureScript(ast, { language }); js.imports.addNamed(ast.instance.content, { @@ -233,19 +212,15 @@ export default defineAddon({ '

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

'; svelte.addFragment(ast, templateCode); - - return generateCode(); - }); + })); } const { validLanguageTags } = parseLanguageTagInput(options.languageTags); for (const languageTag of validLanguageTags) { - sv.file(`messages/${languageTag}.json`, (content) => { - const { data, generateCode } = parse.json(content); + sv.file(`messages/${languageTag}.json`, transforms.json((data) => { data['$schema'] = 'https://inlang.com/schema/inlang-message-format'; data.hello_world = `Hello, {name} from ${languageTag}!`; - return generateCode(); - }); + })); } }, diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts index 75216fe83..382bd3bcb 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'; @@ -11,14 +11,10 @@ export default defineAddon({ run: ({ sv, language, files, kit }) => { sv.devDependency('@playwright/test', '^1.58.2'); - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.package, transforms.json((data) => { json.packageScriptsUpsert(data, 'test:e2e', 'playwright test'); json.packageScriptsUpsert(data, 'test', 'npm run test:e2e'); - - return generateCode(); - }); + })); sv.file(files.gitignore, (content) => { if (!content) return content; @@ -29,9 +25,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,8 +49,7 @@ export default defineAddon({ `; }); - sv.file(`playwright.config.${language}`, (content) => { - const { ast, generateCode } = parse.script(content); + sv.file(`playwright.config.${language}`, transforms.script((ast) => { const defineConfig = js.common.parseExpression('defineConfig({})'); const { value: defaultExport } = js.exports.createDefault(ast, { fallback: defineConfig }); @@ -79,8 +72,7 @@ export default defineAddon({ } else { log.warn('Unexpected playwright config for playwright add-on. Could not update.'); } - return generateCode(); - }); + })); }, nextSteps: ({ kit }) => { diff --git a/packages/sv/src/addons/prettier.ts b/packages/sv/src/addons/prettier.ts index c1c7d307a..3c2e3edb5 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, transforms, json } from '@sveltejs/sv-utils'; import { defineAddon } from '../core/config.ts'; import { addEslintConfigPrettier } from './common.ts'; @@ -68,20 +68,14 @@ export default defineAddon({ const eslintVersion = dependencyVersion('eslint'); const eslintInstalled = hasEslint(eslintVersion); - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.package, transforms.json((data) => { 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); + sv.file(files.vscodeExtensions, transforms.json((data) => { json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode'); - return generateCode(); - }); + })); if (eslintVersion?.startsWith(SUPPORTED_ESLINT_VERSION) === false) { log.warn( diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index a05057391..5bc56e4bf 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,4 +1,4 @@ -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'; @@ -45,8 +45,7 @@ export default defineAddon({ const adapter = adapters.find((a) => a.id === options.adapter)!; // removes previously installed adapters - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); + sv.file(files.package, transforms.json((data) => { const devDeps = data['devDependencies']; for (const pkg of Object.keys(devDeps)) { @@ -63,15 +62,11 @@ export default defineAddon({ : 'wrangler pages dev .svelte-kit/cloudflare --port 4173'; data.scripts.preview = preview; } - - return generateCode(); - }); + })); sv.devDependency(adapter.package, adapter.version); - sv.file(files.svelteConfig, (content) => { - const { ast, comments, generateCode } = parse.script(content); - + sv.file(files.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( @@ -117,9 +112,7 @@ export default defineAddon({ c.loc.end.line <= cfgKitValue.loc.end.line ); } - - return generateCode(); - }); + })); if (adapter.package === '@sveltejs/adapter-cloudflare') { sv.devDependency('wrangler', '^4.63.0'); @@ -180,28 +173,18 @@ export default defineAddon({ }); // Setup wrangler types command - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.package, transforms.json((data) => { json.packageScriptsUpsert(data, 'gen', 'wrangler types'); - - return generateCode(); - }); + })); // Add Cloudflare generated types to tsconfig - sv.file(`${jsconfig ? 'jsconfig' : 'tsconfig'}.json`, (content) => { - const { data, generateCode } = parse.json(content); - + 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', (content) => { - const { ast, comments, generateCode } = parse.script(content); - + 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`'); @@ -216,9 +199,7 @@ export default defineAddon({ js.common.createTypeProperty('caches', 'CacheStorage'), js.common.createTypeProperty('cf', 'IncomingRequestCfProperties', true) ); - - return generateCode(); - }); + })); } } }, diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts index 00941ab25..a0cce3d24 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, files, kit, dependencyVersion }) => { const prettierInstalled = Boolean(dependencyVersion('prettier')); sv.devDependency('tailwindcss', '^4.1.18'); @@ -46,19 +46,13 @@ export default defineAddon({ } // add the vite plugin - sv.file(files.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - + sv.file(files.viteConfig, transforms.script((ast) => { 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); - + sv.file(files.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 @@ -77,63 +71,46 @@ export default defineAddon({ 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); + sv.file(appSvelte, transforms.svelte((ast, { language }) => { svelte.ensureScript(ast, { language }); js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative }); - return generateCode(); - }); + })); } 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); + sv.file(layoutSvelte, transforms.svelte((ast, { language }) => { svelte.ensureScript(ast, { language }); js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative }); - if (content.length === 0) { + if (ast.fragment.nodes.length === 0) { const svelteVersion = dependencyVersion('svelte'); if (!svelteVersion) throw new Error('Failed to determine svelte version'); svelte.addSlot(ast, { svelteVersion }); } - - return generateCode(); - }); + })); } - sv.file(files.vscodeSettings, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.vscodeSettings, transforms.json((data) => { data['files.associations'] ??= {}; data['files.associations']['*.css'] = 'tailwindcss'; + })); - return generateCode(); - }); - - sv.file(files.vscodeExtensions, (content) => { - const { data, generateCode } = parse.json(content); + sv.file(files.vscodeExtensions, transforms.json((data) => { json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss'); - return generateCode(); - }); + })); if (prettierInstalled) { - sv.file(files.prettierrc, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.prettierrc, transforms.json((data) => { json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss'); data.tailwindStylesheet ??= files.getRelative({ to: files.stylesheet }); - - return generateCode(); - }); + })); } } }); diff --git a/packages/sv/src/addons/vitest-addon.ts b/packages/sv/src/addons/vitest-addon.ts index 50f632980..8022a9cb6 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() @@ -40,15 +40,11 @@ export default defineAddon({ sv.devDependency('playwright', '^1.58.2'); } - sv.file(files.package, (content) => { - const { data, generateCode } = parse.json(content); - + sv.file(files.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' }); - - return generateCode(); - }); + })); const examplesDir = (kit ? kit.libDirectory : 'src/lib') + '/vitest-examples'; const typed = language === 'ts'; @@ -119,9 +115,7 @@ export default defineAddon({ }); } - sv.file(files.viteConfig, (content) => { - const { ast, generateCode } = parse.script(content); - + sv.file(files.viteConfig, transforms.script((ast) => { const clientObjectExpression = js.object.create({ extends: `./${files.viteConfig}`, test: { @@ -177,9 +171,7 @@ export default defineAddon({ // Remove the old import js.imports.remove(ast, { name: importName, from: 'vite', statement }); } - - return generateCode(); - }); + })); }, nextSteps: ({ language, options }) => { From 325078094dd99eaa6e139745b0f7df75c2d37d33 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 21:21:53 +0100 Subject: [PATCH 06/17] refactor: move file helpers to sv-utils Move `commonFilePaths`, `getPackageJson`, `readFile`, `fileExists` and `Package` type from `sv/core/files` to `@sveltejs/sv-utils` so they are available to community add-on authors. The symbols are re-exported from `sv/core/files` for backwards compat. --- .changeset/move-file-helpers.md | 6 ++++ packages/sv-utils/src/files.ts | 56 ++++++++++++++++++++++++++++++ packages/sv-utils/src/index.ts | 9 +++++ packages/sv/src/core/files.ts | 61 +++++---------------------------- 4 files changed, 79 insertions(+), 53 deletions(-) create mode 100644 .changeset/move-file-helpers.md create mode 100644 packages/sv-utils/src/files.ts diff --git a/.changeset/move-file-helpers.md b/.changeset/move-file-helpers.md new file mode 100644 index 000000000..36ad9496c --- /dev/null +++ b/.changeset/move-file-helpers.md @@ -0,0 +1,6 @@ +--- +"@sveltejs/sv-utils": minor +"sv": patch +--- + +refactor: move `commonFilePaths`, `getPackageJson`, `readFile`, `fileExists` to `@sveltejs/sv-utils` diff --git a/packages/sv-utils/src/files.ts b/packages/sv-utils/src/files.ts new file mode 100644 index 000000000..1136fa876 --- /dev/null +++ b/packages/sv-utils/src/files.ts @@ -0,0 +1,56 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { parseJson } from './tooling/parsers.ts'; + +export type Package = { + name: string; + version: string; + dependencies?: Record; + devDependencies?: Record; + bugs?: string; + repository?: { type: string; url: string }; + keywords?: string[]; + workspaces?: string[]; +}; + +export function getPackageJson(cwd: string): { + source: string; + data: Package; + generateCode: () => string; +} { + const packageText = readFile(cwd, commonFilePaths.packageJson); + if (!packageText) { + const pkgPath = path.join(cwd, commonFilePaths.packageJson); + throw new Error(`Invalid workspace: missing '${pkgPath}'`); + } + + const { data, generateCode } = parseJson(packageText); + return { source: packageText, data: data as Package, generateCode }; +} + +export function readFile(cwd: string, filePath: string): string { + const fullFilePath = path.resolve(cwd, filePath); + + if (!fileExists(cwd, filePath)) { + return ''; + } + + const text = fs.readFileSync(fullFilePath, 'utf8'); + + return text; +} + +export function fileExists(cwd: string, filePath: string): boolean { + const fullFilePath = path.resolve(cwd, filePath); + return fs.existsSync(fullFilePath); +} + +export const commonFilePaths = { + packageJson: 'package.json', + svelteConfig: 'svelte.config.js', + svelteConfigTS: 'svelte.config.ts', + jsconfig: 'jsconfig.json', + tsconfig: 'tsconfig.json', + viteConfig: 'vite.config.js', + viteConfigTS: 'vite.config.ts' +} as const; diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index 13e736a88..a32e9a0a9 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -61,6 +61,15 @@ export { createPrinter } from './utils.ts'; export { sanitizeName } from './sanitize.ts'; export { downloadJson } from './downloadJson.ts'; +// File system helpers +export { + commonFilePaths, + fileExists, + getPackageJson, + readFile, + type Package +} from './files.ts'; + // Terminal styling export { color } from './color.ts'; diff --git a/packages/sv/src/core/files.ts b/packages/sv/src/core/files.ts index 1027fac6b..e1a5b753a 100644 --- a/packages/sv/src/core/files.ts +++ b/packages/sv/src/core/files.ts @@ -1,35 +1,17 @@ import * as p from '@clack/prompts'; -import { type AgentName, resolveCommand, parse } from '@sveltejs/sv-utils'; +import { + type AgentName, + commonFilePaths, + getPackageJson, + resolveCommand +} from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; import { exec } from 'tinyexec'; import type { Workspace } from './workspace.ts'; -export type Package = { - name: string; - version: string; - dependencies?: Record; - devDependencies?: Record; - bugs?: string; - repository?: { type: string; url: string }; - keywords?: string[]; - workspaces?: string[]; -}; - -export function getPackageJson(cwd: string): { - source: string; - data: Package; - generateCode: () => string; -} { - const packageText = readFile(cwd, commonFilePaths.packageJson); - if (!packageText) { - const pkgPath = path.join(cwd, commonFilePaths.packageJson); - throw new Error(`Invalid workspace: missing '${pkgPath}'`); - } - - const { data, generateCode } = parse.json(packageText); - return { source: packageText, data: data as Package, generateCode }; -} +// Re-export from sv-utils for backwards compatibility +export { commonFilePaths, fileExists, getPackageJson, readFile, type Package } from '@sveltejs/sv-utils'; export async function formatFiles(options: { packageManager: AgentName; @@ -62,18 +44,6 @@ export async function formatFiles(options: { stop('Successfully formatted modified files'); } -export function readFile(cwd: string, filePath: string): string { - const fullFilePath = path.resolve(cwd, filePath); - - if (!fileExists(cwd, filePath)) { - return ''; - } - - const text = fs.readFileSync(fullFilePath, 'utf8'); - - return text; -} - export function installPackages( dependencies: Array<{ pkg: string; version: string; dev: boolean }>, workspace: Workspace @@ -118,18 +88,3 @@ export function writeFile(workspace: Workspace, filePath: string, content: strin fs.writeFileSync(fullFilePath, content, 'utf8'); } - -export function fileExists(cwd: string, filePath: string): boolean { - const fullFilePath = path.resolve(cwd, filePath); - return fs.existsSync(fullFilePath); -} - -export const commonFilePaths = { - packageJson: 'package.json', - svelteConfig: 'svelte.config.js', - svelteConfigTS: 'svelte.config.ts', - jsconfig: 'jsconfig.json', - tsconfig: 'tsconfig.json', - viteConfig: 'vite.config.js', - viteConfigTS: 'vite.config.ts' -} as const; From 2f33a9b66c216b3879b22c5748f775a7c4b43273 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 21:24:05 +0100 Subject: [PATCH 07/17] refactor: move all file helpers to sv-utils, use cwd instead of Workspace Move `writeFile`, `installPackages` alongside the previously moved helpers. Refactor signatures to take `cwd: string` instead of `Workspace` since only `workspace.cwd` was ever used. `formatFiles` stays in `sv` as it depends on `@clack/prompts`. --- packages/sv-utils/src/files.ts | 45 +++++++++++++++++++++++ packages/sv-utils/src/index.ts | 2 ++ packages/sv/src/core/engine.ts | 6 ++-- packages/sv/src/core/files.ts | 65 ++++++---------------------------- 4 files changed, 60 insertions(+), 58 deletions(-) diff --git a/packages/sv-utils/src/files.ts b/packages/sv-utils/src/files.ts index 1136fa876..4dd22c359 100644 --- a/packages/sv-utils/src/files.ts +++ b/packages/sv-utils/src/files.ts @@ -45,6 +45,51 @@ export function fileExists(cwd: string, filePath: string): boolean { return fs.existsSync(fullFilePath); } +export function writeFile(cwd: string, filePath: string, content: string): void { + const fullFilePath = path.resolve(cwd, filePath); + const fullDirectoryPath = path.dirname(fullFilePath); + + if (content && !content.endsWith('\n')) content += '\n'; + + if (!fs.existsSync(fullDirectoryPath)) { + fs.mkdirSync(fullDirectoryPath, { recursive: true }); + } + + fs.writeFileSync(fullFilePath, content, 'utf8'); +} + +export function installPackages( + dependencies: Array<{ pkg: string; version: string; dev: boolean }>, + cwd: string +): string { + const { data, generateCode } = getPackageJson(cwd); + + for (const dependency of dependencies) { + if (dependency.dev) { + data.devDependencies ??= {}; + data.devDependencies[dependency.pkg] = dependency.version; + } else { + data.dependencies ??= {}; + data.dependencies[dependency.pkg] = dependency.version; + } + } + + if (data.dependencies) data.dependencies = alphabetizeProperties(data.dependencies); + if (data.devDependencies) data.devDependencies = alphabetizeProperties(data.devDependencies); + + writeFile(cwd, commonFilePaths.packageJson, generateCode()); + return commonFilePaths.packageJson; +} + +function alphabetizeProperties(obj: Record) { + const orderedObj: Record = {}; + const sortedEntries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)); + for (const [key, value] of sortedEntries) { + orderedObj[key] = value; + } + return orderedObj; +} + export const commonFilePaths = { packageJson: 'package.json', svelteConfig: 'svelte.config.js', diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index a32e9a0a9..84f8c0256 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -66,7 +66,9 @@ export { commonFilePaths, fileExists, getPackageJson, + installPackages, readFile, + writeFile, type Package } from './files.ts'; diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 0d081a737..0c85a7615 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -12,7 +12,7 @@ import { type SvApi } from './config.ts'; import { TESTING } from './env.ts'; -import { fileExists, installPackages, readFile, writeFile } from './files.ts'; +import { fileExists, installPackages, readFile, writeFile } from '@sveltejs/sv-utils'; import { createWorkspace, type Workspace } from './workspace.ts'; export type InstallOptions = { @@ -179,7 +179,7 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } fileContent = content(fileContent); if (!fileContent) return fileContent; - writeFile(workspace, path, fileContent); + writeFile(workspace.cwd, path, fileContent); files.add(path); } catch (e) { if (e instanceof Error) { @@ -245,7 +245,7 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } } if (cancels.length === 0) { - const pkgPath = installPackages(dependencies, workspace); + const pkgPath = installPackages(dependencies, workspace.cwd); files.add(pkgPath); } diff --git a/packages/sv/src/core/files.ts b/packages/sv/src/core/files.ts index e1a5b753a..4a67e8f5b 100644 --- a/packages/sv/src/core/files.ts +++ b/packages/sv/src/core/files.ts @@ -1,17 +1,17 @@ import * as p from '@clack/prompts'; -import { - type AgentName, - commonFilePaths, - getPackageJson, - resolveCommand -} from '@sveltejs/sv-utils'; -import fs from 'node:fs'; -import path from 'node:path'; +import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; import { exec } from 'tinyexec'; -import type { Workspace } from './workspace.ts'; // Re-export from sv-utils for backwards compatibility -export { commonFilePaths, fileExists, getPackageJson, readFile, type Package } from '@sveltejs/sv-utils'; +export { + commonFilePaths, + fileExists, + getPackageJson, + installPackages, + readFile, + writeFile, + type Package +} from '@sveltejs/sv-utils'; export async function formatFiles(options: { packageManager: AgentName; @@ -43,48 +43,3 @@ export async function formatFiles(options: { } stop('Successfully formatted modified files'); } - -export function installPackages( - dependencies: Array<{ pkg: string; version: string; dev: boolean }>, - workspace: Workspace -): string { - const { data, generateCode } = getPackageJson(workspace.cwd); - - for (const dependency of dependencies) { - if (dependency.dev) { - data.devDependencies ??= {}; - data.devDependencies[dependency.pkg] = dependency.version; - } else { - data.dependencies ??= {}; - data.dependencies[dependency.pkg] = dependency.version; - } - } - - if (data.dependencies) data.dependencies = alphabetizeProperties(data.dependencies); - if (data.devDependencies) data.devDependencies = alphabetizeProperties(data.devDependencies); - - writeFile(workspace, commonFilePaths.packageJson, generateCode()); - return commonFilePaths.packageJson; -} - -function alphabetizeProperties(obj: Record) { - const orderedObj: Record = {}; - const sortedEntries = Object.entries(obj).sort(([a], [b]) => a.localeCompare(b)); - for (const [key, value] of sortedEntries) { - orderedObj[key] = value; - } - return orderedObj; -} - -export function writeFile(workspace: Workspace, filePath: string, content: string): void { - const fullFilePath = path.resolve(workspace.cwd, filePath); - const fullDirectoryPath = path.dirname(fullFilePath); - - if (content && !content.endsWith('\n')) content += '\n'; - - if (!fs.existsSync(fullDirectoryPath)) { - fs.mkdirSync(fullDirectoryPath, { recursive: true }); - } - - fs.writeFileSync(fullFilePath, content, 'utf8'); -} From 48665f718a699b0adc0b11818277c338534a9a4f Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 21:27:24 +0100 Subject: [PATCH 08/17] refactor: rename files.ts to formatFiles.ts, import from sv-utils directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove re-exports — all callers now import file helpers from `@sveltejs/sv-utils` directly. Rename the remaining file to match its only export. --- .changeset/move-file-helpers.md | 6 +++--- packages/sv/src/addons/drizzle.ts | 3 +-- packages/sv/src/addons/sveltekit-adapter.ts | 3 +-- packages/sv/src/cli/add.ts | 2 +- packages/sv/src/cli/create.ts | 4 ++-- packages/sv/src/core/engine.ts | 3 +-- packages/sv/src/core/{files.ts => formatFiles.ts} | 11 ----------- packages/sv/src/core/workspace.ts | 2 +- packages/sv/src/create/index.ts | 3 +-- packages/sv/src/create/playground.ts | 4 ++-- 10 files changed, 13 insertions(+), 28 deletions(-) rename packages/sv/src/core/{files.ts => formatFiles.ts} (83%) diff --git a/.changeset/move-file-helpers.md b/.changeset/move-file-helpers.md index 36ad9496c..4f33f444f 100644 --- a/.changeset/move-file-helpers.md +++ b/.changeset/move-file-helpers.md @@ -1,6 +1,6 @@ --- -"@sveltejs/sv-utils": minor -"sv": patch +'@sveltejs/sv-utils': patch +'sv': patch --- -refactor: move `commonFilePaths`, `getPackageJson`, `readFile`, `fileExists` to `@sveltejs/sv-utils` +refactor: move files utilities to `@sveltejs/sv-utils` diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 9a242e1be..493a4ba2d 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -1,9 +1,8 @@ -import { color, dedent, text, js, parse, resolveCommand, json } from '@sveltejs/sv-utils'; +import { color, dedent, text, js, parse, resolveCommand, json, fileExists } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; -import { fileExists } from '../core/files.ts'; import type { OptionValues } from '../core/options.ts'; import { getNodeTypesVersion } from './common.ts'; diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index a05057391..65c75b443 100644 --- a/packages/sv/src/addons/sveltekit-adapter.ts +++ b/packages/sv/src/addons/sveltekit-adapter.ts @@ -1,8 +1,7 @@ -import { color, js, resolveCommand, json, sanitizeName, text, parse } from '@sveltejs/sv-utils'; +import { color, js, resolveCommand, json, sanitizeName, text, parse, fileExists } from '@sveltejs/sv-utils'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; -import { fileExists } from '../core/files.ts'; const adapters = [ { id: 'auto', package: '@sveltejs/adapter-auto', version: '^7.0.0' }, diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index d8825b9a5..c786496d0 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -20,7 +20,7 @@ import { } from '../core/config.ts'; import { applyAddons, setupAddons } from '../core/engine.ts'; import { downloadPackage, getPackageJSON } from '../core/fetch-packages.ts'; -import { formatFiles } from '../core/files.ts'; +import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, addPnpmBuildDependencies, diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 1ea4e63db..172a25bf3 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts'; -import { color, resolveCommand } from '@sveltejs/sv-utils'; +import { color, resolveCommand, commonFilePaths, getPackageJson } from '@sveltejs/sv-utils'; import { Command, Option } from 'commander'; import fs from 'node:fs'; import path from 'node:path'; @@ -7,7 +7,7 @@ import process from 'node:process'; import * as v from 'valibot'; import * as common from '../core/common.ts'; import type { LoadedAddon, OptionValues } from '../core/config.ts'; -import { commonFilePaths, formatFiles, getPackageJson } from '../core/files.ts'; +import { formatFiles } from '../core/formatFiles.ts'; import { AGENT_NAMES, addPnpmBuildDependencies, diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 0c85a7615..47ca15f14 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, resolveCommand, type AgentName, fileExists, installPackages, readFile, writeFile } from '@sveltejs/sv-utils'; import { NonZeroExitError, exec } from 'tinyexec'; import { createLoadedAddon } from '../cli/add.ts'; import { @@ -12,7 +12,6 @@ import { type SvApi } from './config.ts'; import { TESTING } from './env.ts'; -import { fileExists, installPackages, readFile, writeFile } from '@sveltejs/sv-utils'; import { createWorkspace, type Workspace } from './workspace.ts'; export type InstallOptions = { diff --git a/packages/sv/src/core/files.ts b/packages/sv/src/core/formatFiles.ts similarity index 83% rename from packages/sv/src/core/files.ts rename to packages/sv/src/core/formatFiles.ts index 4a67e8f5b..39d7eb2e3 100644 --- a/packages/sv/src/core/files.ts +++ b/packages/sv/src/core/formatFiles.ts @@ -2,17 +2,6 @@ import * as p from '@clack/prompts'; import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; import { exec } from 'tinyexec'; -// Re-export from sv-utils for backwards compatibility -export { - commonFilePaths, - fileExists, - getPackageJson, - installPackages, - readFile, - writeFile, - type Package -} from '@sveltejs/sv-utils'; - export async function formatFiles(options: { packageManager: AgentName; cwd: string; diff --git a/packages/sv/src/core/workspace.ts b/packages/sv/src/core/workspace.ts index 7c0e6717e..799c701a5 100644 --- a/packages/sv/src/core/workspace.ts +++ b/packages/sv/src/core/workspace.ts @@ -2,7 +2,7 @@ import { type AgentName, type AstTypes, js, parse } from '@sveltejs/sv-utils'; import * as find from 'empathic/find'; import fs from 'node:fs'; import path from 'node:path'; -import { commonFilePaths, getPackageJson, readFile } from './files.ts'; +import { commonFilePaths, getPackageJson, readFile } from '@sveltejs/sv-utils'; import type { OptionDefinition, OptionValues } from './options.ts'; import { detectPackageManager } from './package-manager.ts'; diff --git a/packages/sv/src/create/index.ts b/packages/sv/src/create/index.ts index 4176eac13..5a2947cbe 100644 --- a/packages/sv/src/create/index.ts +++ b/packages/sv/src/create/index.ts @@ -1,7 +1,6 @@ -import { sanitizeName } from '@sveltejs/sv-utils'; +import { sanitizeName, commonFilePaths } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; -import { commonFilePaths } from '../core/files.ts'; import { mkdirp, copy, dist, getSharedFiles, replace, kv } from './utils.ts'; export type TemplateType = (typeof templateTypes)[number]; diff --git a/packages/sv/src/create/playground.ts b/packages/sv/src/create/playground.ts index cf8e4cacd..ae2fae244 100644 --- a/packages/sv/src/create/playground.ts +++ b/packages/sv/src/create/playground.ts @@ -5,11 +5,11 @@ import { parse, svelte, downloadJson, - Walker + Walker, + commonFilePaths } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; -import { commonFilePaths } from '../core/files.ts'; import { getSharedFiles } from './utils.ts'; export function validatePlaygroundUrl(link: string): boolean { From 3dd34a63c12a9be950705cb184f5727aab9c0d38 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 21:32:07 +0100 Subject: [PATCH 09/17] fmt --- packages/sv/src/addons/drizzle.ts | 11 ++++++++++- packages/sv/src/addons/sveltekit-adapter.ts | 11 ++++++++++- packages/sv/src/core/engine.ts | 10 +++++++++- packages/sv/src/core/workspace.ts | 11 +++++++++-- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 493a4ba2d..7447acd63 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -1,4 +1,13 @@ -import { color, dedent, text, js, parse, resolveCommand, json, fileExists } from '@sveltejs/sv-utils'; +import { + color, + dedent, + text, + js, + parse, + resolveCommand, + json, + fileExists +} from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts index 65c75b443..18287bf80 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, fileExists } from '@sveltejs/sv-utils'; +import { + color, + js, + resolveCommand, + json, + sanitizeName, + text, + parse, + fileExists +} from '@sveltejs/sv-utils'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 47ca15f14..e5403a49a 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -1,5 +1,13 @@ import * as p from '@clack/prompts'; -import { color, resolveCommand, type AgentName, fileExists, installPackages, readFile, writeFile } from '@sveltejs/sv-utils'; +import { + color, + resolveCommand, + type AgentName, + fileExists, + installPackages, + readFile, + writeFile +} from '@sveltejs/sv-utils'; import { NonZeroExitError, exec } from 'tinyexec'; import { createLoadedAddon } from '../cli/add.ts'; import { diff --git a/packages/sv/src/core/workspace.ts b/packages/sv/src/core/workspace.ts index 799c701a5..fc5c10160 100644 --- a/packages/sv/src/core/workspace.ts +++ b/packages/sv/src/core/workspace.ts @@ -1,8 +1,15 @@ -import { type AgentName, type AstTypes, js, parse } from '@sveltejs/sv-utils'; +import { + type AgentName, + type AstTypes, + js, + parse, + commonFilePaths, + getPackageJson, + readFile +} from '@sveltejs/sv-utils'; import * as find from 'empathic/find'; import fs from 'node:fs'; import path from 'node:path'; -import { commonFilePaths, getPackageJson, readFile } from '@sveltejs/sv-utils'; import type { OptionDefinition, OptionValues } from './options.ts'; import { detectPackageManager } from './package-manager.ts'; From 618f1adabe9c4df350fde6f4fbb3e6f4f98463e2 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 22:36:19 +0100 Subject: [PATCH 10/17] feat: remaining tweaks for community add-ons docs, templates, dependency setup, scoped package validation, bundling, and test updates --- .changeset/move-file-helpers.md | 6 - documentation/docs/20-commands/20-sv-add.md | 3 - documentation/docs/40-api/10-add-on.md | 67 +++++--- documentation/docs/40-api/20-sv-utils.md | 150 +++++++++++++++++- packages/sv-utils/package.json | 1 - packages/sv-utils/src/index.ts | 23 +-- packages/sv-utils/src/tooling/transforms.ts | 30 +--- packages/sv/src/addons/common.ts | 1 + packages/sv/src/cli/add.ts | 2 +- packages/sv/src/cli/create.ts | 12 +- packages/sv/src/cli/tests/cli.ts | 6 +- .../snapshots/@my-org/sv/CONTRIBUTING.md | 18 ++- .../tests/snapshots/@my-org/sv/package.json | 25 +-- .../tests/snapshots/@my-org/sv/src/index.js | 44 ++--- .../snapshots/@my-org/sv/tsdown.config.js | 6 + packages/sv/src/core/config.ts | 11 ++ packages/sv/src/core/fetch-packages.ts | 15 +- .../{sv-utils/src => sv/src/core}/files.ts | 80 +++++++--- packages/sv/src/core/formatFiles.ts | 34 ---- packages/sv/src/core/workspace.ts | 11 +- packages/sv/src/create/index.ts | 3 +- packages/sv/src/create/playground.ts | 4 +- .../src/create/shared/+addon/CONTRIBUTING.md | 18 ++- .../templates/addon/package.template.json | 24 ++- .../src/create/templates/addon/src/index.js | 44 ++--- .../create/templates/addon/tsdown.config.js | 6 + packages/sv/src/testing.ts | 10 +- 27 files changed, 420 insertions(+), 234 deletions(-) delete mode 100644 .changeset/move-file-helpers.md create mode 100644 packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js rename packages/{sv-utils/src => sv/src/core}/files.ts (64%) delete mode 100644 packages/sv/src/core/formatFiles.ts create mode 100644 packages/sv/src/create/templates/addon/tsdown.config.js diff --git a/.changeset/move-file-helpers.md b/.changeset/move-file-helpers.md deleted file mode 100644 index 4f33f444f..000000000 --- a/.changeset/move-file-helpers.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@sveltejs/sv-utils': patch -'sv': patch ---- - -refactor: move files utilities to `@sveltejs/sv-utils` diff --git a/documentation/docs/20-commands/20-sv-add.md b/documentation/docs/20-commands/20-sv-add.md index 27a8b4b10..dd56bf59d 100644 --- a/documentation/docs/20-commands/20-sv-add.md +++ b/documentation/docs/20-commands/20-sv-add.md @@ -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..d7e5160d8 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) @@ -50,17 +50,19 @@ export default defineAddon({ if (!kit) 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( + kit.routesDirectory + '/+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 +79,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 +96,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 +116,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 +146,7 @@ Your package can export the add-on in two ways: ```json { "exports": { - ".": "./dist/index.js" + ".": "./src/index.js" } } ``` @@ -136,17 +155,23 @@ 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 +``` -- **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`. +> `prepublishOnly` automatically runs the build before publishing. ## 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..5d39fe1ed 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -8,5 +8,153 @@ 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. + +```js +sv.file(files.viteConfig, transforms.script((ast, comments) => { + 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 +sv.file('src/routes/+page.svelte', 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. + +```js +sv.file(files.stylesheet, transforms.css((ast) => { + css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" }); +})); +``` + +### `transforms.json` + +Transform a JSON file. Mutate the `data` object directly. + +```js +sv.file('tsconfig.json', transforms.json((data) => { + data.compilerOptions ??= {}; + data.compilerOptions.strict = true; +})); +``` + +### `transforms.yaml` / `transforms.toml` + +Same pattern as `transforms.json`, for YAML and TOML files respectively. + +### `transforms.text` + +Transform a plain text file (.env, .gitignore, etc.). No parser — string in, string out. + +```js +sv.file('.env', transforms.text((content) => { + return content + '\nDATABASE_URL="file:local.db"'; +})); +``` + +### Aborting a transform + +Return `false` from any transform callback to abort — the original content is returned unchanged. + +```js +sv.file(files.eslintConfig, 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 context manually +const result = addPlugin(fileContent, { language: 'ts' }); +``` + +### Composability + +Add-ons can export reusable transforms that other add-ons consume: + +```js +// in @my-org/sv-utils +export const addFooImport = transforms.svelte((ast, { language }) => { + svelte.ensureScript(ast, { language }); + js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' }); +}); + +// in another add-on +import { addFooImport } from '@my-org/sv-utils'; +sv.file('src/routes/+page.svelte', addFooImport); +``` + +## 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 d8b021540..0e3de0512 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -42,19 +42,17 @@ export { * * Use `parse` directly when you need error handling around parsing or * conditional parser selection at runtime. - * Then manipulate the `ast` as you want, - * and finally `generateCode()` to write it back to the file. * * ```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 = { @@ -73,17 +71,6 @@ export { createPrinter } from './utils.ts'; export { sanitizeName } from './sanitize.ts'; export { downloadJson } from './downloadJson.ts'; -// File system helpers -export { - commonFilePaths, - fileExists, - getPackageJson, - installPackages, - readFile, - writeFile, - type Package -} from './files.ts'; - // Terminal styling export { color } from './color.ts'; diff --git a/packages/sv-utils/src/tooling/transforms.ts b/packages/sv-utils/src/tooling/transforms.ts index 9a0756d4b..ca56c1291 100644 --- a/packages/sv-utils/src/tooling/transforms.ts +++ b/packages/sv-utils/src/tooling/transforms.ts @@ -1,15 +1,7 @@ import type { TomlTable } from 'smol-toml'; import type { Comments, SvelteAst } from './index.ts'; import type { TsEstree } from './js/ts-estree.ts'; -import { - parseCss, - parseHtml, - parseJson, - parseScript, - parseSvelte, - parseToml, - parseYaml -} from './parsers.ts'; +import { parseCss, parseJson, parseScript, parseSvelte, parseToml, parseYaml } from './parsers.ts'; /** * Context injected by the `sv` engine when running a transform via `sv.file()`. @@ -21,7 +13,7 @@ export type TransformContext = { const TRANSFORM_KEY = '__transform' as const; -export type TransformType = 'script' | 'css' | 'svelte' | 'json' | 'yaml' | 'toml' | 'text' | 'html'; +export type TransformType = 'script' | 'css' | 'svelte' | 'json' | 'yaml' | 'toml' | 'text'; export type TransformFn = { (content: string, ctx?: TransformContext): string; @@ -168,24 +160,6 @@ export const transforms = { return fn; }, - /** - * Transform an HTML file (e.g. app.html). - * - * Return `false` from the callback to abort — the original content is returned unchanged. - */ - html( - cb: (ast: SvelteAst.Fragment, ctx: TransformContext) => void | false - ): TransformFn { - const fn = ((content: string, ctx?: TransformContext) => { - const { ast, generateCode } = parseHtml(content); - const result = cb(ast, ctx ?? { language: 'ts' }); - if (result === false) return content; - return generateCode(); - }) as TransformFn; - fn[TRANSFORM_KEY] = 'html'; - return fn; - }, - /** * Transform a plain text file (.env, .gitignore, etc.). * No parsing — just string in, string out. diff --git a/packages/sv/src/addons/common.ts b/packages/sv/src/addons/common.ts index d39401988..e56d96528 100644 --- a/packages/sv/src/addons/common.ts +++ b/packages/sv/src/addons/common.ts @@ -82,6 +82,7 @@ export function addToDemoPage(path: string): TransformFn { js.imports.addNamed(ast.instance.content, { imports: ['resolve'], from: '$app/paths' }); svelte.addFragment(ast, `${path}`, { mode: 'prepend' }); + ast.fragment.nodes.unshift(); }); } diff --git a/packages/sv/src/cli/add.ts b/packages/sv/src/cli/add.ts index c786496d0..d8825b9a5 100644 --- a/packages/sv/src/cli/add.ts +++ b/packages/sv/src/cli/add.ts @@ -20,7 +20,7 @@ import { } from '../core/config.ts'; import { applyAddons, setupAddons } from '../core/engine.ts'; import { downloadPackage, getPackageJSON } from '../core/fetch-packages.ts'; -import { formatFiles } from '../core/formatFiles.ts'; +import { formatFiles } from '../core/files.ts'; import { AGENT_NAMES, addPnpmBuildDependencies, diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts index 32b8f27f1..d95ac9073 100644 --- a/packages/sv/src/cli/create.ts +++ b/packages/sv/src/cli/create.ts @@ -1,5 +1,5 @@ import * as p from '@clack/prompts'; -import { color, resolveCommand, commonFilePaths, getPackageJson } from '@sveltejs/sv-utils'; +import { color, resolveCommand } from '@sveltejs/sv-utils'; import { Command, Option } from 'commander'; import fs from 'node:fs'; import path from 'node:path'; @@ -7,7 +7,7 @@ import process from 'node:process'; import * as v from 'valibot'; import * as common from '../core/common.ts'; import type { LoadedAddon, OptionValues } from '../core/config.ts'; -import { formatFiles } from '../core/formatFiles.ts'; +import { commonFilePaths, formatFiles, getPackageJson } from '../core/files.ts'; import { AGENT_NAMES, addPnpmBuildDependencies, @@ -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.` 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/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 1cae858f4..4a76004c8 100644 --- a/packages/sv/src/core/config.ts +++ b/packages/sv/src/core/config.ts @@ -34,6 +34,17 @@ export type SvApi = { * * 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; 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-utils/src/files.ts b/packages/sv/src/core/files.ts similarity index 64% rename from packages/sv-utils/src/files.ts rename to packages/sv/src/core/files.ts index 4dd22c359..1027fac6b 100644 --- a/packages/sv-utils/src/files.ts +++ b/packages/sv/src/core/files.ts @@ -1,6 +1,9 @@ +import * as p from '@clack/prompts'; +import { type AgentName, resolveCommand, parse } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; -import { parseJson } from './tooling/parsers.ts'; +import { exec } from 'tinyexec'; +import type { Workspace } from './workspace.ts'; export type Package = { name: string; @@ -24,10 +27,41 @@ export function getPackageJson(cwd: string): { throw new Error(`Invalid workspace: missing '${pkgPath}'`); } - const { data, generateCode } = parseJson(packageText); + const { data, generateCode } = parse.json(packageText); return { source: packageText, data: data as Package, generateCode }; } +export async function formatFiles(options: { + packageManager: AgentName; + cwd: string; + filesToFormat: string[]; +}): Promise { + if (options.filesToFormat.length === 0) return; + const { start, stop } = p.spinner(); + start('Formatting modified files'); + + const args = ['prettier', '--write', '--ignore-unknown', ...options.filesToFormat]; + const cmd = resolveCommand(options.packageManager, 'execute-local', args)!; + + try { + const result = await exec(cmd.command, cmd.args, { + nodeOptions: { cwd: options.cwd, stdio: 'pipe' }, + throwOnError: true + }); + if (result.exitCode !== 0) { + stop('Failed to format files'); + p.log.error(result.stderr); + return; + } + } catch (e) { + stop('Failed to format files'); + // @ts-expect-error + p.log.error(e?.output?.stderr || 'unknown error'); + return; + } + stop('Successfully formatted modified files'); +} + export function readFile(cwd: string, filePath: string): string { const fullFilePath = path.resolve(cwd, filePath); @@ -40,29 +74,11 @@ export function readFile(cwd: string, filePath: string): string { return text; } -export function fileExists(cwd: string, filePath: string): boolean { - const fullFilePath = path.resolve(cwd, filePath); - return fs.existsSync(fullFilePath); -} - -export function writeFile(cwd: string, filePath: string, content: string): void { - const fullFilePath = path.resolve(cwd, filePath); - const fullDirectoryPath = path.dirname(fullFilePath); - - if (content && !content.endsWith('\n')) content += '\n'; - - if (!fs.existsSync(fullDirectoryPath)) { - fs.mkdirSync(fullDirectoryPath, { recursive: true }); - } - - fs.writeFileSync(fullFilePath, content, 'utf8'); -} - export function installPackages( dependencies: Array<{ pkg: string; version: string; dev: boolean }>, - cwd: string + workspace: Workspace ): string { - const { data, generateCode } = getPackageJson(cwd); + const { data, generateCode } = getPackageJson(workspace.cwd); for (const dependency of dependencies) { if (dependency.dev) { @@ -77,7 +93,7 @@ export function installPackages( if (data.dependencies) data.dependencies = alphabetizeProperties(data.dependencies); if (data.devDependencies) data.devDependencies = alphabetizeProperties(data.devDependencies); - writeFile(cwd, commonFilePaths.packageJson, generateCode()); + writeFile(workspace, commonFilePaths.packageJson, generateCode()); return commonFilePaths.packageJson; } @@ -90,6 +106,24 @@ function alphabetizeProperties(obj: Record) { return orderedObj; } +export function writeFile(workspace: Workspace, filePath: string, content: string): void { + const fullFilePath = path.resolve(workspace.cwd, filePath); + const fullDirectoryPath = path.dirname(fullFilePath); + + if (content && !content.endsWith('\n')) content += '\n'; + + if (!fs.existsSync(fullDirectoryPath)) { + fs.mkdirSync(fullDirectoryPath, { recursive: true }); + } + + fs.writeFileSync(fullFilePath, content, 'utf8'); +} + +export function fileExists(cwd: string, filePath: string): boolean { + const fullFilePath = path.resolve(cwd, filePath); + return fs.existsSync(fullFilePath); +} + export const commonFilePaths = { packageJson: 'package.json', svelteConfig: 'svelte.config.js', diff --git a/packages/sv/src/core/formatFiles.ts b/packages/sv/src/core/formatFiles.ts deleted file mode 100644 index 39d7eb2e3..000000000 --- a/packages/sv/src/core/formatFiles.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as p from '@clack/prompts'; -import { type AgentName, resolveCommand } from '@sveltejs/sv-utils'; -import { exec } from 'tinyexec'; - -export async function formatFiles(options: { - packageManager: AgentName; - cwd: string; - filesToFormat: string[]; -}): Promise { - if (options.filesToFormat.length === 0) return; - const { start, stop } = p.spinner(); - start('Formatting modified files'); - - const args = ['prettier', '--write', '--ignore-unknown', ...options.filesToFormat]; - const cmd = resolveCommand(options.packageManager, 'execute-local', args)!; - - try { - const result = await exec(cmd.command, cmd.args, { - nodeOptions: { cwd: options.cwd, stdio: 'pipe' }, - throwOnError: true - }); - if (result.exitCode !== 0) { - stop('Failed to format files'); - p.log.error(result.stderr); - return; - } - } catch (e) { - stop('Failed to format files'); - // @ts-expect-error - p.log.error(e?.output?.stderr || 'unknown error'); - return; - } - stop('Successfully formatted modified files'); -} diff --git a/packages/sv/src/core/workspace.ts b/packages/sv/src/core/workspace.ts index b7b51bb39..9ee6422c8 100644 --- a/packages/sv/src/core/workspace.ts +++ b/packages/sv/src/core/workspace.ts @@ -1,15 +1,8 @@ -import { - type AgentName, - type AstTypes, - js, - parse, - commonFilePaths, - getPackageJson, - readFile -} from '@sveltejs/sv-utils'; +import { type AgentName, type AstTypes, js, parse } from '@sveltejs/sv-utils'; import * as find from 'empathic/find'; import fs from 'node:fs'; import path from 'node:path'; +import { commonFilePaths, getPackageJson, readFile } from './files.ts'; import type { OptionDefinition, OptionValues } from './options.ts'; import { detectPackageManager } from './package-manager.ts'; diff --git a/packages/sv/src/create/index.ts b/packages/sv/src/create/index.ts index 5a2947cbe..4176eac13 100644 --- a/packages/sv/src/create/index.ts +++ b/packages/sv/src/create/index.ts @@ -1,6 +1,7 @@ -import { sanitizeName, commonFilePaths } from '@sveltejs/sv-utils'; +import { sanitizeName } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; +import { commonFilePaths } from '../core/files.ts'; import { mkdirp, copy, dist, getSharedFiles, replace, kv } from './utils.ts'; export type TemplateType = (typeof templateTypes)[number]; diff --git a/packages/sv/src/create/playground.ts b/packages/sv/src/create/playground.ts index ae2fae244..cf8e4cacd 100644 --- a/packages/sv/src/create/playground.ts +++ b/packages/sv/src/create/playground.ts @@ -5,11 +5,11 @@ import { parse, svelte, downloadJson, - Walker, - commonFilePaths + Walker } from '@sveltejs/sv-utils'; import fs from 'node:fs'; import path from 'node:path'; +import { commonFilePaths } from '../core/files.ts'; import { getSharedFiles } from './utils.ts'; export function validatePlaygroundUrl(link: string): boolean { 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/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 14bfb309a..cab3e45f2 100644 --- a/packages/sv/src/testing.ts +++ b/packages/sv/src/testing.ts @@ -10,7 +10,7 @@ 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']; @@ -298,7 +298,7 @@ export function createSetupTest(vitest: VitestContext): { - testName = path.dirname(suite.file.filepath).split('/').at(-1)!; + testName = path.dirname(suite.name).split('/').at(-1) ?? ''; create = createProject({ cwd, templatesDir, testName }); @@ -338,14 +338,14 @@ export function createSetupTest(vitest: VitestContext): (async (ctx) => { + beforeEach(async (ctx) => { let browserCtx: Awaited>; if (withBrowser) { browserCtx = await browser.newContext(); - ctx.page = await browserCtx.newPage(); + (ctx as unknown as Fixtures).page = await browserCtx.newPage(); } - ctx.cwd = (addonTestCase) => { + (ctx as unknown as Fixtures).cwd = (addonTestCase) => { return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`); }; From f03f842cde977b199211f21c08d32adc36205a3b Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 22:43:26 +0100 Subject: [PATCH 11/17] docs: document transform context parameter and optional addon properties --- documentation/docs/40-api/10-add-on.md | 18 ++++++++++++++++++ documentation/docs/40-api/20-sv-utils.md | 18 +++++++++--------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/documentation/docs/40-api/10-add-on.md b/documentation/docs/40-api/10-add-on.md index d7e5160d8..40ba56c0a 100644 --- a/documentation/docs/40-api/10-add-on.md +++ b/documentation/docs/40-api/10-add-on.md @@ -37,6 +37,10 @@ const options = defineAddonOptions() // your add-on definition, the entry point export default defineAddon({ id: 'your-addon-name', + // alias: 'short-name', // optional: alternative name for CLI usage + // shortDescription: 'does X', // optional: one-liner shown in prompts + // homepage: 'https://...', // optional: link to docs/repo + // hidden: false, // optional: if true, hidden from interactive prompt options, @@ -172,6 +176,20 @@ npm publish > `prepublishOnly` automatically runs the build before publishing. +## Next steps + +You can optionally display guidance after your add-on runs: + +```js +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 `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 5d39fe1ed..34625895d 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -30,10 +30,10 @@ import { transforms, js, svelte, css, json } from '@sveltejs/sv-utils'; ### `transforms.script` -Transform a JavaScript/TypeScript file. +Transform a JavaScript/TypeScript file. The callback receives the AST, comments, and a context with `language`. ```js -sv.file(files.viteConfig, transforms.script((ast, comments) => { +sv.file(files.viteConfig, transforms.script((ast, comments, { language }) => { js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); js.vite.addPlugin(ast, { code: 'foo()' }); })); @@ -53,20 +53,20 @@ sv.file('src/routes/+page.svelte', transforms.svelte((ast, { language }) => { ### `transforms.css` -Transform a CSS file. +Transform a CSS file. The callback receives the AST and a context with `language`. ```js -sv.file(files.stylesheet, transforms.css((ast) => { +sv.file(files.stylesheet, transforms.css((ast, { language }) => { css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" }); })); ``` ### `transforms.json` -Transform a JSON file. Mutate the `data` object directly. +Transform a JSON file. Mutate the `data` object directly. The callback also receives a context with `language`. ```js -sv.file('tsconfig.json', transforms.json((data) => { +sv.file('tsconfig.json', transforms.json((data, { language }) => { data.compilerOptions ??= {}; data.compilerOptions.strict = true; })); @@ -74,14 +74,14 @@ sv.file('tsconfig.json', transforms.json((data) => { ### `transforms.yaml` / `transforms.toml` -Same pattern as `transforms.json`, for YAML and TOML files respectively. +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. +Transform a plain text file (.env, .gitignore, etc.). No parser — string in, string out. The callback also receives a context with `language`. ```js -sv.file('.env', transforms.text((content) => { +sv.file('.env', transforms.text((content, { language }) => { return content + '\nDATABASE_URL="file:local.db"'; })); ``` From d3dfcc5c28b0adca3833c130ea42f5207a74b41b Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 22:51:58 +0100 Subject: [PATCH 12/17] docs: fix type error annotations for svelte.dev doc checker --- documentation/docs/40-api/10-add-on.md | 1 + documentation/docs/40-api/20-sv-utils.md | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/documentation/docs/40-api/10-add-on.md b/documentation/docs/40-api/10-add-on.md index 40ba56c0a..f8398a3e5 100644 --- a/documentation/docs/40-api/10-add-on.md +++ b/documentation/docs/40-api/10-add-on.md @@ -181,6 +181,7 @@ npm publish You can optionally display guidance after your add-on runs: ```js +// @errors: 2304 7031 export default defineAddon({ // ... nextSteps: ({ options }) => [ diff --git a/documentation/docs/40-api/20-sv-utils.md b/documentation/docs/40-api/20-sv-utils.md index 34625895d..62c021c39 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -33,6 +33,7 @@ import { transforms, js, svelte, css, json } from '@sveltejs/sv-utils'; Transform a JavaScript/TypeScript file. The callback receives the AST, comments, and a context with `language`. ```js +// @errors: 2304 2552 7006 7031 sv.file(files.viteConfig, transforms.script((ast, comments, { language }) => { js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); js.vite.addPlugin(ast, { code: 'foo()' }); @@ -44,6 +45,7 @@ sv.file(files.viteConfig, transforms.script((ast, comments, { language }) => { Transform a Svelte component. The engine injects `language` automatically via the context. ```js +// @errors: 2304 2552 7006 7031 sv.file('src/routes/+page.svelte', transforms.svelte((ast, { language }) => { svelte.ensureScript(ast, { language }); js.imports.addDefault(ast.instance.content, { as: 'Foo', from: './Foo.svelte' }); @@ -56,6 +58,7 @@ sv.file('src/routes/+page.svelte', transforms.svelte((ast, { language }) => { Transform a CSS file. The callback receives the AST and a context with `language`. ```js +// @errors: 2304 2552 7006 7031 sv.file(files.stylesheet, transforms.css((ast, { language }) => { css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" }); })); @@ -66,6 +69,7 @@ sv.file(files.stylesheet, transforms.css((ast, { language }) => { Transform a JSON file. Mutate the `data` object directly. The callback also receives a context with `language`. ```js +// @errors: 2304 2552 7006 7031 sv.file('tsconfig.json', transforms.json((data, { language }) => { data.compilerOptions ??= {}; data.compilerOptions.strict = true; @@ -81,6 +85,7 @@ Same pattern as `transforms.json`, for YAML and TOML files respectively. All cal Transform a plain text file (.env, .gitignore, etc.). No parser — string in, string out. The callback also receives a context with `language`. ```js +// @errors: 2304 2552 7006 7031 sv.file('.env', transforms.text((content, { language }) => { return content + '\nDATABASE_URL="file:local.db"'; })); @@ -91,6 +96,7 @@ sv.file('.env', transforms.text((content, { language }) => { Return `false` from any transform callback to abort — the original content is returned unchanged. ```js +// @errors: 2304 2552 7006 sv.file(files.eslintConfig, transforms.script((ast) => { const { value: existing } = js.exports.createDefault(ast, { fallback: myConfig }); if (existing !== myConfig) { @@ -106,6 +112,7 @@ sv.file(files.eslintConfig, transforms.script((ast) => { Transforms are just functions — they work without the `sv` engine. Pass content directly, with an optional context: ```js +// @errors: 2304 2552 7006 import { transforms, js } from '@sveltejs/sv-utils'; const addPlugin = transforms.script((ast) => { @@ -121,6 +128,7 @@ const result = addPlugin(fileContent, { language: 'ts' }); Add-ons can export reusable transforms that other add-ons consume: ```js +// @errors: 2304 2552 7006 7031 // in @my-org/sv-utils export const addFooImport = transforms.svelte((ast, { language }) => { svelte.ensureScript(ast, { language }); From 7a72294aa211d977e08a0e3f082dca009666cee4 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 23:19:30 +0100 Subject: [PATCH 13/17] update docs --- documentation/docs/20-commands/20-sv-add.md | 2 +- documentation/docs/40-api/10-add-on.md | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/documentation/docs/20-commands/20-sv-add.md b/documentation/docs/20-commands/20-sv-add.md index dd56bf59d..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 diff --git a/documentation/docs/40-api/10-add-on.md b/documentation/docs/40-api/10-add-on.md index f8398a3e5..4e4e0010a 100644 --- a/documentation/docs/40-api/10-add-on.md +++ b/documentation/docs/40-api/10-add-on.md @@ -37,10 +37,8 @@ const options = defineAddonOptions() // your add-on definition, the entry point export default defineAddon({ id: 'your-addon-name', - // alias: 'short-name', // optional: alternative name for CLI usage // shortDescription: 'does X', // optional: one-liner shown in prompts // homepage: 'https://...', // optional: link to docs/repo - // hidden: false, // optional: if true, hidden from interactive prompt options, @@ -50,12 +48,12 @@ 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', + directory.routes + '/+page.svelte', transforms.svelte((ast) => { svelte.addFragment(ast, `

Hello ${options.who}!

`); }) From 3a01dbd20f7490d5d3cb620a6aa2530869d1b0c9 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sat, 21 Mar 2026 23:23:54 +0100 Subject: [PATCH 14/17] like this --- documentation/docs/40-api/20-sv-utils.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/docs/40-api/20-sv-utils.md b/documentation/docs/40-api/20-sv-utils.md index 62c021c39..fa0d097b5 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -34,7 +34,7 @@ Transform a JavaScript/TypeScript file. The callback receives the AST, comments, ```js // @errors: 2304 2552 7006 7031 -sv.file(files.viteConfig, transforms.script((ast, comments, { language }) => { +sv.file(file.viteConfig, transforms.script((ast, comments, { language }) => { js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); js.vite.addPlugin(ast, { code: 'foo()' }); })); @@ -59,7 +59,7 @@ Transform a CSS file. The callback receives the AST and a context with `language ```js // @errors: 2304 2552 7006 7031 -sv.file(files.stylesheet, transforms.css((ast, { language }) => { +sv.file(file.stylesheet, transforms.css((ast, { language }) => { css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" }); })); ``` @@ -97,7 +97,7 @@ Return `false` from any transform callback to abort — the original content is ```js // @errors: 2304 2552 7006 -sv.file(files.eslintConfig, transforms.script((ast) => { +sv.file(file.eslintConfig, transforms.script((ast) => { const { value: existing } = js.exports.createDefault(ast, { fallback: myConfig }); if (existing !== myConfig) { // config already exists, don't touch it From ae6cc71ffde4a260416be9f1c307515f2a783ee4 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 22 Mar 2026 18:11:05 +0100 Subject: [PATCH 15/17] docs: make sv-utils examples standalone with proper imports --- documentation/docs/40-api/20-sv-utils.md | 57 +++++++++++++----------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/documentation/docs/40-api/20-sv-utils.md b/documentation/docs/40-api/20-sv-utils.md index fa0d097b5..94e4a7db4 100644 --- a/documentation/docs/40-api/20-sv-utils.md +++ b/documentation/docs/40-api/20-sv-utils.md @@ -33,11 +33,12 @@ import { transforms, js, svelte, css, json } from '@sveltejs/sv-utils'; Transform a JavaScript/TypeScript file. The callback receives the AST, comments, and a context with `language`. ```js -// @errors: 2304 2552 7006 7031 -sv.file(file.viteConfig, transforms.script((ast, comments, { language }) => { +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` @@ -45,12 +46,13 @@ sv.file(file.viteConfig, transforms.script((ast, comments, { language }) => { Transform a Svelte component. The engine injects `language` automatically via the context. ```js -// @errors: 2304 2552 7006 7031 -sv.file('src/routes/+page.svelte', transforms.svelte((ast, { language }) => { +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` @@ -58,10 +60,11 @@ sv.file('src/routes/+page.svelte', transforms.svelte((ast, { language }) => { Transform a CSS file. The callback receives the AST and a context with `language`. ```js -// @errors: 2304 2552 7006 7031 -sv.file(file.stylesheet, transforms.css((ast, { language }) => { +import { transforms, css } from '@sveltejs/sv-utils'; + +const addTailwind = transforms.css((ast, { language }) => { css.addAtRule(ast, { name: 'import', params: "'tailwindcss'" }); -})); +}); ``` ### `transforms.json` @@ -69,11 +72,12 @@ sv.file(file.stylesheet, transforms.css((ast, { language }) => { Transform a JSON file. Mutate the `data` object directly. The callback also receives a context with `language`. ```js -// @errors: 2304 2552 7006 7031 -sv.file('tsconfig.json', transforms.json((data, { language }) => { +import { transforms } from '@sveltejs/sv-utils'; + +const enableStrict = transforms.json((data, { language }) => { data.compilerOptions ??= {}; data.compilerOptions.strict = true; -})); +}); ``` ### `transforms.yaml` / `transforms.toml` @@ -85,10 +89,11 @@ Same pattern as `transforms.json`, for YAML and TOML files respectively. All cal Transform a plain text file (.env, .gitignore, etc.). No parser — string in, string out. The callback also receives a context with `language`. ```js -// @errors: 2304 2552 7006 7031 -sv.file('.env', transforms.text((content, { language }) => { +import { transforms } from '@sveltejs/sv-utils'; + +const addDbUrl = transforms.text((content, { language }) => { return content + '\nDATABASE_URL="file:local.db"'; -})); +}); ``` ### Aborting a transform @@ -96,15 +101,17 @@ sv.file('.env', transforms.text((content, { language }) => { Return `false` from any transform callback to abort — the original content is returned unchanged. ```js -// @errors: 2304 2552 7006 -sv.file(file.eslintConfig, transforms.script((ast) => { +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 @@ -112,15 +119,14 @@ sv.file(file.eslintConfig, transforms.script((ast) => { Transforms are just functions — they work without the `sv` engine. Pass content directly, with an optional context: ```js -// @errors: 2304 2552 7006 import { transforms, js } from '@sveltejs/sv-utils'; const addPlugin = transforms.script((ast) => { js.imports.addDefault(ast, { as: 'foo', from: 'foo' }); }); -// use standalone — pass context manually -const result = addPlugin(fileContent, { language: 'ts' }); +// use standalone — pass content and context directly +const result = addPlugin('export default {}', { language: 'ts' }); ``` ### Composability @@ -128,16 +134,13 @@ const result = addPlugin(fileContent, { language: 'ts' }); Add-ons can export reusable transforms that other add-ons consume: ```js -// @errors: 2304 2552 7006 7031 -// in @my-org/sv-utils +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' }); }); - -// in another add-on -import { addFooImport } from '@my-org/sv-utils'; -sv.file('src/routes/+page.svelte', addFooImport); ``` ## Parsers (low-level) From eee119bff1cd5cabfb1c1a483d7122e2f63c1341 Mon Sep 17 00:00:00 2001 From: jycouet Date: Sun, 22 Mar 2026 18:34:59 +0100 Subject: [PATCH 16/17] chore: trigger CI From e87d2175194a2ec8ac362898f11dcca620d9a95d Mon Sep 17 00:00:00 2001 From: jycouet Date: Mon, 23 Mar 2026 16:37:38 +0100 Subject: [PATCH 17/17] chore: trigger CI