diff --git a/.changeset/sanitize-wrangler-name.md b/.changeset/sanitize-wrangler-name.md new file mode 100644 index 000000000..22d09fd20 --- /dev/null +++ b/.changeset/sanitize-wrangler-name.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +fix(adapter-cloudflare): sanitize wrangler project name to comply with Cloudflare naming requirements diff --git a/packages/sv/lib/addons/sveltekit-adapter/index.ts b/packages/sv/lib/addons/sveltekit-adapter/index.ts index b5630edcc..ffe6f1ffb 100644 --- a/packages/sv/lib/addons/sveltekit-adapter/index.ts +++ b/packages/sv/lib/addons/sveltekit-adapter/index.ts @@ -2,6 +2,7 @@ import { defineAddon, defineAddonOptions } from '../../core/index.ts'; import { exports, functions, imports, object, type AstTypes } from '../../core/tooling/js/index.ts'; import { parseJson, parseScript, parseToml } from '../../core/tooling/parsers.ts'; import { fileExists, readFile } from '../../cli/add/utils.ts'; +import { sanitizeName } from '../../core/sanitize.ts'; import { resolveCommand } from 'package-manager-detector'; import * as js from '../../core/tooling/js/index.ts'; @@ -139,7 +140,7 @@ export default defineAddon({ if (!data.name) { const pkg = parseJson(readFile(cwd, files.package)); - data.name = pkg.data.name; + data.name = sanitizeName(pkg.data.name, 'wrangler'); } data.compatibility_date ??= new Date().toISOString().split('T')[0]; diff --git a/packages/sv/lib/core/sanitize.ts b/packages/sv/lib/core/sanitize.ts new file mode 100644 index 000000000..c42461054 --- /dev/null +++ b/packages/sv/lib/core/sanitize.ts @@ -0,0 +1,27 @@ +/** + * @param name - The name to sanitize. + * @param style - The sanitization style. + * - `package` for package.json + * - `wrangler` for Cloudflare Wrangler compatibility + * @returns The sanitized name. + */ +export function sanitizeName(name: string, style: 'package' | 'wrangler'): string { + let sanitized = name.trim().toLowerCase().replace(/\s+/g, '-'); + if (style === 'package') { + const hasLeadingAt = sanitized.startsWith('@'); + sanitized = sanitized + .replace(/\s+/g, '-') + .replace(/^[._]/, '') + .replace(/[^a-z0-9~./-]+/g, '-'); + if (hasLeadingAt) sanitized = '@' + sanitized.slice(1); + } else if (style === 'wrangler') { + sanitized = sanitized + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .slice(0, 63) + .replace(/^-|-$/g, ''); + } else { + throw new Error(`Invalid kind: ${style}`); + } + return sanitized || 'undefined-sv-name'; +} diff --git a/packages/sv/lib/core/tests/sanitize.ts b/packages/sv/lib/core/tests/sanitize.ts new file mode 100644 index 000000000..1497a22d3 --- /dev/null +++ b/packages/sv/lib/core/tests/sanitize.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from 'vitest'; +import { sanitizeName } from '../sanitize.ts'; + +const testCases: Array<{ input: string; expected: string; expectedPackage?: string }> = [ + // Basic cases + { input: 'my-project', expected: 'my-project' }, + { input: 'myproject', expected: 'myproject' }, + + // Dots + { input: 'sub.example.com', expected: 'sub-example-com', expectedPackage: 'sub.example.com' }, + { input: 'my.cool.app', expected: 'my-cool-app', expectedPackage: 'my.cool.app' }, + + // Underscores + { input: 'my_project_name', expected: 'my-project-name' }, + + // Mixed cases + { input: 'My_Project.Name', expected: 'my-project-name', expectedPackage: 'my-project.name' }, + { input: 'MyAwesomeApp', expected: 'myawesomeapp' }, + + // Special characters + { input: '@scope/package', expected: 'scope-package', expectedPackage: '@scope/package' }, + { input: 'hello@world!test', expected: 'hello-world-test' }, + + // Multiple consecutive invalid chars + { input: 'my..project__name', expected: 'my-project-name', expectedPackage: 'my..project-name' }, + + // Leading/trailing invalid chars + { input: '.my-project.', expected: 'my-project', expectedPackage: 'my-project.' }, + { input: '---test---', expected: 'test', expectedPackage: '---test---' }, + + // Numbers + { input: 'project123', expected: 'project123' }, + { input: '123project', expected: '123project' }, + + // Empty/invalid fallback + { input: '___', expected: 'undefined-sv-name', expectedPackage: '-' }, + { input: '!@#$%', expected: 'undefined-sv-name', expectedPackage: '-' }, + { input: '', expected: 'undefined-sv-name' }, + + // Length limit (63 chars max) + { input: 'a'.repeat(70), expected: 'a'.repeat(63), expectedPackage: 'a'.repeat(70) }, + { + input: 'my-very-long-project-name-that-exceeds-the-limit-of-63-characters-allowed', + expected: 'my-very-long-project-name-that-exceeds-the-limit-of-63-characte', + expectedPackage: 'my-very-long-project-name-that-exceeds-the-limit-of-63-characters-allowed' + }, + + // Truncation trap: slice leaves trailing dash + { + input: 'a'.repeat(62) + '-b', + expected: 'a'.repeat(62), + expectedPackage: 'a'.repeat(62) + '-b' + }, + + // Spaces + { input: 'my cool project', expected: 'my-cool-project' }, + { input: ' spaced out ', expected: 'spaced-out' }, + + // Exact boundary (off-by-one check) + { input: 'a'.repeat(63), expected: 'a'.repeat(63) }, + + // Unicode / accents / emojis (replaced with dashes) + { input: 'piñata', expected: 'pi-ata' }, + { input: 'café', expected: 'caf', expectedPackage: 'caf-' }, + { input: 'cool 🚀 app', expected: 'cool-app', expectedPackage: 'cool---app' } +]; + +describe('sanitizeName wrangler', () => { + it.each(testCases)('sanitizes $input to $expected', ({ input, expected }) => { + expect(sanitizeName(input, 'wrangler')).toBe(expected); + }); +}); + +describe('sanitizeName package', () => { + it.each(testCases)('sanitizes $input to $expected', ({ input, expected, expectedPackage }) => { + expect(sanitizeName(input, 'package')).toBe(expectedPackage ?? expected); + }); +}); diff --git a/packages/sv/lib/create/index.ts b/packages/sv/lib/create/index.ts index 59b1ba8fb..849222414 100644 --- a/packages/sv/lib/create/index.ts +++ b/packages/sv/lib/create/index.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { mkdirp, copy, dist, getSharedFiles } from './utils.ts'; +import { sanitizeName } from '../core/sanitize.ts'; export type TemplateType = (typeof templateTypes)[number]; export type LanguageType = (typeof languageTypes)[number]; @@ -89,7 +90,7 @@ function write_common_files(cwd: string, options: Options, name: string) { pkg.dependencies = sort_keys(pkg.dependencies); pkg.devDependencies = sort_keys(pkg.devDependencies); - pkg.name = to_valid_package_name(name); + pkg.name = sanitizeName(name, 'package'); fs.writeFileSync(pkg_file, JSON.stringify(pkg, null, '\t') + '\n'); } @@ -158,12 +159,3 @@ function sort_files(files: Common['files']) { return same || different ? 0 : f1_more_generic ? -1 : 1; }); } - -function to_valid_package_name(name: string) { - return name - .trim() - .toLowerCase() - .replace(/\s+/g, '-') - .replace(/^[._]/, '') - .replace(/[^a-z0-9~.-]+/g, '-'); -}