diff --git a/.changeset/nervous-poems-clap.md b/.changeset/nervous-poems-clap.md new file mode 100644 index 000000000..ae2c939dc --- /dev/null +++ b/.changeset/nervous-poems-clap.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +fix(experimental): `@sveltejs/kit@next` projects now install, build and type-check - `#lib` imports instead of `$lib`, a `tsconfig` extending `$app/tsconfig`, and no options kit 3 removed diff --git a/.changeset/olive-geese-repeat.md b/.changeset/olive-geese-repeat.md new file mode 100644 index 000000000..3f9762e6c --- /dev/null +++ b/.changeset/olive-geese-repeat.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/sv-utils': patch +--- + +feat: add SvelteKit 3 helpers - `isKit3`, `resolveLibPrefix`, `libSubpathImports` diff --git a/packages/sv-utils/api-surface.md b/packages/sv-utils/api-surface.md index c34e1e572..919db761e 100644 --- a/packages/sv-utils/api-surface.md +++ b/packages/sv-utils/api-surface.md @@ -854,6 +854,16 @@ type DefineEnv = { }; declare function defineEnv({ sv, cwd, dependencyVersion }: DefineEnvContext): DefineEnv; + +declare function isKit3(kitRange: string | undefined): boolean; + +declare function resolveLibPrefix(kitRange: string | undefined): '#lib' | '$lib'; + +declare function libSubpathImports(libDir: string): Record; + +declare const KIT3_TSCONFIG = '$app/tsconfig'; + +declare const KIT3_TSCONFIG_DEFAULT: Record; type ColorInput = string | string[]; declare const color: { addon: (str: ColorInput) => string; @@ -886,6 +896,8 @@ export { COMMANDS, type Comments, type ConfigFileReader, + KIT3_TSCONFIG, + KIT3_TSCONFIG_DEFAULT, type Package, type SvelteAst, type SvelteConfigKind, @@ -905,9 +917,11 @@ export { downloadJson, fileExists, index_d_exports$2 as html, + isKit3, isVersionUnsupportedBelow, index_d_exports$3 as js, json_d_exports as json, + libSubpathImports, loadFile, loadPackageJson, minVersion, @@ -915,6 +929,7 @@ export { pnpm_d_exports as pnpm, resolveCommand, resolveCommandArray, + resolveLibPrefix, sanitizeName, saveFile, splitVersion, diff --git a/packages/sv-utils/src/env.ts b/packages/sv-utils/src/env.ts index 84a7d8c3e..1bca67158 100644 --- a/packages/sv-utils/src/env.ts +++ b/packages/sv-utils/src/env.ts @@ -1,4 +1,5 @@ import { fileExists } from './files.ts'; +import { isKit3 } from './kit3.ts'; import { coerceVersion } from './semver.ts'; import { svelteConfig, type ConfigFileReader, type SvFileApi } from './svelte-config.ts'; import type { AstTypes } from './tooling/index.ts'; @@ -15,10 +16,8 @@ export function resolveEnvMode({ explicitEnvFlag: boolean; }): EnvMode { if (!kitRange) return 'legacy'; - if (kitRange === 'next') return 'declared'; - const { major } = coerceVersion(kitRange); - if (major !== undefined && major >= 3) return 'declared'; - if (major === 2 && explicitEnvFlag) return 'declared'; + if (isKit3(kitRange)) return 'declared'; + if (coerceVersion(kitRange).major === 2 && explicitEnvFlag) return 'declared'; return 'legacy'; } @@ -110,23 +109,23 @@ function getOrCreateVariablesObject( * just call `define`/`reference` and never deal with the legacy-vs-declared distinction themselves. */ export function defineEnv({ sv, cwd, dependencyVersion }: DefineEnvContext): DefineEnv { - const mode = resolveEnvMode({ - kitRange: dependencyVersion('@sveltejs/kit'), - explicitEnvFlag: readExplicitEnvFlag(cwd) - }); + const kitRange = dependencyVersion('@sveltejs/kit'); + const mode = resolveEnvMode({ kitRange, explicitEnvFlag: readExplicitEnvFlag(cwd) }); const language = fileExists(cwd, 'tsconfig.json') ? 'ts' : 'js'; - return _bindEnv({ sv, mode, language }); + return _bindEnv({ sv, mode, language, kit3: isKit3(kitRange) }); } /** @internal The mode-resolved core, exported for filesystem-free tests. */ export function _bindEnv({ sv, mode, - language + language, + kit3 = false }: { sv: SvFileApi; mode: EnvMode; language: 'ts' | 'js'; + kit3?: boolean; }): DefineEnv { const declared = new Map(); @@ -138,7 +137,10 @@ export function _bindEnv({ const envPath = `src/env.${language}`; sv.file(envPath, (content) => transforms.script(({ ast, js }) => { - js.imports.addNamed(ast, { from: '@sveltejs/kit/hooks', imports: ['defineEnvVars'] }); + js.imports.addNamed(ast, { + from: kit3 ? '@sveltejs/kit/env' : '@sveltejs/kit/hooks', + imports: ['defineEnvVars'] + }); const variables = getOrCreateVariablesObject(ast, js); const entry = js.object.property(variables, { name: spec.name, diff --git a/packages/sv-utils/src/index.ts b/packages/sv-utils/src/index.ts index b91c7b553..1c9e286bd 100644 --- a/packages/sv-utils/src/index.ts +++ b/packages/sv-utils/src/index.ts @@ -87,6 +87,15 @@ export { // Env access (abstracts over legacy `$env/dynamic/*` vs declared `$app/env/*` + `src/env.ts`) export { defineEnv } from './env.ts'; +// Kit 3 specifics (version detection, `$lib` -> `#lib`, the generated tsconfig) +export { + KIT3_TSCONFIG, + KIT3_TSCONFIG_DEFAULT, + isKit3, + libSubpathImports, + resolveLibPrefix +} from './kit3.ts'; + // Terminal styling export { color } from './color.ts'; diff --git a/packages/sv-utils/src/kit3.ts b/packages/sv-utils/src/kit3.ts new file mode 100644 index 000000000..158751041 --- /dev/null +++ b/packages/sv-utils/src/kit3.ts @@ -0,0 +1,43 @@ +import { coerceVersion } from './semver.ts'; + +/** Whether a `@sveltejs/kit` range resolves to v3+, including the `next` dist-tag. */ +export function isKit3(kitRange: string | undefined): boolean { + if (!kitRange) return false; + if (kitRange === 'next') return true; + const { major } = coerceVersion(kitRange); + return major !== undefined && major >= 3; +} + +/** The prefix for `src/lib` imports. Kit 3 dropped the built-in `$lib` alias for `#lib` subpath imports. */ +export function resolveLibPrefix(kitRange: string | undefined): '#lib' | '$lib' { + return isKit3(kitRange) ? '#lib' : '$lib'; +} + +/** The `package.json#imports` entries backing `#lib`. `libDir` is workspace-relative, e.g. `src/lib`. */ +export function libSubpathImports(libDir: string): Record { + return { '#lib': `./${libDir}/index.js`, '#lib/*': `./${libDir}/*` }; +} + +/** The config kit 3 generates into `node_modules`, replacing `.svelte-kit/tsconfig.json`. */ +export const KIT3_TSCONFIG = '$app/tsconfig'; + +/** + * Options `$app/tsconfig` already sets. A local copy of the same value is noise, but a different + * value is a deliberate override and must stay - so only drop keys whose value matches. + */ +export const KIT3_TSCONFIG_DEFAULT: Record = { + allowImportingTsExtensions: true, + allowJs: true, + checkJs: true, + esModuleInterop: true, + forceConsistentCasingInFileNames: true, + isolatedModules: true, + module: 'esnext', + moduleDetection: 'force', + moduleResolution: 'bundler', + noEmit: true, + resolveJsonModule: true, + skipLibCheck: true, + target: 'esnext', + verbatimModuleSyntax: true +}; diff --git a/packages/sv/src/addons/better-auth.ts b/packages/sv/src/addons/better-auth.ts index ef5d21dc8..ea1910e24 100644 --- a/packages/sv/src/addons/better-auth.ts +++ b/packages/sv/src/addons/better-auth.ts @@ -1,5 +1,6 @@ import { log } from '@clack/prompts'; import { + resolveLibPrefix, type AstTypes, Walker, color, @@ -44,6 +45,7 @@ export default defineAddon({ runsAfter('experimental'); }, run: ({ sv, cwd, language, options, directory, dependencyVersion, file }) => { + const lib = resolveLibPrefix(dependencyVersion('@sveltejs/kit')); const svelteVersion = dependencyVersion('svelte'); const svelte5 = !!svelteVersion && coerceVersion(svelteVersion).major === 5; const [ts, s5] = createPrinter(language === 'ts', svelte5); @@ -122,7 +124,7 @@ export default defineAddon({ sv.file( `${directory.lib}/server/auth.${language}`, transforms.script(({ ast, comments, js }) => { - js.imports.addNamed(ast, { from: '$lib/server/db', imports: [d1 ? 'getDb' : 'db'] }); + 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: 'better-auth/svelte-kit', @@ -238,7 +240,7 @@ export default defineAddon({ sv.file( 'src/app.d.ts', transforms.script(({ ast, comments, js }) => { - if (d1) js.imports.addNamed(ast, { imports: ['createAuth'], from: '$lib/server/auth' }); + if (d1) js.imports.addNamed(ast, { imports: ['createAuth'], from: `${lib}/server/auth` }); js.imports.addNamed(ast, { imports: ['User', 'Session'], from: 'better-auth', @@ -286,7 +288,7 @@ export default defineAddon({ }); js.imports.addNamed(ast, { imports: [d1 ? 'createAuth' : 'auth'], - from: '$lib/server/auth' + from: `${lib}/server/auth` }); env.importEnv(ast, js, ['building']); @@ -413,7 +415,7 @@ export default defineAddon({ import { fail, redirect } from '@sveltejs/kit'; ${ts("import type { Actions } from './$types';")} ${ts("import type { PageServerLoad } from './$types';")} - ${!d1 ? "import { auth } from '$lib/server/auth';" : ''} + ${!d1 ? `import { auth } from '${lib}/server/auth';` : ''} ${needsAPIError ? "import { APIError } from 'better-auth/api';" : ''} export const load${ts(': PageServerLoad')} = (event) => { @@ -502,7 +504,7 @@ export default defineAddon({ import { redirect } from '@sveltejs/kit'; ${ts("import type { Actions } from './$types';")} ${ts("import type { PageServerLoad } from './$types';")} - ${!d1 ? "import { auth } from '$lib/server/auth';" : ''} + ${!d1 ? `import { auth } from '${lib}/server/auth';` : ''} export const load${ts(': PageServerLoad')} = (event) => { if (!event.locals.user) { diff --git a/packages/sv/src/addons/drizzle.ts b/packages/sv/src/addons/drizzle.ts index 1ff089565..f064b0bac 100644 --- a/packages/sv/src/addons/drizzle.ts +++ b/packages/sv/src/addons/drizzle.ts @@ -8,7 +8,8 @@ import { fileExists, createPrinter, svelteConfig, - defineEnv + defineEnv, + isKit3 } from '@sveltejs/sv-utils'; import crypto from 'node:crypto'; import fs from 'node:fs'; @@ -300,15 +301,33 @@ export default defineAddon({ }) ); - svelteConfig.edit({ sv, cwd }, ({ override, js }) => { - override({ - typescript: { - config: js.common.parseExpression( - `(config) => { config.include.push('../drizzle.config.${language}')}` - ) - } + // kit 3 dropped the `typescript.config` hook's `include` (and deprecates the hook itself), + // so the project's own ts/jsconfig has to cover the drizzle config + if (isKit3(dependencyVersion('@sveltejs/kit'))) { + const configFile = language === 'ts' ? 'tsconfig.json' : 'jsconfig.json'; + if (fileExists(cwd, configFile)) { + sv.file( + configFile, + transforms.json(({ data }) => { + const include: string[] = (data.include ??= ['src']); + if (!include.includes(`drizzle.config.${language}`)) { + include.push(`drizzle.config.${language}`); + } + }) + ); + } + } else { + // prior to kit 3 + svelteConfig.edit({ sv, cwd }, ({ override, js }) => { + override({ + typescript: { + config: js.common.parseExpression( + `(config) => { config.include.push('../drizzle.config.${language}')}` + ) + } + }); }); - }); + } sv.file( paths['database schema'], diff --git a/packages/sv/src/addons/experimental.ts b/packages/sv/src/addons/experimental.ts index dc07d1654..9cef4ad0e 100644 --- a/packages/sv/src/addons/experimental.ts +++ b/packages/sv/src/addons/experimental.ts @@ -1,4 +1,15 @@ -import { isVersionUnsupportedBelow, loadPackageJson, svelteConfig } from '@sveltejs/sv-utils'; +import { + KIT3_TSCONFIG, + KIT3_TSCONFIG_DEFAULT, + fileExists, + isVersionUnsupportedBelow, + libSubpathImports, + loadPackageJson, + svelteConfig, + transforms +} from '@sveltejs/sv-utils'; +import fs from 'node:fs'; +import path from 'node:path'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; // Single source of truth, keyed by flag name. `path` defaults to `experimental.`; `off` opts out @@ -8,10 +19,13 @@ const FEATURES: Record = { async: { label: 'async', hint: 'await in components', path: 'compilerOptions.experimental.async' }, // prettier-ignore remoteFunctions: { label: 'remote functions' }, explicitEnvironmentVariables: { label: 'explicit environment variables', hint: 'kit ^2 only', inNext: false }, // prettier-ignore - handleRenderingErrors: { label: 'rendering error boundaries' }, + handleRenderingErrors: { label: 'rendering error boundaries', hint: 'kit ^2 only', inNext: false }, // prettier-ignore forkPreloads: { label: 'forked preloading', off: true } }; +// files whose `$lib` imports are rewritten to `#lib` +const SOURCE_EXTENSIONS = ['.svelte', '.svelte.ts', '.svelte.js', '.ts', '.js', '.svx', '.md']; + // kit 3 raises these peer floors; bump only when the project is below them (never downgrade). const KIT3_PEERS = { vite: '^8.0.0', @@ -47,7 +61,7 @@ export default defineAddon({ setup: ({ runsAfter }) => runsAfter('sveltekitAdapter'), - run: ({ sv, cwd, options, language, dependencyVersion }) => { + run: ({ sv, cwd, options, language, directory, dependencyVersion }) => { const kitNext = options.versions.includes('kit'); if (kitNext) { @@ -65,6 +79,38 @@ export default defineAddon({ } } + if (kitNext) { + // kit 3 serves the generated config from `$app/tsconfig` and no longer supplies `include` + for (const name of ['tsconfig.json', 'jsconfig.json']) { + if (!fileExists(cwd, name)) continue; + sv.file( + name, + transforms.json(({ data }) => { + data.extends = KIT3_TSCONFIG; + data.include ??= [directory.src]; + for (const [key, value] of Object.entries(data.compilerOptions ?? {})) { + // a differing value is a deliberate override and stays + if (KIT3_TSCONFIG_DEFAULT[key] === value) delete data.compilerOptions[key]; + } + }) + ); + } + + // `$lib` is gone in favour of `#lib` subpath imports, which Vite resolves from `package.json` + sv.file( + 'package.json', + transforms.json(({ data }) => { + data.imports = { ...libSubpathImports(directory.lib), ...data.imports }; + }) + ); + // safe here: templates are already written and every add-on emitting `$lib` runs later + for (const relative of sourceFiles(cwd, directory.src)) { + sv.file(relative, (content) => + content.includes('$lib') ? content.replaceAll('$lib', '#lib') : false + ); + } + } + const config: Record = {}; for (const [name, f] of Object.entries(FEATURES)) { if (!options.features.includes(name)) continue; @@ -79,3 +125,17 @@ export default defineAddon({ svelteConfig.edit({ sv, cwd }, ({ override }) => override(config)); } }); + +/** Workspace-relative source files under `src`, for the `$lib` -> `#lib` rewrite. */ +function sourceFiles(cwd: string, src: string): string[] { + const root = path.resolve(cwd, src); + if (!fs.existsSync(root)) return []; + return fs + .readdirSync(root, { recursive: true }) + .map((entry) => path.join(src, entry as string)) + .filter( + (relative) => + SOURCE_EXTENSIONS.some((ext) => relative.endsWith(ext)) && + fs.statSync(path.resolve(cwd, relative)).isFile() + ); +} diff --git a/packages/sv/src/addons/paraglide.ts b/packages/sv/src/addons/paraglide.ts index 12048cf09..ccbfe1ddd 100644 --- a/packages/sv/src/addons/paraglide.ts +++ b/packages/sv/src/addons/paraglide.ts @@ -1,5 +1,13 @@ import { log } from '@clack/prompts'; -import { color, createPrinter, dedent, type SvelteAst, transforms } from '@sveltejs/sv-utils'; +import { + color, + createPrinter, + dedent, + isKit3, + resolveLibPrefix, + type SvelteAst, + transforms +} from '@sveltejs/sv-utils'; import { defineAddon, defineAddonOptions } from '../core/config.ts'; import { addToDemoPage } from './common.ts'; @@ -50,11 +58,17 @@ export default defineAddon({ shortDescription: 'i18n', homepage: 'https://inlang.com/m/gerre34r/library-inlang-paraglideJs', options, - setup: ({ isKit, unsupported }) => { + setup: ({ isKit, unsupported, runsAfter }) => { if (!isKit) unsupported('Requires SvelteKit'); + // it picks the kit-3 shape off the version `experimental` writes + runsAfter('experimental'); }, - run: ({ sv, options, file, language, directory }) => { + run: ({ sv, options, file, language, directory, dependencyVersion }) => { const [ts] = createPrinter(language === 'ts'); + const kitRange = dependencyVersion('@sveltejs/kit'); + const lib = resolveLibPrefix(kitRange); + // kit 3 renamed the `Pathname` route type to `Path` + const pathType = isKit3(kitRange) ? 'Path' : 'Pathname'; const paraglideOutDir = `${directory.lib}/paraglide`; sv.devDependency('@inlang/paraglide-js', '^2.18.2'); @@ -80,7 +94,7 @@ export default defineAddon({ `src/hooks.${language}`, transforms.script(({ ast, comments, js }) => { js.imports.addNamed(ast, { - from: '$lib/paraglide/runtime', + from: `${lib}/paraglide/runtime`, imports: ['deLocalizeUrl'] }); @@ -120,11 +134,11 @@ export default defineAddon({ `src/hooks.server.${language}`, transforms.script(({ ast, comments, js }) => { js.imports.addNamed(ast, { - from: '$lib/paraglide/server', + from: `${lib}/paraglide/server`, imports: ['paraglideMiddleware'] }); js.imports.addNamed(ast, { - from: '$lib/paraglide/runtime', + from: `${lib}/paraglide/runtime`, imports: ['getTextDirection'] }); @@ -196,13 +210,13 @@ export default defineAddon({ transforms.svelteScript({ language }, ({ ast, svelte, js }) => { js.imports.addNamed(ast.instance.content, { imports: ['locales', 'localizeHref'], - from: '$lib/paraglide/runtime' + from: `${lib}/paraglide/runtime` }); js.imports.addNamed(ast.instance.content, { imports: ['page'], from: '$app/state' }); js.imports.addNamed(ast.instance.content, { imports: ['resolve'], from: '$app/paths' }); if (language === 'ts') { js.imports.addNamed(ast.instance.content, { - imports: ['Pathname'], + imports: [pathType], from: '$app/types', isType: true }); @@ -212,7 +226,7 @@ export default defineAddon({ dedent`
{#each locales as locale (locale)} - {locale} + {locale} {/each}
`, { language } @@ -229,13 +243,13 @@ export default defineAddon({ transforms.svelteScript({ language }, ({ ast, svelte, js }) => { js.imports.addNamed(ast.instance.content, { imports: { m: 'm' }, - from: '$lib/paraglide/messages.js' + from: `${lib}/paraglide/messages.js` }); js.imports.addNamed(ast.instance.content, { imports: { setLocale: 'setLocale' }, - from: '$lib/paraglide/runtime' + from: `${lib}/paraglide/runtime` }); // add localized message diff --git a/packages/sv/src/addons/tests/experimental/test.ts b/packages/sv/src/addons/tests/experimental/test.ts index d42df2dbc..c849dc9d7 100644 --- a/packages/sv/src/addons/tests/experimental/test.ts +++ b/packages/sv/src/addons/tests/experimental/test.ts @@ -1,3 +1,4 @@ +import { parse } from '@sveltejs/sv-utils'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect } from 'vitest'; @@ -10,7 +11,7 @@ const { test, testCases } = setupTest( { kinds: [ { - // kit@next selected + every feature: explicitEnvironmentVariables must be dropped (gone in kit 3) + // kit@next selected + every feature: the flags removed in kit 3 must be dropped type: 'next-all', options: { [addonId]: { @@ -50,23 +51,42 @@ test.concurrent.for(testCases)('experimental $kind.type $variant', (testCase, { const source = readFileSync(config, 'utf8'); const pkg = readFileSync(join(cwd, 'package.json'), 'utf8'); + const tsconfigPath = ['tsconfig.json', 'jsconfig.json'] + .map((name) => join(cwd, name)) + .find((file) => existsSync(file)); + const tsconfig = tsconfigPath ? parse.json(readFileSync(tsconfigPath, 'utf8')).data : undefined; + if (testCase.kind.type === 'next-all') { expect(JSON.parse(pkg).devDependencies['@sveltejs/kit']).toBe('next'); + if (tsconfig) { + expect(tsconfig.extends).toBe('$app/tsconfig'); + expect(tsconfig.include).toStrictEqual(['src']); + expect(tsconfig.compilerOptions).not.toHaveProperty('checkJs'); + } // the adapter must follow kit onto its `next` line (it peers on kit's major) expect(JSON.parse(pkg).devDependencies['@sveltejs/adapter-auto']).toBe('next'); expect(source).toMatch('async: true'); expect(source).toMatch('remoteFunctions: true'); - expect(source).toMatch('handleRenderingErrors: true'); expect(source).toMatch('forkPreloads: true'); - // removed from experimental in kit 3, so it must be skipped when kit@next is chosen + // kit 3 no longer provides `$lib` on its own - sources move to `#lib` subpath imports + expect(JSON.parse(pkg).imports).toMatchObject({ '#lib': expect.any(String) }); + const libIndex = ['src/lib/index.ts', 'src/lib/index.js'] + .map((name) => join(cwd, name)) + .find((file) => existsSync(file))!; + expect(readFileSync(libIndex, 'utf8')).not.toMatch('$lib'); + expect(source).not.toMatch('alias'); + // removed from experimental in kit 3, so they must be skipped when kit@next is chosen expect(source).not.toMatch('explicitEnvironmentVariables'); + expect(source).not.toMatch('handleRenderingErrors'); } else if (testCase.kind.type === 'kit2-defaults') { expect(JSON.parse(pkg).devDependencies['@sveltejs/kit']).not.toBe('next'); + if (tsconfig) expect(tsconfig.extends).toBe('./.svelte-kit/tsconfig.json'); expect(source).toMatch('async: true'); expect(source).toMatch('remoteFunctions: true'); expect(source).toMatch('explicitEnvironmentVariables: true'); + // kit 2 provides `$lib` itself, so nothing is rewritten + expect(JSON.parse(pkg).imports).toBeUndefined(); // not selected -> absent expect(source).not.toMatch('forkPreloads'); - expect(source).not.toMatch('handleRenderingErrors'); } }); diff --git a/packages/sv/src/cli/tests/cli.ts b/packages/sv/src/cli/tests/cli.ts index 1fcb40ccf..0bac5f647 100644 --- a/packages/sv/src/cli/tests/cli.ts +++ b/packages/sv/src/cli/tests/cli.ts @@ -46,6 +46,17 @@ describe('cli', () => { 'experimental=versions:+features:explicitEnvironmentVariables' ] }, + { + // guards the `kit@next` shape against upstream churn: no snapshot (the point is that it + // installs, builds and type-checks, not what it looks like) + projectName: 'create-experimental-next', + snapshot: false, + args: [ + '--add', + 'drizzle=database:sqlite+sqlite:libsql', + 'experimental=versions:kit+features:async,remoteFunctions' + ] + }, { projectName: '@my-org/sv', template: 'addon', @@ -57,7 +68,17 @@ describe('cli', () => { 'should create a new project with name $projectName', { timeout: 240_000 }, async (testCase) => { - const { projectName, args, template = 'minimal' } = testCase; + const { + projectName, + args, + template = 'minimal', + snapshot = true + } = testCase as { + projectName: string; + args: string[]; + template?: string; + snapshot?: boolean; + }; const testOutputPath = path.relative( monoRepoPath, @@ -102,7 +123,9 @@ describe('cli', () => { 'snapshots', projectName ); - const relativeFiles = fs.readdirSync(testOutputPath, { recursive: true }) as string[]; + const relativeFiles = snapshot + ? (fs.readdirSync(testOutputPath, { recursive: true }) as string[]) + : []; // Files from ai-tools repo (skills, agents) change independently - // snapshot only file listings, not content @@ -200,6 +223,38 @@ describe('cli', () => { ).toBe(0); } + // `kit@next` moves fast - only a real install/build/check catches options it removed + if (projectName === 'create-experimental-next' && process.platform !== 'win32') { + const run = (cmd: string, cmdArgs: string[]) => + exec(cmd, cmdArgs, { nodeOptions: { stdio: 'pipe', cwd: testOutputPath } }); + + const install = await run('pnpm', [ + 'install', + '--no-frozen-lockfile', + // without this pnpm walks up and installs the sv monorepo instead of this project + '--ignore-workspace', + // ...which also loses the workspace's `minimumReleaseAgeExclude`, so a prerelease + // published in the last day would be refused + '--config.minimumReleaseAge=0' + ]); + expect( + install.exitCode, + `pnpm install failed:\n stdout: ${install.stdout}\n stderr: ${install.stderr}` + ).toBe(0); + + const build = await run('pnpm', ['build']); + expect( + build.exitCode, + `build failed on kit@next:\n stdout: ${build.stdout}\n stderr: ${build.stderr}` + ).toBe(0); + + const check = await run('pnpm', ['check']); + expect( + check.exitCode, + `svelte-check failed on kit@next:\n stdout: ${check.stdout}\n stderr: ${check.stderr}` + ).toBe(0); + } + if (projectName === 'create-experimental') { const read = (p: string) => fs.readFileSync(path.resolve(testOutputPath, p), 'utf-8'); const envFile = read('src/env.ts'); diff --git a/packages/sv/src/core/engine.ts b/packages/sv/src/core/engine.ts index 38d0bf554..8540c56ba 100644 --- a/packages/sv/src/core/engine.ts +++ b/packages/sv/src/core/engine.ts @@ -319,14 +319,38 @@ async function runAddon({ addon, loaded, multiple, workspace, workspaceOptions } }; } -// orders addons by putting addons that don't require any other addon in the front. -// This is a drastic simplification, as this could still cause some inconvenient circumstances, -// but works for now in contrary to the previous implementation +/** + * Orders add-ons so every `runsAfter` is honoured, keeping the original order between add-ons that + * don't constrain each other. Cycles and unknown ids are ignored rather than fatal - an add-on that + * can't be placed simply keeps its position. + */ export function orderAddons( addons: Array>, setupResults: Record ): Array> { - return addons.sort((a, b) => { - return setupResults[a.id]?.runsAfter?.length - setupResults[b.id]?.runsAfter?.length; - }); + const byId = new Map(addons.map((addon) => [addon.id, addon])); + const ordered: Array> = []; + const placed = new Set(); + const visiting = new Set(); + + const place = (addon: Addon) => { + if (placed.has(addon.id) || visiting.has(addon.id)) return; + visiting.add(addon.id); + for (const id of setupResults[addon.id]?.runsAfter ?? []) { + const dependency = byId.get(id); + if (dependency) place(dependency); + } + visiting.delete(addon.id); + placed.add(addon.id); + ordered.push(addon); + }; + + // seeded with the "fewest constraints first" order this used to rely on, so add-ons that don't + // constrain each other keep the relative order they already had + const seeded = [...addons].sort( + (a, b) => + (setupResults[a.id]?.runsAfter?.length ?? 0) - (setupResults[b.id]?.runsAfter?.length ?? 0) + ); + for (const addon of seeded) place(addon); + return ordered; }