+ sv.file(
+ `${kit.routesDirectory}/+layout.svelte`,
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
+ js.imports.addNamed(ast.instance.content, {
+ imports: ['locales', 'localizeHref'],
+ from: '$lib/paraglide/runtime'
+ });
+ js.imports.addNamed(ast.instance.content, { imports: ['page'], from: '$app/state' });
+ svelte.addFragment(
+ ast,
+ `
{#each locales as locale}
{locale}
{/each}
`
- );
- return generateCode();
- });
+ );
+ })
+ );
if (options.demo) {
- sv.file(`${kit.routesDirectory}/demo/+page.svelte`, (content) => {
- return addToDemoPage(content, 'paraglide', language);
- });
+ sv.file(`${kit.routesDirectory}/demo/+page.svelte`, addToDemoPage('paraglide'));
// add usage example
- sv.file(`${kit.routesDirectory}/demo/paraglide/+page.svelte`, (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
-
- js.imports.addNamed(ast.instance.content, {
- imports: { m: 'm' },
- from: '$lib/paraglide/messages.js'
- });
- js.imports.addNamed(ast.instance.content, {
- imports: {
- setLocale: 'setLocale'
- },
- from: '$lib/paraglide/runtime'
- });
-
- // add localized message
- let templateCode = "
{m.hello_world({ name: 'SvelteKit User' })} ";
-
- // add links to other localized pages, the first one is the default
- // language, thus it does not require any localized route
- const { validLanguageTags } = parseLanguageTagInput(options.languageTags);
- const links = validLanguageTags
- .map((x) => `
setLocale('${x}')}>${x} `)
- .join('');
- templateCode += `
${links}
`;
-
- templateCode +=
- '
If you use VSCode, install the Sherlock i18n extension for a better i18n experience.
';
-
- svelte.addFragment(ast, templateCode);
-
- return generateCode();
- });
+ sv.file(
+ `${kit.routesDirectory}/demo/paraglide/+page.svelte`,
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
+
+ js.imports.addNamed(ast.instance.content, {
+ imports: { m: 'm' },
+ from: '$lib/paraglide/messages.js'
+ });
+ js.imports.addNamed(ast.instance.content, {
+ imports: {
+ setLocale: 'setLocale'
+ },
+ from: '$lib/paraglide/runtime'
+ });
+
+ // add localized message
+ let templateCode = "
{m.hello_world({ name: 'SvelteKit User' })} ";
+
+ // add links to other localized pages, the first one is the default
+ // language, thus it does not require any localized route
+ const { validLanguageTags } = parseLanguageTagInput(options.languageTags);
+ const links = validLanguageTags
+ .map((x) => `
setLocale('${x}')}>${x} `)
+ .join('');
+ templateCode += `
${links}
`;
+
+ templateCode +=
+ '
If you use VSCode, install the Sherlock i18n extension for a better i18n experience.
';
+
+ svelte.addFragment(ast, templateCode);
+ })
+ );
}
const { validLanguageTags } = parseLanguageTagInput(options.languageTags);
for (const languageTag of validLanguageTags) {
- sv.file(`messages/${languageTag}.json`, (content) => {
- const { data, generateCode } = parse.json(content);
- data['$schema'] = 'https://inlang.com/schema/inlang-message-format';
- data.hello_world = `Hello, {name} from ${languageTag}!`;
- return generateCode();
- });
+ sv.file(
+ `messages/${languageTag}.json`,
+ transforms.json((data) => {
+ data['$schema'] = 'https://inlang.com/schema/inlang-message-format';
+ data.hello_world = `Hello, {name} from ${languageTag}!`;
+ })
+ );
}
},
diff --git a/packages/sv/src/addons/playwright.ts b/packages/sv/src/addons/playwright.ts
index 75216fe83..512629141 100644
--- a/packages/sv/src/addons/playwright.ts
+++ b/packages/sv/src/addons/playwright.ts
@@ -1,5 +1,5 @@
import { log } from '@clack/prompts';
-import { color, dedent, js, parse, json, text } from '@sveltejs/sv-utils';
+import { color, dedent, js, transforms, json, text } from '@sveltejs/sv-utils';
import { defineAddon } from '../core/config.ts';
import { addToDemoPage } from './common.ts';
@@ -8,19 +8,18 @@ export default defineAddon({
shortDescription: 'browser testing',
homepage: 'https://playwright.dev',
options: {},
- run: ({ sv, language, files, kit }) => {
+ run: ({ sv, language, file, kit }) => {
sv.devDependency('@playwright/test', '^1.58.2');
- sv.file(files.package, (content) => {
- const { data, generateCode } = parse.json(content);
+ sv.file(
+ file.package,
+ transforms.json((data) => {
+ json.packageScriptsUpsert(data, 'test:e2e', 'playwright test');
+ json.packageScriptsUpsert(data, 'test', 'npm run test:e2e');
+ })
+ );
- json.packageScriptsUpsert(data, 'test:e2e', 'playwright test');
- json.packageScriptsUpsert(data, 'test', 'npm run test:e2e');
-
- return generateCode();
- });
-
- sv.file(files.gitignore, (content) => {
+ sv.file(file.gitignore, (content) => {
if (!content) return content;
return text.upsert(content, 'test-results', { comment: 'Playwright' });
});
@@ -29,9 +28,7 @@ export default defineAddon({
const testRoute = kit ? '/demo/playwright' : '/';
if (kit) {
- sv.file(`${kit.routesDirectory}/demo/+page.svelte`, (content) => {
- return addToDemoPage(content, 'playwright', language);
- });
+ sv.file(`${kit.routesDirectory}/demo/+page.svelte`, addToDemoPage('playwright'));
sv.file(`${testDir}/+page.svelte`, (content) => {
if (content) return content;
@@ -55,32 +52,33 @@ export default defineAddon({
`;
});
- sv.file(`playwright.config.${language}`, (content) => {
- const { ast, generateCode } = parse.script(content);
- const defineConfig = js.common.parseExpression('defineConfig({})');
- const { value: defaultExport } = js.exports.createDefault(ast, { fallback: defineConfig });
-
- const config = {
- webServer: {
- command: 'npm run build && npm run preview',
- port: 4173
- },
- testMatch: '**/*.e2e.{ts,js}'
- };
-
- if (
- defaultExport.type === 'CallExpression' &&
- defaultExport.arguments[0]?.type === 'ObjectExpression'
- ) {
- js.imports.addNamed(ast, { imports: ['defineConfig'], from: '@playwright/test' });
- js.object.overrideProperties(defaultExport.arguments[0], config);
- } else if (defaultExport.type === 'ObjectExpression') {
- js.object.overrideProperties(defaultExport, config);
- } else {
- log.warn('Unexpected playwright config for playwright add-on. Could not update.');
- }
- return generateCode();
- });
+ sv.file(
+ `playwright.config.${language}`,
+ transforms.script((ast) => {
+ const defineConfig = js.common.parseExpression('defineConfig({})');
+ const { value: defaultExport } = js.exports.createDefault(ast, { fallback: defineConfig });
+
+ const config = {
+ webServer: {
+ command: 'npm run build && npm run preview',
+ port: 4173
+ },
+ testMatch: '**/*.e2e.{ts,js}'
+ };
+
+ if (
+ defaultExport.type === 'CallExpression' &&
+ defaultExport.arguments[0]?.type === 'ObjectExpression'
+ ) {
+ js.imports.addNamed(ast, { imports: ['defineConfig'], from: '@playwright/test' });
+ js.object.overrideProperties(defaultExport.arguments[0], config);
+ } else if (defaultExport.type === 'ObjectExpression') {
+ js.object.overrideProperties(defaultExport, config);
+ } else {
+ log.warn('Unexpected playwright config for playwright add-on. Could not update.');
+ }
+ })
+ );
},
nextSteps: ({ kit }) => {
diff --git a/packages/sv/src/addons/prettier.ts b/packages/sv/src/addons/prettier.ts
index c1c7d307a..df56a213a 100644
--- a/packages/sv/src/addons/prettier.ts
+++ b/packages/sv/src/addons/prettier.ts
@@ -1,5 +1,5 @@
import { log } from '@clack/prompts';
-import { color, dedent, parse, json } from '@sveltejs/sv-utils';
+import { color, dedent, parse, json, transforms } from '@sveltejs/sv-utils';
import { defineAddon } from '../core/config.ts';
import { addEslintConfigPrettier } from './common.ts';
@@ -8,14 +8,14 @@ export default defineAddon({
shortDescription: 'formatter',
homepage: 'https://prettier.io',
options: {},
- run: ({ sv, dependencyVersion, files }) => {
+ run: ({ sv, dependencyVersion, file }) => {
const tailwindcssInstalled = Boolean(dependencyVersion('tailwindcss'));
if (tailwindcssInstalled) sv.devDependency('prettier-plugin-tailwindcss', '^0.7.2');
sv.devDependency('prettier', '^3.8.1');
sv.devDependency('prettier-plugin-svelte', '^3.4.1');
- sv.file(files.prettierignore, (content) => {
+ sv.file(file.prettierignore, (content) => {
if (content) return content;
return dedent`
# Package Managers
@@ -30,7 +30,7 @@ export default defineAddon({
`;
});
- sv.file(files.prettierrc, (content) => {
+ sv.file(file.prettierrc, (content) => {
let data, generateCode;
try {
({ data, generateCode } = parse.json(content));
@@ -52,7 +52,7 @@ export default defineAddon({
if (tailwindcssInstalled) {
json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss');
- data.tailwindStylesheet ??= files.getRelative({ to: files.stylesheet });
+ data.tailwindStylesheet ??= file.getRelative({ to: file.stylesheet });
}
data.overrides ??= [];
@@ -68,20 +68,20 @@ export default defineAddon({
const eslintVersion = dependencyVersion('eslint');
const eslintInstalled = hasEslint(eslintVersion);
- sv.file(files.package, (content) => {
- const { data, generateCode } = parse.json(content);
-
- json.packageScriptsUpsert(data, 'lint', 'prettier --check .', { mode: 'prepend' });
- json.packageScriptsUpsert(data, 'format', 'prettier --write .');
-
- return generateCode();
- });
-
- sv.file(files.vscodeExtensions, (content) => {
- const { data, generateCode } = parse.json(content);
- json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode');
- return generateCode();
- });
+ sv.file(
+ file.package,
+ transforms.json((data) => {
+ json.packageScriptsUpsert(data, 'lint', 'prettier --check .', { mode: 'prepend' });
+ json.packageScriptsUpsert(data, 'format', 'prettier --write .');
+ })
+ );
+
+ sv.file(
+ file.vscodeExtensions,
+ transforms.json((data) => {
+ json.arrayUpsert(data, 'recommendations', 'esbenp.prettier-vscode');
+ })
+ );
if (eslintVersion?.startsWith(SUPPORTED_ESLINT_VERSION) === false) {
log.warn(
@@ -93,7 +93,7 @@ export default defineAddon({
if (eslintInstalled) {
sv.devDependency('eslint-config-prettier', '^10.1.8');
- sv.file(files.eslintConfig, addEslintConfigPrettier);
+ sv.file(file.eslintConfig, addEslintConfigPrettier);
}
}
});
diff --git a/packages/sv/src/addons/sveltekit-adapter.ts b/packages/sv/src/addons/sveltekit-adapter.ts
index a05057391..7d3eeb613 100644
--- a/packages/sv/src/addons/sveltekit-adapter.ts
+++ b/packages/sv/src/addons/sveltekit-adapter.ts
@@ -1,4 +1,13 @@
-import { color, js, resolveCommand, json, sanitizeName, text, parse } from '@sveltejs/sv-utils';
+import {
+ color,
+ js,
+ resolveCommand,
+ json,
+ sanitizeName,
+ text,
+ parse,
+ transforms
+} from '@sveltejs/sv-utils';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { defineAddon, defineAddonOptions } from '../core/config.ts';
@@ -41,85 +50,85 @@ export default defineAddon({
setup: ({ kit, unsupported }) => {
if (!kit) unsupported('Requires SvelteKit');
},
- run: ({ sv, options, files, cwd, language }) => {
+ run: ({ sv, options, file, cwd, language }) => {
const adapter = adapters.find((a) => a.id === options.adapter)!;
// removes previously installed adapters
- sv.file(files.package, (content) => {
- const { data, generateCode } = parse.json(content);
- const devDeps = data['devDependencies'];
-
- for (const pkg of Object.keys(devDeps)) {
- if (pkg.startsWith('@sveltejs/adapter-')) {
- delete devDeps[pkg];
+ sv.file(
+ file.package,
+ transforms.json((data) => {
+ const devDeps = data['devDependencies'];
+
+ for (const pkg of Object.keys(devDeps)) {
+ if (pkg.startsWith('@sveltejs/adapter-')) {
+ delete devDeps[pkg];
+ }
}
- }
-
- // in sk 3, we will keep "preview": "vite preview" like any other adapter
- if (options.adapter === 'cloudflare') {
- const preview =
- options.cfTarget === 'workers'
- ? 'wrangler dev .svelte-kit/cloudflare/_worker.js --port 4173'
- : 'wrangler pages dev .svelte-kit/cloudflare --port 4173';
- data.scripts.preview = preview;
- }
- return generateCode();
- });
+ // in sk 3, we will keep "preview": "vite preview" like any other adapter
+ if (options.adapter === 'cloudflare') {
+ const preview =
+ options.cfTarget === 'workers'
+ ? 'wrangler dev .svelte-kit/cloudflare/_worker.js --port 4173'
+ : 'wrangler pages dev .svelte-kit/cloudflare --port 4173';
+ data.scripts.preview = preview;
+ }
+ })
+ );
sv.devDependency(adapter.package, adapter.version);
- sv.file(files.svelteConfig, (content) => {
- const { ast, comments, generateCode } = parse.script(content);
+ sv.file(
+ file.svelteConfig,
+ transforms.script((ast, comments) => {
+ // finds any existing adapter's import declaration
+ const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration');
+ const adapterImportDecl = importDecls.find(
+ (importDecl) =>
+ typeof importDecl.source.value === 'string' &&
+ importDecl.source.value.startsWith('@sveltejs/adapter-') &&
+ importDecl.importKind === 'value'
+ );
- // finds any existing adapter's import declaration
- const importDecls = ast.body.filter((n) => n.type === 'ImportDeclaration');
- const adapterImportDecl = importDecls.find(
- (importDecl) =>
- typeof importDecl.source.value === 'string' &&
- importDecl.source.value.startsWith('@sveltejs/adapter-') &&
- importDecl.importKind === 'value'
- );
+ let adapterName = 'adapter';
+ if (adapterImportDecl) {
+ // replaces the import's source with the new adapter
+ adapterImportDecl.source.value = adapter.package;
+ // reset raw value, so that the string is re-generated
+ adapterImportDecl.source.raw = undefined;
+
+ adapterName = adapterImportDecl.specifiers?.find(
+ (s) => s.type === 'ImportDefaultSpecifier'
+ )?.local?.name as string;
+ } else {
+ js.imports.addDefault(ast, { from: adapter.package, as: adapterName });
+ }
- let adapterName = 'adapter';
- if (adapterImportDecl) {
- // replaces the import's source with the new adapter
- adapterImportDecl.source.value = adapter.package;
- // reset raw value, so that the string is re-generated
- adapterImportDecl.source.raw = undefined;
-
- adapterName = adapterImportDecl.specifiers?.find((s) => s.type === 'ImportDefaultSpecifier')
- ?.local?.name as string;
- } else {
- js.imports.addDefault(ast, { from: adapter.package, as: adapterName });
- }
+ const { value: config } = js.exports.createDefault(ast, { fallback: js.object.create({}) });
- const { value: config } = js.exports.createDefault(ast, { fallback: js.object.create({}) });
+ // override the adapter property
+ js.object.overrideProperties(config, {
+ kit: {
+ adapter: js.functions.createCall({ name: adapterName, args: [], useIdentifiers: true })
+ }
+ });
- // override the adapter property
- js.object.overrideProperties(config, {
- kit: {
- adapter: js.functions.createCall({ name: adapterName, args: [], useIdentifiers: true })
+ // reset the comment for non-auto adapters
+ if (adapter.package !== '@sveltejs/adapter-auto') {
+ const fallback = js.object.create({});
+ const cfgKitValue = js.object.property(config, { name: 'kit', fallback });
+
+ // removes any existing adapter auto comments
+ comments.remove(
+ (c) =>
+ c.loc &&
+ cfgKitValue.loc &&
+ c.loc.start.line >= cfgKitValue.loc.start.line &&
+ c.loc.end.line <= cfgKitValue.loc.end.line
+ );
}
- });
-
- // reset the comment for non-auto adapters
- if (adapter.package !== '@sveltejs/adapter-auto') {
- const fallback = js.object.create({});
- const cfgKitValue = js.object.property(config, { name: 'kit', fallback });
-
- // removes any existing adapter auto comments
- comments.remove(
- (c) =>
- c.loc &&
- cfgKitValue.loc &&
- c.loc.start.line >= cfgKitValue.loc.start.line &&
- c.loc.end.line <= cfgKitValue.loc.end.line
- );
- }
-
- return generateCode();
- });
+ })
+ );
if (adapter.package === '@sveltejs/adapter-cloudflare') {
sv.devDependency('wrangler', '^4.63.0');
@@ -136,7 +145,7 @@ export default defineAddon({
}
if (!data.name) {
- const pkg = parse.json(readFileSync(join(cwd, files.package), 'utf-8'));
+ const pkg = parse.json(readFileSync(join(cwd, file.package), 'utf-8'));
data.name = sanitizeName(pkg.data.name, 'wrangler');
}
@@ -172,7 +181,7 @@ export default defineAddon({
const typeChecked = language === 'ts' || jsconfig;
if (typeChecked) {
- sv.file(files.gitignore, (content) => {
+ sv.file(file.gitignore, (content) => {
if (content.length === 0) return content;
return text.upsert(content, '/worker-configuration.d.ts', {
comment: 'Cloudflare Types'
@@ -180,45 +189,42 @@ export default defineAddon({
});
// Setup wrangler types command
- sv.file(files.package, (content) => {
- const { data, generateCode } = parse.json(content);
-
- json.packageScriptsUpsert(data, 'gen', 'wrangler types');
-
- return generateCode();
- });
+ sv.file(
+ file.package,
+ transforms.json((data) => {
+ json.packageScriptsUpsert(data, 'gen', 'wrangler types');
+ })
+ );
// Add Cloudflare generated types to tsconfig
- sv.file(`${jsconfig ? 'jsconfig' : 'tsconfig'}.json`, (content) => {
- const { data, generateCode } = parse.json(content);
-
- data.compilerOptions ??= {};
- data.compilerOptions.types ??= [];
- data.compilerOptions.types.push('./worker-configuration.d.ts');
-
- return generateCode();
- });
-
- sv.file('src/app.d.ts', (content) => {
- const { ast, comments, generateCode } = parse.script(content);
-
- const platform = js.kit.addGlobalAppInterface(ast, { name: 'Platform' });
- if (!platform) {
- throw new Error('Failed detecting `platform` interface in `src/app.d.ts`');
- }
-
- // remove the commented out placeholder since we're adding the real one
- comments.remove((c) => c.type === 'Line' && c.value.trim() === 'interface Platform {}');
-
- platform.body.body.push(
- js.common.createTypeProperty('env', 'Env'),
- js.common.createTypeProperty('ctx', 'ExecutionContext'),
- js.common.createTypeProperty('caches', 'CacheStorage'),
- js.common.createTypeProperty('cf', 'IncomingRequestCfProperties', true)
- );
+ sv.file(
+ `${jsconfig ? 'jsconfig' : 'tsconfig'}.json`,
+ transforms.json((data) => {
+ data.compilerOptions ??= {};
+ data.compilerOptions.types ??= [];
+ data.compilerOptions.types.push('./worker-configuration.d.ts');
+ })
+ );
- return generateCode();
- });
+ sv.file(
+ 'src/app.d.ts',
+ transforms.script((ast, comments) => {
+ const platform = js.kit.addGlobalAppInterface(ast, { name: 'Platform' });
+ if (!platform) {
+ throw new Error('Failed detecting `platform` interface in `src/app.d.ts`');
+ }
+
+ // remove the commented out placeholder since we're adding the real one
+ comments.remove((c) => c.type === 'Line' && c.value.trim() === 'interface Platform {}');
+
+ platform.body.body.push(
+ js.common.createTypeProperty('env', 'Env'),
+ js.common.createTypeProperty('ctx', 'ExecutionContext'),
+ js.common.createTypeProperty('caches', 'CacheStorage'),
+ js.common.createTypeProperty('cf', 'IncomingRequestCfProperties', true)
+ );
+ })
+ );
}
}
},
diff --git a/packages/sv/src/addons/tailwindcss.ts b/packages/sv/src/addons/tailwindcss.ts
index 00941ab25..7abf74e74 100644
--- a/packages/sv/src/addons/tailwindcss.ts
+++ b/packages/sv/src/addons/tailwindcss.ts
@@ -1,4 +1,4 @@
-import { css, js, parse, svelte, json } from '@sveltejs/sv-utils';
+import { css, js, transforms, svelte, json } from '@sveltejs/sv-utils';
import { defineAddon, defineAddonOptions } from '../core/config.ts';
const plugins = [
@@ -30,7 +30,7 @@ export default defineAddon({
shortDescription: 'css framework',
homepage: 'https://tailwindcss.com',
options,
- run: ({ sv, options, files, kit, dependencyVersion, language }) => {
+ run: ({ sv, options, file, kit, dependencyVersion }) => {
const prettierInstalled = Boolean(dependencyVersion('prettier'));
sv.devDependency('tailwindcss', '^4.1.18');
@@ -46,94 +46,93 @@ export default defineAddon({
}
// add the vite plugin
- sv.file(files.viteConfig, (content) => {
- const { ast, generateCode } = parse.script(content);
-
- const vitePluginName = 'tailwindcss';
- js.imports.addDefault(ast, { as: vitePluginName, from: '@tailwindcss/vite' });
- js.vite.addPlugin(ast, { code: `${vitePluginName}()`, mode: 'prepend' });
-
- return generateCode();
- });
-
- sv.file(files.stylesheet, (content) => {
- const { ast, generateCode } = parse.css(content);
-
- // since we are prepending all the `AtRule` let's add them in reverse order,
- // so they appear in the expected order in the final file
-
- for (const plugin of plugins) {
- if (!options.plugins.includes(plugin.id)) continue;
+ sv.file(
+ file.viteConfig,
+ transforms.script((ast) => {
+ const vitePluginName = 'tailwindcss';
+ js.imports.addDefault(ast, { as: vitePluginName, from: '@tailwindcss/vite' });
+ js.vite.addPlugin(ast, { code: `${vitePluginName}()`, mode: 'prepend' });
+ })
+ );
+
+ sv.file(
+ file.stylesheet,
+ transforms.css((ast) => {
+ // since we are prepending all the `AtRule` let's add them in reverse order,
+ // so they appear in the expected order in the final file
+
+ for (const plugin of plugins) {
+ if (!options.plugins.includes(plugin.id)) continue;
+
+ css.addAtRule(ast, {
+ name: 'plugin',
+ params: `'${plugin.package}'`,
+ append: false
+ });
+ }
css.addAtRule(ast, {
- name: 'plugin',
- params: `'${plugin.package}'`,
+ name: 'import',
+ params: `'tailwindcss'`,
append: false
});
- }
-
- css.addAtRule(ast, {
- name: 'import',
- params: `'tailwindcss'`,
- append: false
- });
-
- return generateCode();
- });
+ })
+ );
if (!kit) {
const appSvelte = 'src/App.svelte';
- const stylesheetRelative = files.getRelative({ from: appSvelte, to: files.stylesheet });
- sv.file(appSvelte, (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
- js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative });
- return generateCode();
- });
+ const stylesheetRelative = file.getRelative({ from: appSvelte, to: file.stylesheet });
+ sv.file(
+ appSvelte,
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
+ js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative });
+ })
+ );
} else {
const layoutSvelte = `${kit?.routesDirectory}/+layout.svelte`;
- const stylesheetRelative = files.getRelative({ from: layoutSvelte, to: files.stylesheet });
- sv.file(layoutSvelte, (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
- js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative });
-
- if (content.length === 0) {
- const svelteVersion = dependencyVersion('svelte');
- if (!svelteVersion) throw new Error('Failed to determine svelte version');
- svelte.addSlot(ast, {
- svelteVersion
- });
- }
-
- return generateCode();
- });
+ const stylesheetRelative = file.getRelative({ from: layoutSvelte, to: file.stylesheet });
+ sv.file(
+ layoutSvelte,
+ transforms.svelte((ast, { language }) => {
+ const isEmpty = ast.fragment.nodes.length === 0;
+ svelte.ensureScript(ast, { language });
+ js.imports.addEmpty(ast.instance.content, { from: stylesheetRelative });
+
+ if (isEmpty) {
+ const svelteVersion = dependencyVersion('svelte');
+ if (!svelteVersion) throw new Error('Failed to determine svelte version');
+ svelte.addSlot(ast, {
+ svelteVersion
+ });
+ }
+ })
+ );
}
- sv.file(files.vscodeSettings, (content) => {
- const { data, generateCode } = parse.json(content);
-
- data['files.associations'] ??= {};
- data['files.associations']['*.css'] = 'tailwindcss';
-
- return generateCode();
- });
-
- sv.file(files.vscodeExtensions, (content) => {
- const { data, generateCode } = parse.json(content);
- json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss');
- return generateCode();
- });
+ sv.file(
+ file.vscodeSettings,
+ transforms.json((data) => {
+ data['files.associations'] ??= {};
+ data['files.associations']['*.css'] = 'tailwindcss';
+ })
+ );
+
+ sv.file(
+ file.vscodeExtensions,
+ transforms.json((data) => {
+ json.arrayUpsert(data, 'recommendations', 'bradlc.vscode-tailwindcss');
+ })
+ );
if (prettierInstalled) {
- sv.file(files.prettierrc, (content) => {
- const { data, generateCode } = parse.json(content);
-
- json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss');
- data.tailwindStylesheet ??= files.getRelative({ to: files.stylesheet });
-
- return generateCode();
- });
+ sv.file(
+ file.prettierrc,
+ transforms.json((data) => {
+ json.arrayUpsert(data, 'plugins', 'prettier-plugin-tailwindcss');
+ data.tailwindStylesheet ??= file.getRelative({ to: file.stylesheet });
+ })
+ );
}
}
});
diff --git a/packages/sv/src/addons/tests/_setup/suite.ts b/packages/sv/src/addons/tests/_setup/suite.ts
index b93ca2e60..eebe41415 100644
--- a/packages/sv/src/addons/tests/_setup/suite.ts
+++ b/packages/sv/src/addons/tests/_setup/suite.ts
@@ -1,117 +1,4 @@
-import { chromium } from '@playwright/test';
-import { execSync } from 'node:child_process';
-import fs from 'node:fs';
-import path from 'node:path';
-import { add } from 'sv';
-import {
- createProject,
- addPnpmBuildDependencies,
- prepareServer,
- type AddonTestCase,
- type Fixtures,
- type SetupTestOptions
-} from 'sv/testing';
-import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest';
-import type { AddonMap } from '../../../core/engine.ts';
+import * as vitest from 'vitest';
+import { createSetupTest } from '../../../testing.ts';
-const cwd = inject('testDir');
-const templatesDir = inject('templatesDir');
-const variants = inject('variants');
-
-export function setupTest
(
- addons: Addons,
- options?: SetupTestOptions
-) {
- const test = vitestTest.extend({} as any);
-
- const withBrowser = options?.browser ?? true;
-
- let create: ReturnType;
- let browser: Awaited>;
-
- if (withBrowser) {
- beforeAll(async () => {
- browser = await chromium.launch();
- return async () => {
- await browser.close();
- };
- });
- }
-
- const testCases: Array> = [];
- for (const kind of options?.kinds ?? []) {
- for (const variant of variants) {
- const addonTestCase = { variant, kind };
- if (options?.filter === undefined || options.filter(addonTestCase)) {
- testCases.push(addonTestCase);
- }
- }
- }
- let testName: string;
- test.beforeAll(async (_ctx, suite) => {
- testName = path.dirname(suite.file.filepath).split('/').at(-1)!;
-
- // constructs a builder to create test projects
- create = createProject({ cwd, templatesDir, testName });
-
- // creates a pnpm workspace in each addon dir
- fs.writeFileSync(
- path.resolve(cwd, testName, 'pnpm-workspace.yaml'),
- "packages:\n - '**/*'",
- 'utf8'
- );
-
- // creates a barebones package.json in each addon dir
- fs.writeFileSync(
- path.resolve(cwd, testName, 'package.json'),
- JSON.stringify({
- name: `${testName}-workspace-root`,
- private: true
- })
- );
-
- for (const addonTestCase of testCases) {
- const { variant, kind } = addonTestCase;
- const cwd = create({ testId: `${kind.type}-${variant}`, variant });
-
- // test metadata
- const metaPath = path.resolve(cwd, 'meta.json');
- fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8');
-
- if (options?.preAdd) {
- await options.preAdd({ addonTestCase, cwd });
- }
- const { pnpmBuildDependencies } = await add({
- cwd,
- addons,
- options: kind.options,
- packageManager: 'pnpm'
- });
- await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]);
- }
-
- execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });
- });
-
- // runs before each test case
- beforeEach(async (ctx) => {
- let browserCtx: Awaited>;
- if (withBrowser) {
- browserCtx = await browser.newContext();
- ctx.page = await browserCtx.newPage();
- }
-
- ctx.cwd = (addonTestCase) => {
- return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`);
- };
-
- return async () => {
- if (withBrowser) {
- await browserCtx.close();
- }
- // ...other tear downs
- };
- });
-
- return { test, testCases, prepareServer };
-}
+export const setupTest = createSetupTest(vitest);
diff --git a/packages/sv/src/addons/vitest-addon.ts b/packages/sv/src/addons/vitest-addon.ts
index 50f632980..26f8d0c46 100644
--- a/packages/sv/src/addons/vitest-addon.ts
+++ b/packages/sv/src/addons/vitest-addon.ts
@@ -1,4 +1,4 @@
-import { color, dedent, js, parse, json } from '@sveltejs/sv-utils';
+import { color, dedent, js, transforms, json } from '@sveltejs/sv-utils';
import { defineAddon, defineAddonOptions } from '../core/config.ts';
const options = defineAddonOptions()
@@ -23,7 +23,7 @@ export default defineAddon({
homepage: 'https://vitest.dev',
options,
- run: ({ sv, files, language, kit, options, dependencyVersion }) => {
+ run: ({ sv, file, language, kit, options, dependencyVersion }) => {
const unitTesting = options.usages.includes('unit');
const componentTesting = options.usages.includes('component');
@@ -40,15 +40,14 @@ export default defineAddon({
sv.devDependency('playwright', '^1.58.2');
}
- sv.file(files.package, (content) => {
- const { data, generateCode } = parse.json(content);
-
- json.packageScriptsUpsert(data, 'test:unit', 'vitest');
- // we use `--run` so that vitest doesn't run in watch mode when running `npm run test`
- json.packageScriptsUpsert(data, 'test', 'npm run test:unit -- --run', { mode: 'prepend' });
-
- return generateCode();
- });
+ sv.file(
+ file.package,
+ transforms.json((data) => {
+ json.packageScriptsUpsert(data, 'test:unit', 'vitest');
+ // we use `--run` so that vitest doesn't run in watch mode when running `npm run test`
+ json.packageScriptsUpsert(data, 'test', 'npm run test:unit -- --run', { mode: 'prepend' });
+ })
+ );
const examplesDir = (kit ? kit.libDirectory : 'src/lib') + '/vitest-examples';
const typed = language === 'ts';
@@ -119,67 +118,66 @@ export default defineAddon({
});
}
- sv.file(files.viteConfig, (content) => {
- const { ast, generateCode } = parse.script(content);
-
- const clientObjectExpression = js.object.create({
- extends: `./${files.viteConfig}`,
- test: {
- name: 'client',
- browser: {
- enabled: true,
- provider: js.functions.createCall({ name: 'playwright', args: [] }),
- instances: [{ browser: 'chromium', headless: true }]
- },
- include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
- exclude: ['src/lib/server/**']
- }
- });
-
- const serverObjectExpression = js.object.create({
- extends: `./${files.viteConfig}`,
- test: {
- name: 'server',
- environment: 'node',
- include: ['src/**/*.{test,spec}.{js,ts}'],
- exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
- }
- });
-
- const viteConfig = js.vite.getConfig(ast);
-
- const testObject = js.object.property(viteConfig, {
- name: 'test',
- fallback: js.object.create({
- expect: {
- requireAssertions: true
+ sv.file(
+ file.viteConfig,
+ transforms.script((ast) => {
+ const clientObjectExpression = js.object.create({
+ extends: `./${file.viteConfig}`,
+ test: {
+ name: 'client',
+ browser: {
+ enabled: true,
+ provider: js.functions.createCall({ name: 'playwright', args: [] }),
+ instances: [{ browser: 'chromium', headless: true }]
+ },
+ include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
+ exclude: ['src/lib/server/**']
}
- })
- });
-
- const workspaceArray = js.object.property(testObject, {
- name: 'projects',
- fallback: js.array.create()
- });
-
- if (componentTesting) js.array.append(workspaceArray, clientObjectExpression);
- if (unitTesting) js.array.append(workspaceArray, serverObjectExpression);
-
- // Manage imports
- if (componentTesting)
- js.imports.addNamed(ast, { imports: ['playwright'], from: '@vitest/browser-playwright' });
- const importName = 'defineConfig';
- const { statement, alias } = js.imports.find(ast, { name: importName, from: 'vite' });
- if (statement) {
- // Switch the import from 'vite' to 'vitest/config' (keeping the alias)
- js.imports.addNamed(ast, { imports: { defineConfig: alias }, from: 'vitest/config' });
-
- // Remove the old import
- js.imports.remove(ast, { name: importName, from: 'vite', statement });
- }
-
- return generateCode();
- });
+ });
+
+ const serverObjectExpression = js.object.create({
+ extends: `./${file.viteConfig}`,
+ test: {
+ name: 'server',
+ environment: 'node',
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
+ }
+ });
+
+ const viteConfig = js.vite.getConfig(ast);
+
+ const testObject = js.object.property(viteConfig, {
+ name: 'test',
+ fallback: js.object.create({
+ expect: {
+ requireAssertions: true
+ }
+ })
+ });
+
+ const workspaceArray = js.object.property(testObject, {
+ name: 'projects',
+ fallback: js.array.create()
+ });
+
+ if (componentTesting) js.array.append(workspaceArray, clientObjectExpression);
+ if (unitTesting) js.array.append(workspaceArray, serverObjectExpression);
+
+ // Manage imports
+ if (componentTesting)
+ js.imports.addNamed(ast, { imports: ['playwright'], from: '@vitest/browser-playwright' });
+ const importName = 'defineConfig';
+ const { statement, alias } = js.imports.find(ast, { name: importName, from: 'vite' });
+ if (statement) {
+ // Switch the import from 'vite' to 'vitest/config' (keeping the alias)
+ js.imports.addNamed(ast, { imports: { defineConfig: alias }, from: 'vitest/config' });
+
+ // Remove the old import
+ js.imports.remove(ast, { name: importName, from: 'vite', statement });
+ }
+ })
+ );
},
nextSteps: ({ language, options }) => {
diff --git a/packages/sv/src/cli/create.ts b/packages/sv/src/cli/create.ts
index 1ea4e63db..d95ac9073 100644
--- a/packages/sv/src/cli/create.ts
+++ b/packages/sv/src/cli/create.ts
@@ -263,6 +263,14 @@ async function createProject(cwd: ProjectPath, options: Options) {
const parentDirName = path.basename(path.dirname(projectPath));
const projectName = parentDirName.startsWith('@') ? `${parentDirName}/${basename}` : basename;
+ if (template === 'addon' && !projectName.startsWith('@')) {
+ // At this stage, we don't support un-scoped add-ons
+ // FYI: a demo exists for `npx sv add my-cool-addon`
+ common.errorAndExit(
+ `Community add-ons must be published under an npm org (e.g. ${color.command('@my-org/sv')}). Unscoped package names are not supported at this stage.`
+ );
+ }
+
if (template === 'addon' && options.add.length > 0) {
common.errorAndExit(
`The ${color.command('--add')} flag cannot be used with the ${color.command('addon')} template.`
@@ -454,8 +462,8 @@ export async function createVirtualWorkspace({
const virtualWorkspace: Workspace = {
...tentativeWorkspace,
language: type === 'typescript' ? 'ts' : 'js',
- files: {
- ...tentativeWorkspace.files,
+ file: {
+ ...tentativeWorkspace.file,
viteConfig: type === 'typescript' ? commonFilePaths.viteConfigTS : commonFilePaths.viteConfig,
svelteConfig: commonFilePaths.svelteConfig // currently we always use js files, never typescript files
}
diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts
index fe56c126f..5cd980ec7 100644
--- a/packages/sv/src/cli/tests/cli.ts
+++ b/packages/sv/src/cli/tests/cli.ts
@@ -123,8 +123,9 @@ describe('cli', () => {
// replace sv and sv-utils versions in package.json for tests
const packageJsonPath = path.resolve(testOutputPath, 'package.json');
const { data: packageJson } = parse.json(fs.readFileSync(packageJsonPath, 'utf-8'));
- packageJson.dependencies['sv'] = 'file:../../../..';
- packageJson.dependencies['@sveltejs/sv-utils'] = 'file:../../../../sv-utils';
+ packageJson.peerDependencies['sv'] = 'file:../../../..';
+ packageJson.devDependencies['sv'] = 'file:../../../..';
+ packageJson.devDependencies['@sveltejs/sv-utils'] = 'file:../../../../sv-utils';
fs.writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 3).replaceAll(' ', '\t')
@@ -135,6 +136,7 @@ describe('cli', () => {
['i'],
['run', 'demo-create'],
['run', 'demo-add:ci'],
+ ['run', 'build'],
['run', 'test']
];
for (const cmd of cmds) {
diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md b/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md
index 20e629e3c..d5da26c73 100644
--- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md
+++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/CONTRIBUTING.md
@@ -24,15 +24,25 @@ Your `add-on` should:
- export a function that returns a `defineAddon` object.
- have a `package.json` with an `exports` field that points to the main entry point of the add-on.
-## Sharing your add-on
+## Building
-When you're ready to publish your add-on to npm, run:
+Your add-on is bundled with [tsdown](https://tsdown.dev/) into a single file in `dist/`. This bundles everything except `sv` (which is a peer dependency provided at runtime).
-```shell
+```sh
+npm run build
+```
+
+## Publishing
+
+When you're ready to publish your add-on to npm:
+
+```sh
npm login
npm publish
```
+> `prepublishOnly` will automatically run the build before publishing.
+
## Things to be aware of
-Community add-ons are **not permitted** to have any external dependencies outside of `sv`. If the use of a dependency is absolutely necessary, then they can be bundled using a bundler of your choosing (e.g. Rollup, Rolldown, tsup, etc.).
+Community add-ons must have `sv` as a `peerDependency` and should **not** have any `dependencies`. Everything else (including `@sveltejs/sv-utils`) is bundled at build time by tsdown.
diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json b/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json
index b124bfdca..03ae9cd99 100644
--- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json
+++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/package.json
@@ -8,30 +8,37 @@
"demo-create": "sv create demo --types ts --template minimal --no-add-ons --no-install",
"demo-add": "sv add file:../ --cwd demo --no-git-check --no-install",
"demo-add:ci": "sv add file:../=who:you --cwd demo --no-git-check --no-download-check --no-install",
+ "build": "tsdown",
+ "prepublishOnly": "npm run build",
"test": "vitest run"
},
"files": [
- "src",
- "!src/**/*.test.*"
+ "dist"
],
"exports": {
".": {
"default": "./src/index.js"
}
},
- "dependencies": {
- "@sveltejs/sv-utils": "latest",
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": {
+ "default": "./dist/index.js"
+ }
+ }
+ },
+ "peerDependencies": {
"sv": "latest"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
+ "@sveltejs/sv-utils": "latest",
+ "sv": "latest",
+ "tsdown": "^0.21.4",
"vitest": "^4.1.0"
},
"keywords": [
"sv-add"
- ],
- "publishConfig": {
- "directory": "dist",
- "access": "public"
- }
+ ]
}
diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js b/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js
index bf1d89251..8fcc274ff 100644
--- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js
+++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/src/index.js
@@ -1,4 +1,4 @@
-import { js, parse, svelte } from '@sveltejs/sv-utils';
+import { js, transforms, svelte } from '@sveltejs/sv-utils';
import { defineAddon, defineAddonOptions } from 'sv';
const options = defineAddonOptions()
@@ -17,37 +17,37 @@ export default defineAddon({
if (!kit) unsupported('Requires SvelteKit');
},
- run: ({ kit, sv, options, language, cancel }) => {
+ run: ({ kit, sv, options, cancel }) => {
if (!kit) return cancel('SvelteKit is required');
sv.file(`src/lib/@my-org/sv/content.txt`, () => {
return `This is a text file made by the Community Addon Template demo for the add-on: '@my-org/sv'!`;
});
- sv.file(`src/lib/@my-org/sv/HelloComponent.svelte`, (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
+ sv.file(
+ `src/lib/@my-org/sv/HelloComponent.svelte`,
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
- js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' });
+ js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' });
- svelte.addFragment(ast, '{content}
');
- svelte.addFragment(ast, `Hello ${options.who}! `);
+ svelte.addFragment(ast, '{content}
');
+ svelte.addFragment(ast, `Hello ${options.who}! `);
+ })
+ );
- return generateCode();
- });
-
- sv.file(kit.routesDirectory + '/+page.svelte', (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
+ sv.file(
+ kit.routesDirectory + '/+page.svelte',
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
- js.imports.addDefault(ast.instance.content, {
- as: 'HelloComponent',
- from: `$lib/@my-org/sv/HelloComponent.svelte`
- });
+ js.imports.addDefault(ast.instance.content, {
+ as: 'HelloComponent',
+ from: `$lib/@my-org/sv/HelloComponent.svelte`
+ });
- svelte.addFragment(ast, ' ');
-
- return generateCode();
- });
+ svelte.addFragment(ast, ' ');
+ })
+ );
}
});
diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js
index 8749c9916..7622928fe 100644
--- a/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js
+++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tests/setup/suite.js
@@ -1,129 +1,4 @@
-import { chromium } from '@playwright/test';
-import { execSync } from 'node:child_process';
-import fs from 'node:fs';
-import path from 'node:path';
-import { add } from 'sv';
-import { createProject, addPnpmBuildDependencies, prepareServer } from 'sv/testing';
-import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest';
+import { createSetupTest } from 'sv/testing';
+import * as vitest from 'vitest';
-const cwd = inject('testDir');
-const templatesDir = inject('templatesDir');
-const variants = inject('variants');
-
-/**
- * @template {import('sv').AddonMap} AddonMap
- * @param {AddonMap} addons
- * @param {import('sv/testing').SetupTestOptions} [options]
- * @returns {{ test: ReturnType>, testCases: Array>, prepareServer: typeof prepareServer }}
- */
-export function setupTest(addons, options) {
- /** @type {ReturnType>} */
- // @ts-ignore - vitest.extend expects fixtures object but we provide it in beforeEach
- const test = vitestTest.extend({});
-
- const withBrowser = options?.browser ?? true;
-
- /** @type {ReturnType} */
- let create;
- /** @type {Awaited>} */
- let browser;
-
- if (withBrowser) {
- beforeAll(async () => {
- browser = await chromium.launch();
- return async () => {
- await browser.close();
- };
- });
- }
-
- /** @type {Array>} */
- const testCases = [];
- for (const kind of options?.kinds ?? []) {
- for (const variant of variants) {
- const addonTestCase = { variant, kind };
- if (options?.filter === undefined || options.filter(addonTestCase)) {
- testCases.push(addonTestCase);
- }
- }
- }
- /** @type {string} */
- let testName;
- test.beforeAll(async (_ctx, suite) => {
- testName = path.dirname(suite.name).split('/').at(-1) ?? '';
-
- // constructs a builder to create test projects
- create = createProject({ cwd, templatesDir, testName });
-
- // creates a pnpm workspace in each addon dir
- fs.writeFileSync(
- path.resolve(cwd, testName, 'pnpm-workspace.yaml'),
- "packages:\n - '**/*'",
- 'utf8'
- );
-
- // creates a barebones package.json in each addon dir
- fs.writeFileSync(
- path.resolve(cwd, testName, 'package.json'),
- JSON.stringify({
- name: `${testName}-workspace-root`,
- private: true
- })
- );
-
- for (const addonTestCase of testCases) {
- const { variant, kind } = addonTestCase;
- const cwd = create({ testId: `${kind.type}-${variant}`, variant });
-
- // test metadata
- const metaPath = path.resolve(cwd, 'meta.json');
- fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8');
-
- if (options?.preAdd) {
- await options.preAdd({ addonTestCase, cwd });
- }
- const { pnpmBuildDependencies } = await add({
- cwd,
- addons,
- options: kind.options,
- packageManager: 'pnpm'
- });
- await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]);
- }
-
- execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });
- });
-
- // runs before each test case
- /**
- * @param {import('sv/testing').Fixtures & import('vitest').TestContext} ctx
- */
- beforeEach(async (ctx) => {
- /** @type {Awaited>} */
- let browserCtx;
- if (withBrowser) {
- browserCtx = await browser.newContext();
- /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).page =
- await browserCtx.newPage();
- }
-
- /**
- * @param {import('sv/testing').AddonTestCase} addonTestCase
- * @returns {string}
- */
- /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).cwd = (
- addonTestCase
- ) => {
- return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`);
- };
-
- return async () => {
- if (withBrowser) {
- await browserCtx.close();
- }
- // ...other tear downs
- };
- });
-
- return { test, testCases, prepareServer };
-}
+export const setupTest = createSetupTest(vitest);
diff --git a/packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js
new file mode 100644
index 000000000..7f45e3cce
--- /dev/null
+++ b/packages/sv/src/cli/tests/snapshots/@my-org/sv/tsdown.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: ['src/index.js'],
+ format: 'esm'
+});
diff --git a/packages/sv/src/core/config.ts b/packages/sv/src/core/config.ts
index 5ebd7fd0c..4a76004c8 100644
--- a/packages/sv/src/core/config.ts
+++ b/packages/sv/src/core/config.ts
@@ -1,3 +1,4 @@
+import type { TransformFn } from '@sveltejs/sv-utils';
import type { officialAddons } from '../addons/index.ts';
import type { OptionDefinition, OptionValues, Question } from './options.ts';
import type { Workspace, WorkspaceOptions } from './workspace.ts';
@@ -29,8 +30,26 @@ export type SvApi = {
devDependency: (pkg: string, version: string) => void;
/** Execute a command in the workspace. */
execute: (args: string[], stdio: 'inherit' | 'pipe') => Promise;
- /** Edit a file in the workspace. (will create it if it doesn't exist) */
- file: (path: string, edit: (content: string) => string) => void;
+ /** Edit a file in the workspace. (will create it if it doesn't exist)
+ *
+ * Accepts either a raw edit function or a typed transform from `@sveltejs/sv-utils`.
+ * When using a transform, the engine automatically injects workspace context (language, etc.).
+ *
+ * @example
+ * ```ts
+ * // Using a typed transform (recommended)
+ * sv.file(files.viteConfig, transforms.script((ast) => {
+ * js.vite.addPlugin(ast, { code: 'kitRoutes()' });
+ * }));
+ *
+ * // Using a raw edit function
+ * sv.file('.env', (content) => content + '\nKEY=value');
+ * ```
+ */
+ file: {
+ (path: string, edit: TransformFn): void;
+ (path: string, edit: (content: string) => string): void;
+ };
};
export type Addon = {
diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts
index 0d081a737..672e4f463 100644
--- a/packages/sv/src/core/engine.ts
+++ b/packages/sv/src/core/engine.ts
@@ -1,5 +1,5 @@
import * as p from '@clack/prompts';
-import { color, resolveCommand, type AgentName } from '@sveltejs/sv-utils';
+import { color, isTransform, resolveCommand, type AgentName } from '@sveltejs/sv-utils';
import { NonZeroExitError, exec } from 'tinyexec';
import { createLoadedAddon } from '../cli/add.ts';
import {
@@ -171,12 +171,14 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions }
const dependencies: Array<{ pkg: string; version: string; dev: boolean }> = [];
const pnpmBuildDependencies: string[] = [];
const sv: SvApi = {
- file: (path, content) => {
+ file: (path, edit) => {
try {
const exists = fileExists(workspace.cwd, path);
let fileContent = exists ? readFile(workspace.cwd, path) : '';
- // process file
- fileContent = content(fileContent);
+ // process file — inject workspace context for typed transforms
+ fileContent = isTransform(edit)
+ ? edit(fileContent, { language: workspace.language })
+ : edit(fileContent);
if (!fileContent) return fileContent;
writeFile(workspace, path, fileContent);
diff --git a/packages/sv/src/core/fetch-packages.ts b/packages/sv/src/core/fetch-packages.ts
index 02e240c68..0952a72a8 100644
--- a/packages/sv/src/core/fetch-packages.ts
+++ b/packages/sv/src/core/fetch-packages.ts
@@ -14,22 +14,21 @@ import type { AddonDefinition, AddonReference } from './config.ts';
const NODE_MODULES = fileURLToPath(new URL('../../node_modules', import.meta.url));
function verifyPackage(addonPkg: Record, specifier: string): string | undefined {
- // We should look only for dependencies, not devDependencies or peerDependencies
+ const peerDeps = { ...addonPkg.peerDependencies };
const deps = { ...addonPkg.dependencies };
- // valid addons should always have a dependency on `sv`
- const addonSvVersion = deps['sv'];
+ // valid addons should always have `sv` as a peerDependency
+ const addonSvVersion = peerDeps['sv'];
if (!addonSvVersion) {
throw new Error(
- `Invalid add-on package specified: '${specifier}' is missing a dependency on 'sv' in its 'package.json'`
+ `Invalid add-on package specified: '${specifier}' is missing 'sv' in its 'peerDependencies'`
);
}
- // addons should never have any external dependencies outside of `sv` and `@sveltejs/sv-utils`
- for (const dep of Object.keys(deps)) {
- if (dep === 'sv' || dep === '@sveltejs/sv-utils') continue;
+ // addons should not have any dependencies (everything should be bundled)
+ if (Object.keys(deps).length > 0) {
throw new Error(
- `Invalid add-on package detected: '${specifier}'\nCommunity addons should not have any external 'dependencies' besides 'sv'. Consider bundling your dependencies if they are necessary`
+ `Invalid add-on package detected: '${specifier}'\nCommunity add-ons should not have any 'dependencies'. Use 'peerDependencies' for 'sv' and bundle everything else`
);
}
diff --git a/packages/sv/src/core/workspace.ts b/packages/sv/src/core/workspace.ts
index 7c0e6717e..9ee6422c8 100644
--- a/packages/sv/src/core/workspace.ts
+++ b/packages/sv/src/core/workspace.ts
@@ -21,7 +21,7 @@ export type Workspace = {
dependencyVersion: (pkg: string) => string | undefined;
/** to know if the workspace is using typescript or javascript */
language: 'ts' | 'js';
- files: {
+ file: {
viteConfig: 'vite.config.js' | 'vite.config.ts';
svelteConfig: 'svelte.config.js' | 'svelte.config.ts';
/** `${kit.routesDirectory}/layout.css` or `src/app.css` */
@@ -119,7 +119,7 @@ export async function createWorkspace({
cwd: resolvedCwd,
packageManager: packageManager ?? (await detectPackageManager(cwd)),
language: typescript ? 'ts' : 'js',
- files: {
+ file: {
viteConfig,
svelteConfig,
stylesheet,
diff --git a/packages/sv/src/create/shared/+addon/CONTRIBUTING.md b/packages/sv/src/create/shared/+addon/CONTRIBUTING.md
index 20e629e3c..d5da26c73 100644
--- a/packages/sv/src/create/shared/+addon/CONTRIBUTING.md
+++ b/packages/sv/src/create/shared/+addon/CONTRIBUTING.md
@@ -24,15 +24,25 @@ Your `add-on` should:
- export a function that returns a `defineAddon` object.
- have a `package.json` with an `exports` field that points to the main entry point of the add-on.
-## Sharing your add-on
+## Building
-When you're ready to publish your add-on to npm, run:
+Your add-on is bundled with [tsdown](https://tsdown.dev/) into a single file in `dist/`. This bundles everything except `sv` (which is a peer dependency provided at runtime).
-```shell
+```sh
+npm run build
+```
+
+## Publishing
+
+When you're ready to publish your add-on to npm:
+
+```sh
npm login
npm publish
```
+> `prepublishOnly` will automatically run the build before publishing.
+
## Things to be aware of
-Community add-ons are **not permitted** to have any external dependencies outside of `sv`. If the use of a dependency is absolutely necessary, then they can be bundled using a bundler of your choosing (e.g. Rollup, Rolldown, tsup, etc.).
+Community add-ons must have `sv` as a `peerDependency` and should **not** have any `dependencies`. Everything else (including `@sveltejs/sv-utils`) is bundled at build time by tsdown.
diff --git a/packages/sv/src/create/templates/addon/package.template.json b/packages/sv/src/create/templates/addon/package.template.json
index 5e4bcf870..cfb672e72 100644
--- a/packages/sv/src/create/templates/addon/package.template.json
+++ b/packages/sv/src/create/templates/addon/package.template.json
@@ -8,26 +8,34 @@
"demo-create": "sv create demo --types ts --template minimal --no-add-ons --no-install",
"demo-add": "sv add file:../ --cwd demo --no-git-check --no-install",
"demo-add:ci": "sv add file:../=who:you --cwd demo --no-git-check --no-download-check --no-install",
+ "build": "tsdown",
+ "prepublishOnly": "npm run build",
"test": "vitest run"
},
- "files": ["src", "!src/**/*.test.*"],
+ "files": ["dist"],
"exports": {
".": {
"default": "./src/index.js"
}
},
- "dependencies": {
- "@sveltejs/sv-utils": "workspace:*",
+ "publishConfig": {
+ "access": "public",
+ "exports": {
+ ".": {
+ "default": "./dist/index.js"
+ }
+ }
+ },
+ "peerDependencies": {
"sv": "workspace:*"
},
"devDependencies": {
+ "sv": "workspace:*",
+ "@sveltejs/sv-utils": "workspace:*",
"@playwright/test": "^1.58.2",
"@types/node": "^25.2.1",
+ "tsdown": "^0.21.4",
"vitest": "^4.1.0"
},
- "keywords": ["sv-add"],
- "publishConfig": {
- "directory": "dist",
- "access": "public"
- }
+ "keywords": ["sv-add"]
}
diff --git a/packages/sv/src/create/templates/addon/src/index.js b/packages/sv/src/create/templates/addon/src/index.js
index 889aab0af..149210413 100644
--- a/packages/sv/src/create/templates/addon/src/index.js
+++ b/packages/sv/src/create/templates/addon/src/index.js
@@ -1,4 +1,4 @@
-import { js, parse, svelte } from '@sveltejs/sv-utils';
+import { js, transforms, svelte } from '@sveltejs/sv-utils';
import { defineAddon, defineAddonOptions } from 'sv';
const options = defineAddonOptions()
@@ -17,37 +17,37 @@ export default defineAddon({
if (!kit) unsupported('Requires SvelteKit');
},
- run: ({ kit, sv, options, language, cancel }) => {
+ run: ({ kit, sv, options, cancel }) => {
if (!kit) return cancel('SvelteKit is required');
sv.file(`src/lib/~SV-NAME-TODO~/content.txt`, () => {
return `This is a text file made by the Community Addon Template demo for the add-on: '~SV-NAME-TODO~'!`;
});
- sv.file(`src/lib/~SV-NAME-TODO~/HelloComponent.svelte`, (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
+ sv.file(
+ `src/lib/~SV-NAME-TODO~/HelloComponent.svelte`,
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
- js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' });
+ js.imports.addDefault(ast.instance.content, { as: 'content', from: './content.txt?raw' });
- svelte.addFragment(ast, '{content}
');
- svelte.addFragment(ast, `Hello ${options.who}! `);
+ svelte.addFragment(ast, '{content}
');
+ svelte.addFragment(ast, `Hello ${options.who}! `);
+ })
+ );
- return generateCode();
- });
-
- sv.file(kit.routesDirectory + '/+page.svelte', (content) => {
- const { ast, generateCode } = parse.svelte(content);
- svelte.ensureScript(ast, { language });
+ sv.file(
+ kit.routesDirectory + '/+page.svelte',
+ transforms.svelte((ast, { language }) => {
+ svelte.ensureScript(ast, { language });
- js.imports.addDefault(ast.instance.content, {
- as: 'HelloComponent',
- from: `$lib/~SV-NAME-TODO~/HelloComponent.svelte`
- });
+ js.imports.addDefault(ast.instance.content, {
+ as: 'HelloComponent',
+ from: `$lib/~SV-NAME-TODO~/HelloComponent.svelte`
+ });
- svelte.addFragment(ast, ' ');
-
- return generateCode();
- });
+ svelte.addFragment(ast, ' ');
+ })
+ );
}
});
diff --git a/packages/sv/src/create/templates/addon/tests/setup/suite.js b/packages/sv/src/create/templates/addon/tests/setup/suite.js
index 8749c9916..7622928fe 100644
--- a/packages/sv/src/create/templates/addon/tests/setup/suite.js
+++ b/packages/sv/src/create/templates/addon/tests/setup/suite.js
@@ -1,129 +1,4 @@
-import { chromium } from '@playwright/test';
-import { execSync } from 'node:child_process';
-import fs from 'node:fs';
-import path from 'node:path';
-import { add } from 'sv';
-import { createProject, addPnpmBuildDependencies, prepareServer } from 'sv/testing';
-import { inject, test as vitestTest, beforeAll, beforeEach } from 'vitest';
+import { createSetupTest } from 'sv/testing';
+import * as vitest from 'vitest';
-const cwd = inject('testDir');
-const templatesDir = inject('templatesDir');
-const variants = inject('variants');
-
-/**
- * @template {import('sv').AddonMap} AddonMap
- * @param {AddonMap} addons
- * @param {import('sv/testing').SetupTestOptions} [options]
- * @returns {{ test: ReturnType>, testCases: Array>, prepareServer: typeof prepareServer }}
- */
-export function setupTest(addons, options) {
- /** @type {ReturnType>} */
- // @ts-ignore - vitest.extend expects fixtures object but we provide it in beforeEach
- const test = vitestTest.extend({});
-
- const withBrowser = options?.browser ?? true;
-
- /** @type {ReturnType} */
- let create;
- /** @type {Awaited>} */
- let browser;
-
- if (withBrowser) {
- beforeAll(async () => {
- browser = await chromium.launch();
- return async () => {
- await browser.close();
- };
- });
- }
-
- /** @type {Array>} */
- const testCases = [];
- for (const kind of options?.kinds ?? []) {
- for (const variant of variants) {
- const addonTestCase = { variant, kind };
- if (options?.filter === undefined || options.filter(addonTestCase)) {
- testCases.push(addonTestCase);
- }
- }
- }
- /** @type {string} */
- let testName;
- test.beforeAll(async (_ctx, suite) => {
- testName = path.dirname(suite.name).split('/').at(-1) ?? '';
-
- // constructs a builder to create test projects
- create = createProject({ cwd, templatesDir, testName });
-
- // creates a pnpm workspace in each addon dir
- fs.writeFileSync(
- path.resolve(cwd, testName, 'pnpm-workspace.yaml'),
- "packages:\n - '**/*'",
- 'utf8'
- );
-
- // creates a barebones package.json in each addon dir
- fs.writeFileSync(
- path.resolve(cwd, testName, 'package.json'),
- JSON.stringify({
- name: `${testName}-workspace-root`,
- private: true
- })
- );
-
- for (const addonTestCase of testCases) {
- const { variant, kind } = addonTestCase;
- const cwd = create({ testId: `${kind.type}-${variant}`, variant });
-
- // test metadata
- const metaPath = path.resolve(cwd, 'meta.json');
- fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8');
-
- if (options?.preAdd) {
- await options.preAdd({ addonTestCase, cwd });
- }
- const { pnpmBuildDependencies } = await add({
- cwd,
- addons,
- options: kind.options,
- packageManager: 'pnpm'
- });
- await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]);
- }
-
- execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });
- });
-
- // runs before each test case
- /**
- * @param {import('sv/testing').Fixtures & import('vitest').TestContext} ctx
- */
- beforeEach(async (ctx) => {
- /** @type {Awaited>} */
- let browserCtx;
- if (withBrowser) {
- browserCtx = await browser.newContext();
- /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).page =
- await browserCtx.newPage();
- }
-
- /**
- * @param {import('sv/testing').AddonTestCase} addonTestCase
- * @returns {string}
- */
- /** @type {import('sv/testing').Fixtures} */ (/** @type {unknown} */ (ctx)).cwd = (
- addonTestCase
- ) => {
- return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`);
- };
-
- return async () => {
- if (withBrowser) {
- await browserCtx.close();
- }
- // ...other tear downs
- };
- });
-
- return { test, testCases, prepareServer };
-}
+export const setupTest = createSetupTest(vitest);
diff --git a/packages/sv/src/create/templates/addon/tsdown.config.js b/packages/sv/src/create/templates/addon/tsdown.config.js
new file mode 100644
index 000000000..7f45e3cce
--- /dev/null
+++ b/packages/sv/src/create/templates/addon/tsdown.config.js
@@ -0,0 +1,6 @@
+import { defineConfig } from 'tsdown';
+
+export default defineConfig({
+ entry: ['src/index.js'],
+ format: 'esm'
+});
diff --git a/packages/sv/src/testing.ts b/packages/sv/src/testing.ts
index b7bf5564d..cab3e45f2 100644
--- a/packages/sv/src/testing.ts
+++ b/packages/sv/src/testing.ts
@@ -6,10 +6,11 @@ import process from 'node:process';
import pstree, { type PS } from 'ps-tree';
import { exec, x } from 'tinyexec';
import type { TestProject } from 'vitest/node';
-import type { AddonMap, OptionMap } from './core/engine.ts';
+import { add, type AddonMap, type OptionMap } from './core/engine.ts';
+import { addPnpmBuildDependencies } from './core/package-manager.ts';
import { create } from './create/index.ts';
-export { addPnpmBuildDependencies } from './core/package-manager.ts';
+export { addPnpmBuildDependencies };
export type ProjectVariant = 'kit-js' | 'kit-ts' | 'vite-js' | 'vite-ts';
export const variants: ProjectVariant[] = ['kit-js', 'kit-ts', 'vite-js', 'vite-ts'];
@@ -237,3 +238,124 @@ export async function prepareServer({
return { url, close };
}
+
+export type VitestContext = Pick<
+ typeof import('vitest'),
+ 'inject' | 'test' | 'beforeAll' | 'beforeEach'
+>;
+
+export function createSetupTest(vitest: VitestContext): (
+ addons: Addons,
+ options?: SetupTestOptions
+) => {
+ test: import('vitest').TestAPI;
+ testCases: Array>;
+ prepareServer: typeof prepareServer;
+} {
+ return function setupTest(
+ addons: Addons,
+ options?: SetupTestOptions
+ ) {
+ const { inject, test: vitestTest, beforeAll, beforeEach } = vitest;
+
+ const test = vitestTest.extend({}) as unknown as import('vitest').TestAPI;
+
+ const cwd = inject('testDir');
+ const templatesDir = inject('templatesDir');
+ const variants = inject('variants');
+
+ const withBrowser = options?.browser ?? true;
+
+ let create: ReturnType;
+ let browser: Awaited>;
+
+ if (withBrowser) {
+ beforeAll(async () => {
+ let chromium: Awaited['chromium'];
+ try {
+ ({ chromium } = await import('@playwright/test'));
+ } catch {
+ throw new Error(
+ 'Browser testing requires @playwright/test. Install it with: pnpm add -D @playwright/test'
+ );
+ }
+ browser = await chromium.launch();
+ return async () => {
+ await browser.close();
+ };
+ });
+ }
+
+ const testCases: Array> = [];
+ for (const kind of options?.kinds ?? []) {
+ for (const variant of variants) {
+ const addonTestCase = { variant, kind };
+ if (options?.filter === undefined || options.filter(addonTestCase)) {
+ testCases.push(addonTestCase);
+ }
+ }
+ }
+
+ let testName: string;
+ test.beforeAll(async (_ctx, suite) => {
+ testName = path.dirname(suite.name).split('/').at(-1) ?? '';
+
+ create = createProject({ cwd, templatesDir, testName });
+
+ fs.writeFileSync(
+ path.resolve(cwd, testName, 'pnpm-workspace.yaml'),
+ "packages:\n - '**/*'",
+ 'utf8'
+ );
+
+ fs.writeFileSync(
+ path.resolve(cwd, testName, 'package.json'),
+ JSON.stringify({
+ name: `${testName}-workspace-root`,
+ private: true
+ })
+ );
+
+ for (const addonTestCase of testCases) {
+ const { variant, kind } = addonTestCase;
+ const cwd = create({ testId: `${kind.type}-${variant}`, variant });
+
+ const metaPath = path.resolve(cwd, 'meta.json');
+ fs.writeFileSync(metaPath, JSON.stringify({ variant, kind }, null, '\t'), 'utf8');
+
+ if (options?.preAdd) {
+ await options.preAdd({ addonTestCase, cwd });
+ }
+ const { pnpmBuildDependencies } = await add({
+ cwd,
+ addons,
+ options: kind.options,
+ packageManager: 'pnpm'
+ });
+ await addPnpmBuildDependencies(cwd, 'pnpm', ['esbuild', ...pnpmBuildDependencies]);
+ }
+
+ execSync('pnpm install', { cwd: path.resolve(cwd, testName), stdio: 'pipe' });
+ });
+
+ beforeEach(async (ctx) => {
+ let browserCtx: Awaited>;
+ if (withBrowser) {
+ browserCtx = await browser.newContext();
+ (ctx as unknown as Fixtures).page = await browserCtx.newPage();
+ }
+
+ (ctx as unknown as Fixtures).cwd = (addonTestCase) => {
+ return path.join(cwd, testName, `${addonTestCase.kind.type}-${addonTestCase.variant}`);
+ };
+
+ return async () => {
+ if (withBrowser) {
+ await browserCtx.close();
+ }
+ };
+ });
+
+ return { test, testCases, prepareServer };
+ };
+}