diff --git a/.changeset/lazy-svelte-config.md b/.changeset/lazy-svelte-config.md new file mode 100644 index 0000000000..61f23c4ea9 --- /dev/null +++ b/.changeset/lazy-svelte-config.md @@ -0,0 +1,149 @@ +--- +'@fuzdev/gro': minor +--- + +feat: read the Svelte config through Vite, lazily + +Gro read `svelte.config.js` eagerly at module scope, so every invocation paid for it +twice - once on the main thread and once on the Node loader's worker thread - even for +tasks that never touch it. The main thread now loads it on demand and memoizes it, and +it's resolved from the project's Vite config rather than read from `svelte.config.js` +directly. + +It's the same `vite.resolveConfig` call SvelteKit's own `load_config` makes, so Gro sees +exactly what SvelteKit sees: the inline options when a project passes them to +`sveltekit()`, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its +own. That means projects keeping a `svelte.config.js` are unaffected - Vite reads it for +them - while projects configuring Svelte inline in `vite.config.ts` now work, along with +SvelteKit's own defaults instead of Gro's hand-rolled fallbacks. A Vite config that +fails to resolve now throws instead of being silently ignored. + +Gro reads the project in the cwd and only that project. SvelteKit resolves its `files` +and `env.dir` against its own cwd rather than the Vite `root` it's handed, so a +directory parameter could only ever be half-honored, and there isn't one. + +The Node loader resolves the config the same way as the main thread. It runs on a +worker thread, where `process.chdir` is unavailable, but Vite's config resolution +doesn't need it - only `@sveltejs/load-config` does, which is why Gro calls +`vite.resolveConfig` directly and drops that dependency. + +The loader can't load it on demand: the hooks thread's own imports go back through its +own `resolve` hook, so a hook that awaited the load it is part of re-enters itself until +the stack blows. But `resolve` is the only hook under that constraint, and the only field +it reads is `alias` - everything else is read inside `load`, which awaits safely. So the +loader caches the alias map at `.gro/svelte_config.json` and defers the rest of the config +to `load`. On a hit it resolves nothing; on a miss it loads at module scope as before, +before the hooks go live, and writes the cache. + +The `Filer` reads that cache too, since aliases are all it needs to resolve the import +specifiers it tracks - so `gro gen` no longer resolves the config on the main thread +either, and the Filer maps specifiers through the very map the loader uses rather than +through one that merely agrees with it. + +An alias map is plain strings, so nothing is lost to serialization - which is why the new +`svelte_config_cache.ts` caches a slice of the config rather than the config, whose +preprocessors and `compilerOptions.warningFilter` are functions. The key is the mtime and +size of every Vite and Svelte config filename plus `package.json`, absent ones included so +that adding a config invalidates too. The blind spot is a Vite config that imports the +module declaring `kit.alias` or `kit.files.lib`: editing that module doesn't invalidate, +and the stale alias surfaces as an unresolved import rather than a bad compile. `gro clean` +clears it, along with the rest of `.gro`. + +Measured in this repo, `gro --version` goes from ~1.24s to ~0.98s. A resolution costs +~700-900ms on its own, but the hooks thread overlaps with main-thread startup, so an +invocation only sheds the part that didn't overlap. + +Resolving a Vite config is more than a read - it runs every plugin's `config` and +`configResolved` hooks, so it inherits their side effects. SvelteKit's rewrite of +`.svelte-kit/env.d.ts` is one, and Vite writing `process.env.NODE_ENV` when it's unset +is another. Gro restores `NODE_ENV` around the call, so the `development` Vite would +leave behind no longer reaches the `vite build` that `gro build` spawns. + +The `compilerOptions` read back for a plain Svelte project are `vite-plugin-svelte`'s +*resolved* options rather than the user's, so they arrive with that plugin's own `css`, +`dev`, and `hmr` mixed in, resolved for the `build`/`production` pass. It deletes +`generate`, so Gro's server default survives, and the loader always sets `dev` itself. +SvelteKit projects are unaffected - their `api.options` is the Svelte config as authored. + +Vite is now an optional peer dependency. A project with no Vite config gets the +conventional defaults rather than an error, so non-Vite projects keep working. Note that +a `svelte.config.js` is only read through Vite, so a project with one but no Vite config +gets those defaults too, not its own preprocessors, aliases, or compiler options. + +Both ways of ending up there now warn, since the Svelte config looks like it's +configuring the project while being ignored: no Vite config to read it through, and a +Vite config that configures no Svelte plugin. The second is the likelier one - a +`vite.config.ts` that only sets up Vitest, alongside a `svelte.config.js` doing the real +configuring, lands exactly there. Unlike SvelteKit, Gro doesn't fall back to importing +the Svelte config itself. A project with no Svelte config stays quiet: it's configuring +nothing to ignore. + +A project that does have a Vite config but no Vite installed throws rather than falling +back, because falling back would mean compiling against the wrong config in silence. + +The warnings go through the exported `svelte_config_log`, since this runs where there's +no logger to pass in - set `svelte_config_log.level = 'off'` to silence them. + +Also fixes `parse_svelte_config` mutating the `compilerOptions` of the config passed to +it, and normalizes `kit.env.dir` to a project-relative path - it's serialized into the +generated `$env/dynamic/*` modules, so the absolute one Vite resolution produces would +bake the build machine's directory into server bundles. + +Breaking changes: + +- `TaskContext.svelte_config` and `GenContext.svelte_config` are now + `Promise` - `await` them +- `default_svelte_config` is replaced by `load_default_svelte_config()`, which takes no + arguments +- `parse_svelte_config` takes `{svelte_config}` instead of `{dir_or_config}`, to parse an + already-loaded config; omit it to resolve the project's +- `load_svelte_config` resolves the project's Vite config and takes no arguments; + `GroConfig.svelte_config_filename` is removed, since Vite picks its own config file +- `has_sveltekit_app` is synchronous and takes a `PackageJson`, detecting + `@sveltejs/kit` as a dependency rather than the presence of `svelte.config.js` +- `has_sveltekit_library` no longer takes a `ParsedSvelteConfig` - it reads the memoized + one, and only after checking the `@sveltejs/package` dependency, so a project that + isn't a library never reads the Svelte config. Both it and `has_sveltekit_app` also + drop their unused `dep_name` parameter and use the dependency-name constants directly +- both detect through `dependencies` and `devDependencies` only. A peer dep declares what + a package works alongside, not what it is, so counting it would run `vite build` over a + library that peers on SvelteKit and has no app. `package_json_has_dependency` takes a + third `include_peer` param for this, defaulting to `true` as before +- `GenContext` is built by the new `create_gen_context`, shared by generation and + dependency resolution so they can't drift +- `ROUTES_DIRNAME` is removed +- `SVELTE_CONFIG_FILENAME` and `VITE_CONFIG_FILENAME` are replaced by + `SVELTE_CONFIG_FILENAMES` and `VITE_CONFIG_FILENAMES`, every filename SvelteKit and + Vite accept for their configs. `gro format` now formats whichever of them a project + has, not only `svelte.config.js` and `vite.config.ts` +- `paths.lib`, `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` are the conventional `src/lib` + rather than derived from `kit.files.lib`, so that `paths` stays free of the config. + Everything that has to honor a customized `files.lib` reads `lib_path` off + `ParsedSvelteConfig` instead - the `package.json` exports automation, the server + plugin's entry point and `outbase`, and `has_sveltekit_library`. Point `task_root_dirs` + at it in `gro.config.ts` if tasks live there +- `LIB_DIRNAME`, `LIB_PATH`, and `LIB_DIR` move from `paths.ts` to `constants.ts`, which + is where a value that reads no config belongs - they only lived in `paths.ts` because + they used to be derived from `kit.files.lib` +- `package_json_sync`'s `exports_dir` param no longer defaults to `paths.lib`; when + omitted it's the Svelte config's `lib_path`, which `has_sveltekit_library` has already + resolved by then. Searching the conventional `src/lib` for a project that moved its lib + directory found nothing and replaced the whole `exports` map with a single entry +- `gro_plugin_server`'s `SERVER_SOURCE_ID` is replaced by `SERVER_SOURCE_PATH` (relative + to the lib directory) and `to_server_source_id(lib_path)`. Its `entry_points` and + `outpaths` defaults now resolve in `setup` rather than at plugin creation, since + reading `lib_path` is async; the outpaths default is exported as `to_default_outpaths`. + `has_server()` takes an optional path and defaults to the configured lib directory +- `MODULE_PATH_LIB_PREFIX` is always `$lib/`, matching SvelteKit, which names the alias + `$lib` no matter where `files.lib` points - it was previously derived from `files.lib` +- the default config detects plugins inside `plugins()` instead of when the config + loads, so tasks other than `dev` and `build` no longer trigger detection +- `CreateGroConfig` takes only `base_config` - its second `ParsedSvelteConfig` parameter + was never passed, and passing one would defeat the lazy load +- `esbuild_plugin_svelte`'s default `svelte_compile_options` is + `SVELTE_COMPILE_OPTIONS_DEFAULT` rather than the project's `compilerOptions`, because + the default can't read the config synchronously - pass `svelte_compile_options` from a + `ParsedSvelteConfig` to honor them +- `dev` and `build` widen their `TaskContext` with the new `to_plugin_context` instead of + spreading it, because spreading calls the lazy `svelte_config` getter and resolves the + config for plugin sets that never read it diff --git a/CLAUDE.md b/CLAUDE.md index 49ec1e15c8..c9e1a889b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ Task context: interface TaskContext { args: TArgs; config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; @@ -138,17 +138,26 @@ Capabilities: - JSON imports (any extension with `type: 'json'` import attribute) - Raw text imports (`.css`, `.svg`, or `?raw` suffix) +Svelte config: the `resolve` hook needs the `alias` map before anything can be +imported, and can't await it — the hooks thread's own imports re-enter its own +hooks. So the alias map is cached at `.gro/svelte_config.json` +([`svelte_config_cache.ts`](src/lib/svelte_config_cache.ts)), keyed by the mtime +and size of the Vite and Svelte config filenames plus `package.json`. Everything +else the loader reads from the config is awaited inside `load`, which most +invocations never reach. A cache miss resolves the whole config at module scope +and rewrites. + SvelteKit module shims: Best-effort shims for tasks/tests/servers, not identical to actual SvelteKit modules: -- `$lib/*` → resolved via svelte.config.js alias to `src/lib/` +- `$lib/*` → resolved via the SvelteKit `$lib` alias to `src/lib/` - `$env/static/public` → reads `PUBLIC_*` vars from `.env` - `$env/static/private` → reads all vars from `.env` - `$env/dynamic/public` → `process.env` with `PUBLIC_*` filtering - `$env/dynamic/private` → full `process.env` - `$app/environment` → `{dev: true, browser: false, building: false, version: ''}` -- `$app/paths` → `{base: '', assets: ''}` from svelte.config.js +- `$app/paths` → `{base: '', assets: ''}` from the resolved Svelte config ### Code generation @@ -193,7 +202,7 @@ Gen context: ```typescript interface GenContext { config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; @@ -287,11 +296,14 @@ Optional `gro.config.ts` at project root exports `CreateGroConfig` function or config object. If absent, uses default config from `src/lib/gro.config.default.ts`. -Default config behavior: Auto-detects project type by checking filesystem: +Default config behavior: Auto-detects project type from `package.json` and the +filesystem, deferred until plugins are created: -- `svelte.config.js` → enables `gro_plugin_sveltekit_app` -- `svelte.config.js` + `@sveltejs/package` in package.json + `src/lib/` → enables `gro_plugin_sveltekit_library` -- `src/lib/server/server.ts` → enables `gro_plugin_server` +- `@sveltejs/kit` in package.json deps or dev deps (not peer deps) → enables + `gro_plugin_sveltekit_app` +- `@sveltejs/package` in package.json + the lib directory → enables + `gro_plugin_sveltekit_library` +- `server/server.ts` in the lib directory → enables `gro_plugin_server` - Always enables `gro_plugin_gen` Config interface: @@ -342,7 +354,7 @@ export default config; - Gen files: `*.gen.*` anywhere in `src/` (pattern: `.gen.` substring) - Test files: `*.test.ts` anywhere (run by Vitest) - Config: `gro.config.ts` at project root -- SvelteKit config: `svelte.config.js` at project root +- Vite config: `vite.config.*` at project root - the Svelte config is read through it Exclusions (configurable via `search_filters`): diff --git a/package-lock.json b/package-lock.json index 30b999ef8e..ac8f5caeb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "svelte": "^5", "svelte-docinfo": ">=0.4.1", "typescript": "^5", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "^3 || ^4", "zod": "^4" }, @@ -80,6 +81,9 @@ "svelte-docinfo": { "optional": true }, + "vite": { + "optional": true + }, "vitest": { "optional": true } @@ -1828,16 +1832,6 @@ } } }, - "node_modules/@sveltejs/load-config": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", - "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, "node_modules/@sveltejs/package": { "version": "2.5.8", "resolved": "https://registry.npmjs.org/@sveltejs/package/-/package-2.5.8.tgz", @@ -3992,6 +3986,16 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-check/node_modules/@sveltejs/load-config": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.1.1.tgz", + "integrity": "sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/svelte-check/node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", diff --git a/package.json b/package.json index cf798c2c1d..d3c4e3d890 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "svelte": "^5", "svelte-docinfo": ">=0.4.1", "typescript": "^5", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "^3 || ^4", "zod": "^4" }, @@ -74,6 +75,9 @@ "svelte-docinfo": { "optional": true }, + "vite": { + "optional": true + }, "vitest": { "optional": true } diff --git a/src/docs/gen.md b/src/docs/gen.md index 24927cfba4..b174f78317 100644 --- a/src/docs/gen.md +++ b/src/docs/gen.md @@ -116,7 +116,7 @@ The generate function receives a `GenContext` object: ```ts export interface GenContext { config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; diff --git a/src/docs/gro_plugin_sveltekit_app.md b/src/docs/gro_plugin_sveltekit_app.md index 9b354a49b7..23a8c06c5c 100644 --- a/src/docs/gro_plugin_sveltekit_app.md +++ b/src/docs/gro_plugin_sveltekit_app.md @@ -10,7 +10,8 @@ import {gro_plugin_sveltekit_app} from '@fuzdev/gro/gro_plugin_sveltekit_app.ts' const config: CreateGroConfig = async (cfg) => { cfg.plugins = async () => [ - // included in the default config for SvelteKit projects with src/routes/ + // included in the default config when `@sveltejs/kit` is a dep or dev dep + // (a peer dep doesn't count - it declares what a package works with, not what it is) gro_plugin_sveltekit_app(), ]; return cfg; diff --git a/src/docs/gro_plugin_sveltekit_library.md b/src/docs/gro_plugin_sveltekit_library.md index 1157f40eb9..c82c65af73 100644 --- a/src/docs/gro_plugin_sveltekit_library.md +++ b/src/docs/gro_plugin_sveltekit_library.md @@ -9,9 +9,15 @@ to build libraries from `src/lib/` for publishing to npm. The [default config](/src/lib/gro.config.default.ts) enables this plugin when all three conditions are met: -1. `svelte.config.js` exists at the project root -2. `src/lib/` directory exists (or the path configured in `svelte.config.js`) -3. `@sveltejs/package` is listed in `package.json` dependencies +1. `@sveltejs/kit` is a dependency in `package.json` +2. `@sveltejs/package` is listed in `package.json` dependencies +3. `src/lib/` directory exists (or the path configured by `kit.files.lib`) + +They're checked in that order, so a project that isn't a library never reads the +Svelte config, which is the only one of the three that costs anything. + +Only `dependencies` and `devDependencies` count for the first two - a peer dep +declares what a package works alongside, not what the package itself is. Install to enable: diff --git a/src/docs/task.md b/src/docs/task.md index 499683ec6e..3530acc811 100644 --- a/src/docs/task.md +++ b/src/docs/task.md @@ -163,7 +163,7 @@ import type {TaskContext} from '@fuzdev/gro'; export interface TaskContext { args: TArgs; config: GroConfig; - svelte_config: ParsedSvelteConfig; + svelte_config: Promise; log: Logger; timings: Timings; invoke_task: InvokeTask; diff --git a/src/lib/build.task.ts b/src/lib/build.task.ts index 9b50cfd60f..ba4531bbca 100644 --- a/src/lib/build.task.ts +++ b/src/lib/build.task.ts @@ -6,7 +6,7 @@ import { join } from 'node:path'; import { fs_exists } from '@fuzdev/fuz_util/fs.ts'; import { TaskError, type Task } from './task.ts'; -import { Plugins } from './plugin.ts'; +import { Plugins, to_plugin_context } from './plugin.ts'; import { clean_fs } from './clean_fs.ts'; import { is_build_cache_valid, @@ -111,7 +111,7 @@ export const task: Task = { await clean_fs({ build_dist: true }); } - const plugins = await Plugins.create({ ...ctx, dev: false, watch: false }); + const plugins = await Plugins.create(to_plugin_context(ctx, false, false)); await plugins.setup(); await plugins.adapt(); await plugins.teardown(); diff --git a/src/lib/changeset.task.ts b/src/lib/changeset.task.ts index 99801d05cd..db9736802a 100644 --- a/src/lib/changeset.task.ts +++ b/src/lib/changeset.task.ts @@ -84,7 +84,6 @@ export const task: Task = { changeset_cli }, log, - svelte_config, config } = ctx; @@ -102,7 +101,7 @@ export const task: Task = { const package_json = await package_json_load(); - const has_sveltekit_library_result = await has_sveltekit_library(package_json, svelte_config); + const has_sveltekit_library_result = await has_sveltekit_library(package_json); if (!has_sveltekit_library_result.ok) { throw new TaskError( 'Failed to find SvelteKit library: ' + has_sveltekit_library_result.message diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7221e208e2..78cfce6f87 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -19,8 +19,44 @@ export const GRO_DIR = GRO_DIRNAME + '/'; /** @trailing_slash */ export const GRO_DEV_DIR = GRO_DEV_DIRNAME + '/'; export const GRO_CONFIG_FILENAME = 'gro.config.ts'; -export const SVELTE_CONFIG_FILENAME = 'svelte.config.js'; -export const VITE_CONFIG_FILENAME = 'vite.config.ts'; +/** + * The conventional library directory, not the SvelteKit `kit.files.lib`, which lives here + * rather than in `./paths.ts` because it's a constant that reads no config. Code that has to + * honor a customized `files.lib` reads `lib_path` off a `ParsedSvelteConfig` instead, and + * projects that move it can point `task_root_dirs` at the new location in `gro.config.ts`. + */ +export const LIB_DIRNAME = 'lib'; +export const LIB_PATH = SOURCE_DIR + LIB_DIRNAME; +/** @trailing_slash */ +export const LIB_DIR = LIB_PATH + '/'; +/** + * Every filename SvelteKit loads its config from, in SvelteKit's own precedence order. + * Gro reads the Svelte config through Vite, never from these directly, but SvelteKit still + * loads one when `sveltekit()` gets no inline options - so they're project files that Gro + * formats, watches for a config it can't read through Vite, and keys its config cache on. + * @see https://svelte.dev/docs/kit/configuration + */ +export const SVELTE_CONFIG_FILENAMES = ['svelte.config.js', 'svelte.config.ts']; +/** + * SvelteKit's alias for the library directory. + * Always `$lib` no matter where `files.lib` points. + * @see https://svelte.dev/docs/kit/configuration#files + */ +export const SVELTEKIT_LIB_ALIAS = '$lib'; +/** + * Every filename Vite picks up as its config, in Vite's own precedence order. + * Which one wins is Vite's call, so Gro treats them as a set rather than privileging + * one extension - it detects a Vite config with all of them, and formats all of them. + * @see https://vite.dev/config/ + */ +export const VITE_CONFIG_FILENAMES = [ + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.ts', + 'vite.config.cjs', + 'vite.config.mts', + 'vite.config.cts' +]; export const NODE_MODULES_DIRNAME = 'node_modules'; export const PACKAGE_JSON_FILENAME = 'package.json'; export const LOCKFILE_FILENAME = 'package-lock.json'; @@ -49,5 +85,6 @@ export const SVELTEKIT_CLI = 'svelte-kit'; export const SVELTE_CHECK_CLI = 'svelte-check'; export const SVELTE_PACKAGE_CLI = 'svelte-package'; export const SVELTE_PACKAGE_DEP_NAME = '@sveltejs/package'; +export const SVELTEKIT_DEP_NAME = '@sveltejs/kit'; export const VITE_CLI = 'vite'; export const VITEST_CLI = 'vitest'; diff --git a/src/lib/dev.task.ts b/src/lib/dev.task.ts index 88ab2df368..a59109aeae 100644 --- a/src/lib/dev.task.ts +++ b/src/lib/dev.task.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { Task } from './task.ts'; -import { Plugins, type PluginContext } from './plugin.ts'; +import { Plugins, to_plugin_context, type PluginContext } from './plugin.ts'; import { clean_fs } from './clean_fs.ts'; /** @nodocs */ @@ -36,7 +36,7 @@ export const task: Task = { await invoke_task('sync', { install, gen: !watch }); } - const plugins = await Plugins.create({ ...ctx, dev: true, watch }); + const plugins = await Plugins.create(to_plugin_context(ctx, true, watch)); await plugins.setup(); if (!watch) { await plugins.teardown(); diff --git a/src/lib/esbuild_plugin_svelte.ts b/src/lib/esbuild_plugin_svelte.ts index 1ce1be7613..15e1fddba6 100644 --- a/src/lib/esbuild_plugin_svelte.ts +++ b/src/lib/esbuild_plugin_svelte.ts @@ -12,7 +12,7 @@ import { relative } from 'node:path'; import { to_define_import_meta_env, default_ts_transform_options } from './esbuild_helpers.ts'; import { - default_svelte_config, + SVELTE_COMPILE_OPTIONS_DEFAULT, to_default_compile_module_options, type ParsedSvelteConfig } from './svelte_config.ts'; @@ -22,6 +22,12 @@ export interface EsbuildPluginSvelteOptions { dev: boolean; base_url: ParsedSvelteConfig['base_url']; dir?: string; + /** + * Defaults to Gro's baseline, not the project's `compilerOptions` - + * reading those is async, so callers pass `svelte_compile_options` + * off a `ParsedSvelteConfig` to honor them. + * @default `SVELTE_COMPILE_OPTIONS_DEFAULT` + */ svelte_compile_options?: CompileOptions; svelte_compile_module_options?: ModuleCompileOptions; svelte_preprocessors?: PreprocessorGroup | Array; @@ -34,7 +40,7 @@ export const esbuild_plugin_svelte = (options: EsbuildPluginSvelteOptions): esbu dev, base_url, dir = process.cwd(), - svelte_compile_options = default_svelte_config.svelte_compile_options, + svelte_compile_options = SVELTE_COMPILE_OPTIONS_DEFAULT, svelte_compile_module_options = to_default_compile_module_options(svelte_compile_options), svelte_preprocessors, ts_transform_options = default_ts_transform_options, diff --git a/src/lib/esbuild_plugin_sveltekit_shim_alias.ts b/src/lib/esbuild_plugin_sveltekit_shim_alias.ts index 928c1b1ce5..a718e84966 100644 --- a/src/lib/esbuild_plugin_sveltekit_shim_alias.ts +++ b/src/lib/esbuild_plugin_sveltekit_shim_alias.ts @@ -2,6 +2,8 @@ import type * as esbuild from 'esbuild'; import { escape_regexp } from '@fuzdev/fuz_util/regexp.ts'; import { join } from 'node:path'; +import { LIB_PATH, SVELTEKIT_LIB_ALIAS } from './constants.ts'; + export interface EsbuildPluginSveltekitShimAliasOptions { dir?: string; alias?: Record; @@ -13,7 +15,9 @@ export const esbuild_plugin_sveltekit_shim_alias = ({ }: EsbuildPluginSveltekitShimAliasOptions): esbuild.Plugin => ({ name: 'sveltekit_shim_alias', setup: (build) => { - const aliases: Record = { $lib: 'src/lib', ...alias }; + // The `$lib` fallback is for callers that pass no `alias` at all - + // a `ParsedSvelteConfig` always carries one, pointed at its `files.lib`. + const aliases: Record = { [SVELTEKIT_LIB_ALIAS]: LIB_PATH, ...alias }; // Create a Go-compatible regexp const filter = new RegExp(`^(?:${Object.keys(aliases).map(escape_regexp).join('|')})`); build.onResolve({ filter }, async (args) => { diff --git a/src/lib/filer.ts b/src/lib/filer.ts index e65e979743..be54d8a2a0 100644 --- a/src/lib/filer.ts +++ b/src/lib/filer.ts @@ -19,12 +19,28 @@ import { import { paths } from './paths.ts'; import { parse_imports } from './parse_imports.ts'; import { resolve_specifier } from './resolve_specifier.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; +import { svelte_config_cache_read, svelte_config_cache_stamps } from './svelte_config_cache.ts'; import { map_sveltekit_aliases } from './sveltekit_helpers.ts'; import { SVELTEKIT_GLOBAL_SPECIFIER } from './constants.ts'; import type { Disknode } from './disknode.ts'; -const aliases = Object.entries(default_svelte_config.alias); +let aliases: Array<[string, string]> | undefined; + +/** + * Loaded on demand so constructing a `Filer` doesn't read the Svelte config, + * and memoized because this is called for every import specifier of every changed file. + * + * Prefers the loader's alias cache, which is keyed on the config files' state and so is + * valid whoever wrote it. That saves a full Vite config resolution for tasks like `gro gen` + * that need nothing else from the config, and it makes the Filer resolve specifiers through + * the same map the loader does rather than through one that merely agrees with it. + */ +const load_aliases = async (): Promise> => + (aliases ??= Object.entries( + svelte_config_cache_read(svelte_config_cache_stamps())?.alias ?? + (await load_default_svelte_config()).alias + )); export type OnFilerChange = (change: WatcherChange, disknode: Disknode) => void; @@ -61,7 +77,7 @@ export class Filer { // TODO for package.json maybe another array of files/dirs to watch to invalidate everything? // or instead of that, think of taking an array of config objects that can specify invalidation rules, // so package.json would be configured differently than ./src, and we could add a default with - // package.json/gro.config.ts/tsconfig.json/svelte.config.js/vite.config.ts to invalidate everything + // package.json/gro.config.ts/tsconfig.json/vite.config.ts to invalidate everything this.#log = options.log; } get inited(): boolean { @@ -272,7 +288,7 @@ export class Filer { } for (const specifier of imported) { if (SVELTEKIT_GLOBAL_SPECIFIER.test(specifier)) continue; - const path = map_sveltekit_aliases(specifier, aliases); + const path = map_sveltekit_aliases(specifier, await load_aliases()); let path_id; // TODO replace `resolve_specifier` with `import.meta.resolve` for local specifiers too diff --git a/src/lib/format_directory.ts b/src/lib/format_directory.ts index 223d40ffa6..c69bd51ede 100644 --- a/src/lib/format_directory.ts +++ b/src/lib/format_directory.ts @@ -6,7 +6,11 @@ import { globSync, readFileSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import { isAbsolute, join, resolve } from 'node:path'; -import { GRO_CONFIG_FILENAME, SVELTE_CONFIG_FILENAME, VITE_CONFIG_FILENAME } from './constants.ts'; +import { + GRO_CONFIG_FILENAME, + SVELTE_CONFIG_FILENAMES, + VITE_CONFIG_FILENAMES +} from './constants.ts'; import { format_file } from './format_file.ts'; import { paths } from './paths.ts'; @@ -20,11 +24,18 @@ const FORMATTABLE_MATCHER = /\.(ts|mts|cts|js|mjs|cjs|svelte|css)$/; /** * Root-level files formatted alongside `paths.source`. + * Every Svelte and Vite config filename is listed rather than just the `.js`/`.ts` one, + * since which extension a project uses is SvelteKit's and Vite's call; the ones a project + * doesn't have are skipped when read, the same as any other default root file it lacks. * `package.json` is intentionally omitted — `gro sync` owns its serialization * via `package_json_serialize` (2-space, matching the npm convention). * `tsconfig.json` is omitted because the sweep no longer formats json. */ -const ROOT_FILES_DEFAULT = [GRO_CONFIG_FILENAME, SVELTE_CONFIG_FILENAME, VITE_CONFIG_FILENAME]; +const ROOT_FILES_DEFAULT = [ + GRO_CONFIG_FILENAME, + ...SVELTE_CONFIG_FILENAMES, + ...VITE_CONFIG_FILENAMES +]; /** * Root-level ignore files for the format sweep (gitignore-style patterns). diff --git a/src/lib/gen.ts b/src/lib/gen.ts index fc3415e069..148a871b68 100644 --- a/src/lib/gen.ts +++ b/src/lib/gen.ts @@ -8,9 +8,9 @@ import type { PathId } from '@fuzdev/fuz_util/path.ts'; import { each_concurrent, map_concurrent } from '@fuzdev/fuz_util/async.ts'; import { fs_search } from '@fuzdev/fuz_util/fs.ts'; -import { print_path } from './paths.ts'; +import { print_path, to_root_path } from './paths.ts'; import type { GroConfig } from './gro_config.ts'; -import type { ParsedSvelteConfig } from './svelte_config.ts'; +import { load_default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; import { load_modules, type LoadModulesFailure, type ModuleMeta } from './modules.ts'; import { InputPath, @@ -63,7 +63,11 @@ export interface GenConfig { export interface GenContext { config: GroConfig; - svelte_config: ParsedSvelteConfig; + /** + * Resolved on first access, so genfiles that don't touch it + * never pay to read the SvelteKit config. + */ + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; @@ -84,6 +88,34 @@ export interface GenContext { changed_file_id: PathId | undefined; } +/** + * The `GenContext` values that are the same for every genfile in a run, + * as opposed to the per-genfile ones `create_gen_context` derives. + */ +export type GenContextBase = Pick< + GenContext, + 'config' | 'filer' | 'log' | 'timings' | 'invoke_task' +>; + +/** + * Builds a `GenContext` for one genfile, so generating and resolving dependencies + * see the same context - notably the lazy `svelte_config`, which is a getter + * rather than a value so a genfile that ignores it never resolves it. + */ +export const create_gen_context = ( + base: GenContextBase, + origin_id: PathId, + changed_file_id?: PathId +): GenContext => ({ + ...base, + get svelte_config() { + return load_default_svelte_config(); + }, + origin_id, + origin_path: to_root_path(origin_id), + changed_file_id +}); + // TODO consider other return data - metadata? effects? non-file build artifacts? export type RawGenResult = string | RawGenFile | null | Array; export interface RawGenFile { diff --git a/src/lib/gen_helpers.ts b/src/lib/gen_helpers.ts index 6c9b1d3ca2..0ffe30bd5c 100644 --- a/src/lib/gen_helpers.ts +++ b/src/lib/gen_helpers.ts @@ -9,12 +9,10 @@ import type { InvokeTask } from './task.ts'; import { normalize_gen_config, validate_gen_module, - type GenContext, type GenDependencies, - type GenDependenciesConfig + type GenDependenciesConfig, + create_gen_context } from './gen.ts'; -import { default_svelte_config } from './svelte_config.ts'; -import { to_root_path } from './paths.ts'; import { load_module } from './modules.ts'; /** @@ -98,18 +96,9 @@ const resolve_gen_dependencies = async ( let dependencies: GenDependencies | null = gen_config.dependencies; if (typeof dependencies === 'function') { - const gen_ctx: GenContext = { - config, - svelte_config: default_svelte_config, - filer, - log, - timings, - invoke_task, - origin_id: gen_file_id, - origin_path: to_root_path(gen_file_id), - changed_file_id - }; - dependencies = await dependencies(gen_ctx); + dependencies = await dependencies( + create_gen_context({ config, filer, log, timings, invoke_task }, gen_file_id, changed_file_id) + ); } if (dependencies === null || dependencies === 'all') { diff --git a/src/lib/gro.config.default.ts b/src/lib/gro.config.default.ts index 934a5e7db4..0419e9e298 100644 --- a/src/lib/gro.config.default.ts +++ b/src/lib/gro.config.default.ts @@ -11,30 +11,37 @@ import { package_json_load } from './package_json.ts'; /** * This is the default config that's passed to `gro.config.ts` * if it exists in the current project, and if not, this is the final config. - * It looks at the SvelteKit config and filesystem and tries to do the right thing: + * It looks at `package.json` and the filesystem and tries to do the right thing: * - * - if `svelte.config.js`, assumes a SvelteKit frontend - respects `KitConfig.kit.files.routes` - * - if `src/lib` + `@sveltejs/package`, assumes a Node library - respects `KitConfig.kit.files.lib` + * - if `@sveltejs/kit`, assumes a SvelteKit frontend + * - if `@sveltejs/package` + the lib directory, assumes a Node library - respects `KitConfig.kit.files.lib` * - if `src/lib/server/server.ts`, assumes a Node server - needs config */ -const config: CreateGroConfig = async (cfg, svelte_config) => { - const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? +const config: CreateGroConfig = (cfg) => { + // Detection is deferred into `plugins` because every Gro invocation loads the config, + // but only `dev` and `build` create plugins - this keeps `package.json` + // and the SvelteKit config off the path of every other task. + cfg.plugins = async () => { + const package_json = await package_json_load(); // TODO gets wastefully loaded by some plugins, maybe put in plugin/task context? how does that interact with `map_package_json`? - const [has_server_result, has_sveltekit_library_result, has_sveltekit_app_result] = - await Promise.all([ + // `has_server` reads the Svelte config, because the server's location follows + // `kit.files.lib` and there's no way to find it without knowing where that points. + // So `dev` and `build` resolve the config once here no matter what the project is - + // which is the right place to pay for it, since they're the commands that need it. + const has_sveltekit_app_result = has_sveltekit_app(package_json); + const [has_server_result, has_sveltekit_library_result] = await Promise.all([ has_server(), - has_sveltekit_library(package_json, svelte_config), - has_sveltekit_app() + has_sveltekit_library(package_json) ]); - // put things that generate files before SvelteKit so it can see them - cfg.plugins = () => - [ + // put things that generate files before SvelteKit so it can see them + return [ gro_plugin_gen(), has_server_result.ok ? gro_plugin_server() : null, has_sveltekit_library_result.ok ? gro_plugin_sveltekit_library() : null, has_sveltekit_app_result.ok ? gro_plugin_sveltekit_app() : null ].filter((v) => v !== null); + }; return cfg; }; diff --git a/src/lib/gro_config.ts b/src/lib/gro_config.ts index 9cafbd6f25..1286e6484e 100644 --- a/src/lib/gro_config.ts +++ b/src/lib/gro_config.ts @@ -18,7 +18,6 @@ import { import create_default_config from './gro.config.default.ts'; import type { PluginsCreateConfig } from './plugin.ts'; import type { PackageJsonMapper } from './package_json.ts'; -import type { ParsedSvelteConfig } from './svelte_config.ts'; import type { FilerOptions } from './filer.ts'; /** @@ -62,8 +61,6 @@ export interface GroConfig extends RawGroConfig { * The CLI to use that's compatible with `npm install` and `npm link`. Defaults to `'npm'`. */ pm_cli: string; - /** @default `SVELTE_CONFIG_FILENAME` */ - svelte_config_filename?: string; /** * SHA-256 hash of the user's `build_cache_config` from `gro.config.ts`. * This is computed during config normalization and the raw value is immediately deleted. @@ -114,10 +111,7 @@ export interface RawGroConfig { filer_options?: Partial | null; } -export type CreateGroConfig = ( - base_config: GroConfig, - svelte_config?: ParsedSvelteConfig -) => RawGroConfig | Promise; +export type CreateGroConfig = (base_config: GroConfig) => RawGroConfig | Promise; export const create_empty_gro_config = (): GroConfig => ({ plugins: () => [], diff --git a/src/lib/gro_plugin_server.ts b/src/lib/gro_plugin_server.ts index 8c8f969908..dbf677e812 100644 --- a/src/lib/gro_plugin_server.ts +++ b/src/lib/gro_plugin_server.ts @@ -3,16 +3,16 @@ import * as esbuild from 'esbuild'; import type { Config as SvelteConfig } from '@sveltejs/kit'; import { join, resolve } from 'node:path'; import { identity } from '@fuzdev/fuz_util/function.ts'; -import { strip_before, strip_end } from '@fuzdev/fuz_util/string.ts'; +import { strip_before } from '@fuzdev/fuz_util/string.ts'; import type { Result } from '@fuzdev/fuz_util/result.ts'; import { fs_exists } from '@fuzdev/fuz_util/fs.ts'; import { throttle } from '@fuzdev/fuz_util/throttle.ts'; import type { PathId } from '@fuzdev/fuz_util/path.ts'; import type { Plugin } from './plugin.ts'; -import { base_path_to_path_id, LIB_DIRNAME, paths } from './paths.ts'; +import { paths } from './paths.ts'; import { GRO_DEV_DIRNAME, SERVER_DIST_PATH } from './constants.ts'; -import { parse_svelte_config, default_svelte_config } from './svelte_config.ts'; +import { parse_svelte_config, load_default_svelte_config } from './svelte_config.ts'; import { esbuild_plugin_sveltekit_shim_app } from './esbuild_plugin_sveltekit_shim_app.ts'; import { esbuild_plugin_sveltekit_shim_env } from './esbuild_plugin_sveltekit_shim_env.ts'; import { print_build_result, to_define_import_meta_env } from './esbuild_helpers.ts'; @@ -25,13 +25,27 @@ import { esbuild_plugin_svelte } from './esbuild_plugin_svelte.ts'; // TODO sourcemap as a hoisted option? disable for production by default - or like `outpaths`, passed a `dev` param -export const SERVER_SOURCE_ID = base_path_to_path_id(LIB_DIRNAME + '/server/server.ts'); +/** + * The server entry point, relative to the project's lib directory. + */ +export const SERVER_SOURCE_PATH = 'server/server.ts'; + +/** + * The server entry point of a project whose lib directory is `lib_path`. + * Taken from the Svelte config's `files.lib` rather than the conventional `src/lib`, + * so a project that moves its lib directory still has its server found and built. + */ +export const to_server_source_id = (lib_path: string): PathId => + join(paths.root, lib_path, SERVER_SOURCE_PATH); -export const has_server = async ( - path = SERVER_SOURCE_ID -): Promise> => { - if (!(await fs_exists(path))) { - return { ok: false, message: `no server file found at ${path}` }; +/** + * @param path - the server entry point to look for; + * defaults to `to_server_source_id` of the Svelte config's `lib_path` + */ +export const has_server = async (path?: string): Promise> => { + const final_path = path ?? to_server_source_id((await load_default_svelte_config()).lib_path); + if (!(await fs_exists(final_path))) { + return { ok: false, message: `no server file found at ${final_path}` }; } return { ok: true }; }; @@ -39,6 +53,7 @@ export const has_server = async ( export interface GroPluginServerOptions { /** * same as esbuild's `entryPoints` + * @default ```[`to_server_source_id` of the Svelte config's `lib_path`]```` */ entry_points?: Array; /** @@ -49,6 +64,7 @@ export interface GroPluginServerOptions { * Returns the `Outpaths` given a `dev` param. * Decoupling this from plugin creation allows it to be created generically, * so the build and dev tasks can be the source of truth for `dev`. + * @default `to_default_outpaths` */ outpaths?: CreateOutpaths; /** @@ -60,7 +76,8 @@ export interface GroPluginServerOptions { */ ambient_env?: Record; /** - * @default ```loaded from `${cwd}/${SVELTE_CONFIG_FILENAME}```` + * An already-loaded Svelte config, to skip resolving the project's Vite config. + * @default ```resolved from the project's Vite config```` */ svelte_config?: SvelteConfig; /** @@ -97,7 +114,7 @@ export interface Outpaths { */ outdir: string; /** - * @default 'src/lib' + * @default ```the Svelte config's `lib_path`, so `src/lib` unless it's customized```` */ outbase: string; /** @@ -108,14 +125,23 @@ export interface Outpaths { export type CreateOutpaths = (dev: boolean) => Outpaths; -export const gro_plugin_server = ({ - entry_points = [SERVER_SOURCE_ID], - dir = process.cwd(), - outpaths = (dev) => ({ +/** + * The `Outpaths` used when the plugin is given none. + * Takes `lib_dir` as a param rather than reading `paths.lib` so a customized `kit.files.lib` + * is honored - resolving it is async, so it can't be a plugin-creation default. + */ +export const to_default_outpaths = + (dir: string, lib_dir: string): CreateOutpaths => + (dev) => ({ outdir: join(dir, dev ? GRO_DEV_DIRNAME : SERVER_DIST_PATH), - outbase: paths.lib, + outbase: lib_dir, outname: 'server/server.js' - }), + }); + +export const gro_plugin_server = ({ + entry_points, + dir = process.cwd(), + outpaths, env_files, ambient_env, svelte_config, @@ -133,13 +159,12 @@ export const gro_plugin_server = ({ return { name: 'gro_plugin_server', setup: async ({ dev, watch, timings, log, config, filer }) => { - const parsed_svelte_config = - !svelte_config && strip_end(dir, '/') === process.cwd() - ? default_svelte_config - : await parse_svelte_config({ - dir_or_config: svelte_config ?? dir, - config_filename: config.svelte_config_filename - }); + // `load_default_svelte_config` memoizes, so this shares the resolution + // with the rest of the process. Note that it reads the cwd's config, + // not `dir`'s - `dir` positions esbuild's output and alias resolution. + const parsed_svelte_config = svelte_config + ? await parse_svelte_config({ svelte_config }) + : await load_default_svelte_config(); const { alias, base_url, @@ -149,10 +174,16 @@ export const gro_plugin_server = ({ public_prefix, svelte_compile_options, svelte_compile_module_options, - svelte_preprocessors + svelte_preprocessors, + lib_path } = parsed_svelte_config; - const { outbase, outdir, outname } = outpaths(dev); + // The entry point and `outbase` defaults land here rather than in the destructuring above + // because they come from the Svelte config, which can only be read asynchronously. + const lib_dir = join(paths.root, lib_path); + const final_entry_points = entry_points ?? [join(lib_dir, SERVER_SOURCE_PATH)]; + + const { outbase, outdir, outname } = (outpaths ?? to_default_outpaths(dir, lib_dir))(dev); const server_outpath = join(outdir, outname); @@ -170,7 +201,7 @@ export const gro_plugin_server = ({ }); build_ctx = await esbuild.context({ - entryPoints: entry_points.map((path) => resolve(dir, path)), + entryPoints: final_entry_points.map((path) => resolve(dir, path)), plugins: [ esbuild_plugin_sveltekit_shim_app({ dev, base_url, assets_url }), esbuild_plugin_sveltekit_shim_env({ diff --git a/src/lib/loader.ts b/src/lib/loader.ts index 5f272b3a11..dbb6aa937c 100644 --- a/src/lib/loader.ts +++ b/src/lib/loader.ts @@ -13,7 +13,17 @@ import { SVELTEKIT_SHIM_APP_PATHS_MATCHER, sveltekit_shim_app_specifiers } from './sveltekit_shim_app.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { + has_vite_config, + load_default_svelte_config, + warn_svelte_config_ignored, + NO_SVELTE_PLUGIN_REASON +} from './svelte_config.ts'; +import { + svelte_config_cache_read, + svelte_config_cache_stamps, + svelte_config_cache_write +} from './svelte_config_cache.ts'; import { paths } from './paths.ts'; import { TS_MATCHER, SVELTE_MATCHER, SVELTE_RUNES_MATCHER } from './constants.ts'; import { resolve_specifier } from './resolve_specifier.ts'; @@ -53,19 +63,52 @@ const dev = true; const dir = paths.root; -const { - alias, - base_url, - assets_url, - env_dir, - private_prefix, - public_prefix, - svelte_compile_options, - svelte_compile_module_options, - svelte_preprocessors -} = default_svelte_config; +/* + +`resolve` is the one hook that can't await the config. + +Resolving it imports the Vite config, and the hooks thread's own imports go back through +its own hooks - so a `resolve` that awaited the load it is part of re-enters itself until +the stack blows. `load` has no such problem, and it's where all but one of the config's +fields are read. So the alias map is the only thing needed up front, and it's the only +thing cached: see `svelte_config_cache.ts`. + +On a hit, nothing is resolved here and the rest of the config is awaited inside `load`, +which most invocations never reach - tasks and genfiles are TypeScript, and `gro test` +hands its files to Vitest rather than to this loader. On a miss the whole config loads +here at module scope, before the hooks go live, which keeps that import graph out of them. + +The one shape this can't survive is a Vite config that imports a module `load` resolves +the config for - a `.svelte`, `.svelte.ts`, `$env`, or `$app/paths` import reached from +the config graph would await a load it is part of. Nothing puts those in a Vite config, +and there's no correct value to hand back if something did. -const aliases = Object.entries(alias); +The loader runs on a worker thread, where `process.chdir` is unavailable - Vite's own +config resolution doesn't need it, so this is the same load the main thread does. +Both read the project in the cwd, which is the only project either one resolves. + +*/ +const cache_stamps = svelte_config_cache_stamps(); +const cached_svelte_config = svelte_config_cache_read(cache_stamps); + +let aliases: Array<[string, string]>; +if (cached_svelte_config) { + aliases = Object.entries(cached_svelte_config.alias); + if (!cached_svelte_config.svelte_config_found) { + warn_svelte_config_ignored(process.cwd(), NO_SVELTE_PLUGIN_REASON); + } +} else { + const parsed_svelte_config = await load_default_svelte_config(); + aliases = Object.entries(parsed_svelte_config.alias); + // Skipped without a Vite config because that path resolves nothing to save, + // and because its warning has to repeat rather than be cached away. + if (has_vite_config()) { + svelte_config_cache_write(cache_stamps, { + alias: parsed_svelte_config.alias, + svelte_config_found: parsed_svelte_config.svelte_config !== null + }); + } +} const RAW_MATCHER = /(%3Fraw|\.css|\.svg)$/; // TODO others? configurable? @@ -74,6 +117,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { // console.log(`url`, url); if (SVELTEKIT_SHIM_APP_PATHS_MATCHER.test(url)) { // SvelteKit `$app/paths` shim + const { base_url, assets_url } = await load_default_svelte_config(); return { format: 'module', shortCircuit: true, @@ -94,6 +138,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { if (raw_source == null) throw Error(`Failed to load ${url}`); // TODO should be nice if we could use Node's builtin amaro transform, but I couldn't find a way after digging into the source, AFAICT it's internal and not exposed const source = ts_blank_space(raw_source); // TODO was using oxc-transform and probably should, but this doesn't require sourcemaps, and it's still alpha as of May 2025 + const { svelte_compile_module_options } = await load_default_svelte_config(); const transformed = compileModule(source, { ...svelte_compile_module_options, dev, @@ -108,6 +153,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { const loaded = await nextLoad(url, { ...context, format: 'module' }); const raw_source = loaded.source!.toString(); // eslint-disable-line @typescript-eslint/no-base-to-string const filename = fileURLToPath(url); + const { svelte_compile_options, svelte_preprocessors } = await load_default_svelte_config(); const preprocessed = svelte_preprocessors // TODO @many use sourcemaps (and diagnostics?) ? await preprocess(raw_source, svelte_preprocessors, { filename }) : null; @@ -162,6 +208,7 @@ export const load: LoadHook = async (url, context, nextLoad) => { throw Error(`Unknown $env import: ${context.importAttributes.virtual}`); } } + const { env_dir, private_prefix, public_prefix } = await load_default_svelte_config(); const source = render_env_shim_module( dev, mode, diff --git a/src/lib/module.ts b/src/lib/module.ts index 7be3636770..e41fece9f5 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -1,11 +1,12 @@ -import { LIB_DIRNAME } from './paths.ts'; -import { SOURCE_DIR, SOURCE_DIRNAME } from './constants.ts'; +import { escape_regexp } from '@fuzdev/fuz_util/regexp.ts'; + +import { SOURCE_DIR, SOURCE_DIRNAME, SVELTEKIT_LIB_ALIAS } from './constants.ts'; export const MODULE_PATH_SRC_PREFIX = SOURCE_DIR; -export const MODULE_PATH_LIB_PREFIX = `$${LIB_DIRNAME}/`; +export const MODULE_PATH_LIB_PREFIX = SVELTEKIT_LIB_ALIAS + '/'; const INTERNAL_MODULE_MATCHER = new RegExp( - `^(\\.?\\.?|${SOURCE_DIRNAME}|\\$${LIB_DIRNAME})\\/`, + `^(\\.?\\.?|${SOURCE_DIRNAME}|${escape_regexp(SVELTEKIT_LIB_ALIAS)})\\/`, 'u' ); diff --git a/src/lib/package_json.ts b/src/lib/package_json.ts index e175d444a3..e39f1f6543 100644 --- a/src/lib/package_json.ts +++ b/src/lib/package_json.ts @@ -18,6 +18,7 @@ import { CSS_MATCHER } from './constants.ts'; import { has_sveltekit_library } from './sveltekit_helpers.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { GITHUB_REPO_MATCHER } from './github.ts'; export type PackageJsonMapper = ( @@ -51,19 +52,28 @@ export const package_json_load = async ( return package_json; }; +/** + * @param exports_dir - the directory whose files become the `exports`; + * defaults to the Svelte config's `lib_path`, which `has_sveltekit_library` + * has already resolved by the time it's read + */ export const package_json_sync = async ( map_package_json: PackageJsonMapper, log: Logger, write = true, dir = paths.root, - exports_dir = paths.lib + exports_dir?: string ): Promise<{ package_json: PackageJson | null; changed: boolean }> => { - const exported_files = await fs_search(exports_dir); - const exported_paths = exported_files.map((f) => f.path); const updated = await package_json_update( async (package_json) => { if ((await has_sveltekit_library(package_json)).ok) { - package_json.exports = package_json_to_exports(exported_paths); + // Reading the lib directory off the Svelte config rather than `paths.lib` is what honors + // a customized `kit.files.lib`. Searching the conventional `src/lib` instead would find + // nothing and quietly replace the library's whole `exports` map with a single entry. + const final_exports_dir = + exports_dir ?? join(paths.root, (await load_default_svelte_config()).lib_path); + const exported_files = await fs_search(final_exports_dir); + package_json.exports = package_json_to_exports(exported_files.map((f) => f.path)); } const mapped = await map_package_json(package_json); return mapped ? parse_package_json(PackageJson, mapped) : mapped; @@ -225,10 +235,19 @@ const parse_or_throw_formatted_error = ( return parsed.data; }; -export const package_json_has_dependency = (dep_name: string, package_json: PackageJson): boolean => +/** + * @param include_peer - whether a `peerDependencies` entry counts. Pass `false` when + * detecting what a project *is*, since a peer dep declares what it works alongside - + * a plugin package peered on `@sveltejs/kit` isn't itself a SvelteKit app. + */ +export const package_json_has_dependency = ( + dep_name: string, + package_json: PackageJson, + include_peer = true +): boolean => !!package_json.devDependencies?.[dep_name] || !!package_json.dependencies?.[dep_name] || - !!package_json.peerDependencies?.[dep_name]; + (include_peer && !!package_json.peerDependencies?.[dep_name]); export interface PackageJsonDep { name: string; diff --git a/src/lib/paths.ts b/src/lib/paths.ts index e2692ed8db..0e8c93cb33 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -1,4 +1,4 @@ -import { join, extname, relative, basename } from 'node:path'; +import { join, extname, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ensure_end, strip_end } from '@fuzdev/fuz_util/string.ts'; import { styleText as st } from 'node:util'; @@ -8,10 +8,10 @@ import { GRO_CONFIG_FILENAME, GRO_DEV_DIR, GRO_DIR, + LIB_DIR, SOURCE_DIR, SVELTEKIT_DIST_DIRNAME } from './constants.ts'; -import { default_svelte_config } from './svelte_config.ts'; /* @@ -20,11 +20,13 @@ It's the same name that Rollup uses. */ -export const LIB_DIRNAME = basename(default_svelte_config.lib_path); -export const LIB_PATH = SOURCE_DIR + LIB_DIRNAME; -/** @trailing_slash */ -export const LIB_DIR = LIB_PATH + '/'; -export const ROUTES_DIRNAME = basename(default_svelte_config.routes_path); +/* + +`paths` is built from the conventional locations in `./constants.ts`, never from the +SvelteKit `files` config values - reading those costs a full Vite config resolution, +which is too expensive for a module every Gro invocation imports. + +*/ export interface Paths { /** @trailing_slash */ diff --git a/src/lib/plugin.ts b/src/lib/plugin.ts index 0f004b1269..6b15f781ad 100644 --- a/src/lib/plugin.ts +++ b/src/lib/plugin.ts @@ -20,6 +20,22 @@ export interface PluginContext extends TaskContext { watch: boolean; } +/** + * Widens a `TaskContext` to a `PluginContext` by adding `dev` and `watch`. + * Copies property descriptors rather than spreading, because `svelte_config` is a lazy getter - + * spreading would call it, resolving the Svelte config even for a plugin set that never reads it, + * and pinning whichever promise it returned instead of the memoized one. + */ +export const to_plugin_context = ( + ctx: TaskContext, + dev: boolean, + watch: boolean +): PluginContext => + Object.defineProperties( + { dev, watch }, + Object.getOwnPropertyDescriptors(ctx) + ) as PluginContext; + /** See `Plugins.create` for a usage example. */ export class Plugins { readonly ctx: TPluginContext; diff --git a/src/lib/release.task.ts b/src/lib/release.task.ts index c545256be0..0b3144a31f 100644 --- a/src/lib/release.task.ts +++ b/src/lib/release.task.ts @@ -45,7 +45,7 @@ export const task: Task = { if (publish) { await invoke_task('publish', { optional: true, dry, check, build, pull, sync, install }); } - if ((await has_sveltekit_app()).ok) { + if (has_sveltekit_app(package_json).ok) { await invoke_task('deploy', { build: build && !publish, dry, diff --git a/src/lib/run_gen.ts b/src/lib/run_gen.ts index 8715ad52ba..7cd518e4e5 100644 --- a/src/lib/run_gen.ts +++ b/src/lib/run_gen.ts @@ -7,16 +7,15 @@ import { map_concurrent } from '@fuzdev/fuz_util/async.ts'; import { type GenResults, type GenfileModuleResult, - type GenContext, type GenfileModuleMeta, to_gen_result, type RawGenResult, - normalize_gen_config + normalize_gen_config, + create_gen_context } from './gen.ts'; -import { print_path, to_root_path } from './paths.ts'; +import { print_path } from './paths.ts'; import type { format_file as base_format_file } from './format_file.ts'; import type { GroConfig } from './gro_config.ts'; -import { default_svelte_config } from './svelte_config.ts'; import type { Filer } from './filer.ts'; import type { InvokeTask } from './task.ts'; @@ -43,17 +42,7 @@ export const run_gen = async ( const timing_for_module = timings.start(id); const gen_config = normalize_gen_config(module_meta.mod.gen); - const gen_ctx: GenContext = { - config, - svelte_config: default_svelte_config, - filer, - log, - timings, - invoke_task, - origin_id: id, - origin_path: to_root_path(id), - changed_file_id: undefined - }; + const gen_ctx = create_gen_context({ config, filer, log, timings, invoke_task }, id); let raw_gen_result: RawGenResult; try { raw_gen_result = await gen_config.generate(gen_ctx); diff --git a/src/lib/run_task.ts b/src/lib/run_task.ts index 23171bdc97..ab613b8652 100644 --- a/src/lib/run_task.ts +++ b/src/lib/run_task.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import type { Filer } from './filer.ts'; import type { GroConfig } from './gro_config.ts'; import type { invoke_task as base_invoke_task } from './invoke_task.ts'; -import { default_svelte_config } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { TaskError, type TaskModuleMeta } from './task.ts'; import { log_task_help } from './task_logging.ts'; @@ -58,7 +58,9 @@ export const run_task = async ( output = await task.run({ args, config, - svelte_config: default_svelte_config, + get svelte_config() { + return load_default_svelte_config(); + }, filer, log, timings, diff --git a/src/lib/svelte_config.ts b/src/lib/svelte_config.ts index 67a13f4fe9..f111912e8e 100644 --- a/src/lib/svelte_config.ts +++ b/src/lib/svelte_config.ts @@ -1,9 +1,15 @@ import type { Config as SvelteConfig } from '@sveltejs/kit'; import type { CompileOptions, ModuleCompileOptions, PreprocessorGroup } from 'svelte/compiler'; -import { join } from 'node:path'; +import { isAbsolute, join, relative } from 'node:path'; +import { existsSync } from 'node:fs'; import { EMPTY_OBJECT } from '@fuzdev/fuz_util/object.ts'; +import { Logger } from '@fuzdev/fuz_util/log.ts'; -import { SVELTE_CONFIG_FILENAME } from './constants.ts'; +import { + SVELTE_CONFIG_FILENAMES, + SVELTEKIT_LIB_ALIAS, + VITE_CONFIG_FILENAMES +} from './constants.ts'; /* eslint-disable @typescript-eslint/no-deprecated */ // see https://github.com/sveltejs/kit/discussions/14240 @@ -11,34 +17,157 @@ import { SVELTE_CONFIG_FILENAME } from './constants.ts'; /* This module is intended to have minimal dependencies to avoid over-imports in the CLI. +Loading is lazy and memoized - see `load_default_svelte_config`. + +The Svelte config is read through Vite, never from `svelte.config.js` directly, +with the same `resolveConfig` call SvelteKit's own `load_config` makes. +So this sees exactly what SvelteKit sees - inline `sveltekit()` options when a project +passes them, and otherwise whatever SvelteKit loaded from `svelte.config.js` on its own. + +A project with no Vite config, or one whose Vite config configures no Svelte plugin, +is read as having no Svelte config, so projects that don't use Vite keep working on the +defaults. Unlike SvelteKit, which falls back to importing `svelte.config.js` itself, +a project in either of those states that has a Svelte config gets a warning instead - +it looks configured while being ignored. Having a Vite config that can't be resolved +is an error rather than a fallback, because falling back would mean compiling against +the wrong config in silence. + +The `api.options` read back off `vite-plugin-svelte` is that plugin's *resolved* options, +not the user's, so a plain Svelte project's `compilerOptions` arrive with the plugin's own +`css`, `dev`, and `hmr` mixed in, resolved for the `build`/`production` pass below. +It deletes `generate` (along with `format` and `filename`), so Gro's server default survives, +and consumers that care about `dev` set it themselves - the loader always compiles for dev. + +Always the project in the cwd, never an arbitrary directory. SvelteKit resolves its +`files` and `env.dir` against its own cwd rather than the Vite `root` it's handed, +so a `dir` parameter here could only ever be half-honored. */ /** - * Loads a SvelteKit config at `dir`. - * @returns `null` if no config is found + * The names of the Vite plugins that carry the resolved Svelte config, most specific first. + * SvelteKit's `api.options` is the split config shape, with its own options under `kit` - + * it's the only one SvelteKit itself reads. `vite-plugin-svelte`'s is that plugin's resolved + * options, which carry no `kit` but overlap in `compilerOptions` and `preprocess`, + * so a plain Svelte project still gets its compiler options and preprocessors. */ -export const load_svelte_config = async ({ - dir = process.cwd(), - config_filename = SVELTE_CONFIG_FILENAME -}: { dir?: string; config_filename?: string } = EMPTY_OBJECT): Promise => { - try { - return (await import(join(dir, config_filename))).default; - } catch (_err) { +const CONFIG_PROVIDER_PLUGIN_NAMES = ['vite-plugin-sveltekit-setup', 'vite-plugin-svelte:config']; + +/** + * This module has no logger in scope - it's called from the Node loader and from + * `load_default_svelte_config`, neither of which has one to pass in. + * Exported so it can be silenced or redirected, e.g. `svelte_config_log.level = 'off'`. + */ +export const svelte_config_log = new Logger('svelte_config'); + +/** + * The first of `filenames` that exists in `dir`, if any. + * Which of several configs wins is Vite's and SvelteKit's call, not Gro's, + * so their filenames are treated as a set rather than privileging one extension. + */ +const find_config_file = (dir: string, filenames: Array): string | undefined => + filenames.find((filename) => existsSync(join(dir, filename))); + +/** + * Whether the project in `dir` has a Vite config, and so anything to read a Svelte config + * through. Exported because it's what makes a config read worth caching - a project without + * one never pays for a resolution to begin with. + */ +export const has_vite_config = (dir = process.cwd()): boolean => + find_config_file(dir, VITE_CONFIG_FILENAMES) !== undefined; + +const NO_VITE_CONFIG_REASON = 'no Vite config to read it through'; + +/** + * Exported because the loader repeats this warning when it reads a cached config - + * a cache is only written after a Vite config resolves, so it's the only reason + * that can still apply on a hit. + */ +export const NO_SVELTE_PLUGIN_REASON = 'its Vite config configures no Svelte plugin'; + +/** + * Warns when `dir` has a Svelte config that Gro found no way to read, because that config + * looks like it's configuring the project while being ignored. A project with no Svelte + * config isn't configuring Svelte at all, so it stays quiet and takes the defaults. + * @param reason - why the config couldn't be read, as a clause following "but" + */ +export const warn_svelte_config_ignored = (dir: string, reason: string): void => { + const svelte_config_filename = find_config_file(dir, SVELTE_CONFIG_FILENAMES); + if (!svelte_config_filename) return; + svelte_config_log.warn( + `Found ${svelte_config_filename} in ${dir} but ${reason},` + + ' so its preprocessors, aliases, and compiler options are being ignored.' + + ' Gro reads the Svelte config through Vite, the same as SvelteKit does.' + ); +}; + +/** + * Loads the Svelte config of the project in the cwd by resolving its Vite config. + * @returns `null` if the project has no Vite config, or one that configures no Svelte plugin + * @throws if the project has a Vite config but Vite isn't installed, or if it fails to resolve + */ +export const load_svelte_config = async (): Promise => { + const dir = process.cwd(); + if (!has_vite_config(dir)) { + warn_svelte_config_ignored(dir, NO_VITE_CONFIG_REASON); return null; } + + let vite; + try { + vite = await import('vite'); + } catch (err) { + // Only reachable with a Vite config in hand, so the project is meant to build with Vite + // and can't. Degrading would mean compiling against the wrong config in silence. + throw new Error(`Found a Vite config at ${dir} but failed to import Vite`, { cause: err }); + } + + let resolved; + // `resolveConfig` writes `process.env.NODE_ENV` when it's unset, and reading the config is + // not a decision about the process. Left alone, the `development` it writes is inherited by + // the `vite build` that `gro build` spawns, which builds for production as if for dev. + // The restore can't cover the await itself, but it bounds the window to this call. + const node_env = process.env.NODE_ENV; + try { + // The same call SvelteKit's `load_config` makes, minus the `process.chdir` it needs + // only to point at another directory - so Gro reads the config SvelteKit would read. + // No `root` or `configFile`, so Vite picks its own config the way it does everywhere + // else; `logLevel` is Gro's, to keep config reads off the CLI's output. + resolved = await vite.resolveConfig( + { logLevel: 'error' }, + 'build', + process.env.MODE ?? 'production' + ); + } catch (err) { + throw new Error(`Failed to resolve the Vite config at ${dir}`, { cause: err }); + } finally { + if (node_env === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = node_env; + } + } + + for (const name of CONFIG_PROVIDER_PLUGIN_NAMES) { + const options = resolved.plugins.find((p) => p.name === name)?.api?.options; + if (options) return options as SvelteConfig; + } + + // A Vite config that configures no Svelte plugin is the same silent-ignore as having no Vite + // config at all, and likelier to be unintended - a `vite.config.ts` that only sets up Vitest + // alongside a `svelte.config.js` that does the real configuring reaches exactly here. + warn_svelte_config_ignored(dir, NO_SVELTE_PLUGIN_REASON); + return null; }; /** - * A subset of SvelteKit's config in a form that Gro uses - * because SvelteKit doesn't expose its config resolver. + * A subset of SvelteKit's config in a form that Gro uses. * Flattens things out to keep them simple and easy to pass around, * and doesn't deal with most properties, but includes the full `svelte_config`. * The `base` and `assets` in particular are renamed for clarity with Gro's internal systems, * so these properties become first-class vocabulary inside Gro. */ export interface ParsedSvelteConfig { - // TODO probably fill these out with defaults svelte_config: SvelteConfig | null; alias: Record; base_url: '' | `/${string}` | undefined; @@ -46,15 +175,15 @@ export interface ParsedSvelteConfig { // TODO others, but maybe replace with a Zod schema? https://svelte.dev/docs/kit/configuration /** - * Same as the SvelteKit `files.assets`. + * Same as the SvelteKit `files.assets`, relative to the project directory. */ assets_path: string; /** - * Same as the SvelteKit `files.lib`. + * Same as the SvelteKit `files.lib`, relative to the project directory. */ lib_path: string; /** - * Same as the SvelteKit `files.routes`. + * Same as the SvelteKit `files.routes`, relative to the project directory. */ routes_path: string; @@ -66,51 +195,66 @@ export interface ParsedSvelteConfig { svelte_preprocessors: PreprocessorGroup | Array | undefined; } -// TODO currently incomplete and hack - maybe rethink +/** + * Resolving through Vite yields absolute `files` paths, but Gro's vocabulary is relative + * to the project directory. The cwd is the base because that's what SvelteKit resolved + * them against, and what every consumer of these paths resolves them against in turn. + */ +const to_project_relative_path = (path: string | undefined): string | undefined => + path === undefined || !isAbsolute(path) ? path : relative(process.cwd(), path) || '.'; + +/** + * Gro compiles for the server by default, + * because SvelteKit handles the client in the normal cases. + * Frozen because it's handed out as a default value. + */ +export const SVELTE_COMPILE_OPTIONS_DEFAULT: CompileOptions = Object.freeze({ generate: 'server' }); + +export interface ParseSvelteConfigOptions { + /** + * An already-loaded config to parse instead of resolving the project's Vite config. + */ + svelte_config?: SvelteConfig; +} + /** * Returns Gro-relevant properties of a SvelteKit config * as a convenience wrapper around `load_svelte_config`. - * Needed because SvelteKit doesn't expose its config resolver. */ -export const parse_svelte_config = async ({ - dir_or_config = process.cwd(), // TODO maybe not the best API, maybe a type union? `({svelte_config} | {dir}) & {config_filename}` - config_filename = SVELTE_CONFIG_FILENAME -}: { - dir_or_config?: string | SvelteConfig; - config_filename?: string; -} = EMPTY_OBJECT): Promise => { - const svelte_config = - typeof dir_or_config === 'string' - ? await load_svelte_config({ dir: dir_or_config, config_filename }) - : dir_or_config; +export const parse_svelte_config = async ( + options: ParseSvelteConfigOptions = EMPTY_OBJECT +): Promise => { + const svelte_config = options.svelte_config ?? (await load_svelte_config()); const kit = svelte_config?.kit; - const alias = { $lib: 'src/lib', ...kit?.alias }; + const assets_path = to_project_relative_path(kit?.files?.assets) ?? 'static'; + const lib_path = to_project_relative_path(kit?.files?.lib) ?? 'src/lib'; + const routes_path = to_project_relative_path(kit?.files?.routes) ?? 'src/routes'; + + // SvelteKit always names this alias `$lib` and points it at `files.lib`. + // @see https://svelte.dev/docs/kit/configuration#alias + const alias = { [SVELTEKIT_LIB_ALIAS]: lib_path, ...kit?.alias }; const base_url = kit?.paths?.base; const assets_url = kit?.paths?.assets; - // TODO probably a Zod schema instead - const assets_path = kit?.files?.assets ?? 'static'; - const lib_path = kit?.files?.lib ?? 'src/lib'; - const routes_path = kit?.files?.routes ?? 'src/routes'; - - const env_dir = kit?.env?.dir; + // Relative like the paths above, and for a sharper reason: `env_dir` is serialized into + // the generated `$env/dynamic/*` modules, so an absolute path from Vite resolution would + // bake the build machine's directory into server bundles. + const env_dir = to_project_relative_path(kit?.env?.dir); const private_prefix = kit?.env?.privatePrefix; const public_prefix = kit?.env?.publicPrefix; - const svelte_compile_options: CompileOptions = svelte_config?.compilerOptions ?? {}; - // Change the default to `generate: 'server'`, - // because SvelteKit handles the client in the normal cases. + const svelte_compile_options: CompileOptions = { ...svelte_config?.compilerOptions }; if (svelte_compile_options.generate === undefined) { - svelte_compile_options.generate = 'server'; + svelte_compile_options.generate = SVELTE_COMPILE_OPTIONS_DEFAULT.generate; } const svelte_compile_module_options = to_default_compile_module_options(svelte_compile_options); // TODO will kit have these separately? const svelte_preprocessors = svelte_config?.preprocess; return { - svelte_config, + svelte_config: svelte_config ?? null, alias, base_url, assets_url, @@ -134,7 +278,26 @@ export const to_default_compile_module_options = ({ warningFilter }: CompileOptions): ModuleCompileOptions => ({ dev, generate, filename, rootDir, warningFilter }); +let default_svelte_config: Promise | undefined; + /** - * The parsed SvelteKit config for the cwd, cached globally at the module level. + * The parsed Svelte config for the project in the cwd, memoized. + * + * Reading it costs a full Vite config resolution, which runs every Vite plugin's + * config hooks, so callers pull it in on demand instead of paying for it + * on every Gro invocation. */ -export const default_svelte_config = await parse_svelte_config(); // always load it to keep things simple ahead +export const load_default_svelte_config = (): Promise => { + if (default_svelte_config === undefined) { + const loading = (default_svelte_config = parse_svelte_config()); + // Evict failures so a long-lived process like `gro dev` picks up a fixed config. + // Attaching the handler here also keeps the cached promise from being reported + // as an unhandled rejection when nothing has awaited it yet. + void loading.catch(() => { + if (default_svelte_config === loading) { + default_svelte_config = undefined; + } + }); + } + return default_svelte_config; +}; diff --git a/src/lib/svelte_config_cache.ts b/src/lib/svelte_config_cache.ts new file mode 100644 index 0000000000..2e75c131c0 --- /dev/null +++ b/src/lib/svelte_config_cache.ts @@ -0,0 +1,154 @@ +import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { + GRO_DIRNAME, + PACKAGE_JSON_FILENAME, + SVELTE_CONFIG_FILENAMES, + VITE_CONFIG_FILENAMES +} from './constants.ts'; + +/* + +Caches the one piece of the Svelte config the Node loader needs before it can resolve +anything, so the common case doesn't pay a full Vite config resolution per invocation. + +The loader's `resolve` hook reads `alias` and nothing else - every other field it uses is +read inside `load`, which can await the real config lazily. So this holds an alias map and +nothing else, and an alias map is always plain strings: `$lib` pointing at `kit.files.lib` +plus whatever `kit.alias` declares. There's nothing here that doesn't survive JSON, which +is why this caches a slice of the config rather than the config, whose preprocessors and +`compilerOptions.warningFilter` are functions. + +Validation is hand-rolled rather than a Zod schema because this module is on the loader's +critical path, where the point is to be cheaper than the thing it replaces. + +Unlike `./svelte_config.ts`, which reads the cwd's project and only that one, everything +here takes a `dir`: SvelteKit resolves its `files` and `env.dir` against its own cwd, so a +directory parameter there could only ever be half-honored, while a JSON file on disk has no +such tie. The loader always passes the cwd; the parameter is what makes this testable. + +*/ + +export const SVELTE_CONFIG_CACHE_FILENAME = 'svelte_config.json'; + +/** + * Bump when `SvelteConfigCache`'s shape changes, or when the meaning of a field does, + * so caches written by an older Gro self-invalidate instead of being read as the new shape. + */ +export const SVELTE_CONFIG_CACHE_VERSION = 1; + +/** + * The mtime and size of each cache input, or `null` for one that doesn't exist. + * Absence is recorded rather than omitted so that *adding* a config file invalidates too. + */ +export type SvelteConfigCacheStamps = Record; + +export interface SvelteConfigCache { + version: number; + stamps: SvelteConfigCacheStamps; + /** + * The `alias` of a `ParsedSvelteConfig`. + */ + alias: Record; + /** + * `false` when the Vite config resolved but configured no Svelte plugin, so a cached read + * can repeat the warning a fresh load would have emitted instead of going quiet about it. + */ + svelte_config_found: boolean; +} + +/** + * The files whose state invalidates the cache, relative to the project directory. + * + * Deliberately the config files themselves and `package.json`, not the modules a Vite config + * imports: `alias` comes from `kit.alias` and `kit.files.lib`, which are authored in one of + * these. A project that factors them out into an imported module keeps a stale alias until + * something else invalidates, which surfaces as an unresolved import rather than a bad + * compile - `gro clean` clears it. + */ +const CACHE_INPUT_FILENAMES = [ + PACKAGE_JSON_FILENAME, + ...VITE_CONFIG_FILENAMES, + ...SVELTE_CONFIG_FILENAMES +]; + +const to_cache_path = (dir: string): string => join(dir, GRO_DIRNAME, SVELTE_CONFIG_CACHE_FILENAME); + +const to_file_stamp = (path: string): string | null => { + try { + const stats = statSync(path); + return `${stats.mtimeMs}:${stats.size}`; + } catch { + return null; // absent, or unreadable, which is the same thing for cache purposes + } +}; + +/** + * Snapshots the state of the cache inputs in `dir`. + * + * Taken by the caller *before* resolving the config, not inside `svelte_config_cache_write` + * afterwards: a config edited mid-resolution should leave a stamp that no longer matches, so + * the next invocation resolves again. Stamping afterwards would pair the old alias with the + * new state and serve it as fresh. + */ +export const svelte_config_cache_stamps = (dir = process.cwd()): SvelteConfigCacheStamps => { + const stamps: SvelteConfigCacheStamps = {}; + for (const filename of CACHE_INPUT_FILENAMES) { + stamps[filename] = to_file_stamp(join(dir, filename)); + } + return stamps; +}; + +/** + * Reads the cache and returns it only if every input still matches `stamps`. + * Every kind of miss - absent, corrupt, wrong version, stale - reads the same to the caller, + * which resolves the config and rewrites. + */ +export const svelte_config_cache_read = ( + stamps: SvelteConfigCacheStamps, + dir = process.cwd() +): SvelteConfigCache | null => { + let cache: SvelteConfigCache; + try { + cache = JSON.parse(readFileSync(to_cache_path(dir), 'utf8')); + } catch { + return null; + } + if ( + cache?.version !== SVELTE_CONFIG_CACHE_VERSION || + typeof cache.alias !== 'object' || + cache.alias === null || + typeof cache.svelte_config_found !== 'boolean' || + typeof cache.stamps !== 'object' || + cache.stamps === null + ) { + return null; + } + for (const filename of CACHE_INPUT_FILENAMES) { + if (cache.stamps[filename] !== stamps[filename]) return null; + } + return cache; +}; + +/** + * Writes the cache for the config that `stamps` was taken before resolving. + * + * Best effort - a cache that can't be written just means the next invocation resolves again, + * so a read-only or otherwise unwritable project directory costs speed and nothing else. + * @param read - what resolving the config produced + */ +export const svelte_config_cache_write = ( + stamps: SvelteConfigCacheStamps, + read: Pick, + dir = process.cwd() +): void => { + const cache: SvelteConfigCache = { version: SVELTE_CONFIG_CACHE_VERSION, stamps, ...read }; + const path = to_cache_path(dir); + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(cache, null, '\t') + '\n', 'utf8'); + } catch { + // see the doc comment - caching is an optimization, not a requirement + } +}; diff --git a/src/lib/sveltekit_helpers.ts b/src/lib/sveltekit_helpers.ts index 70b094922f..fdc061bbc4 100644 --- a/src/lib/sveltekit_helpers.ts +++ b/src/lib/sveltekit_helpers.ts @@ -9,46 +9,55 @@ import { to_forwarded_args } from './args.ts'; import { find_cli, spawn_cli, to_cli_name, type Cli } from './cli.ts'; import { PM_CLI_DEFAULT, - SVELTE_CONFIG_FILENAME, SVELTE_PACKAGE_DEP_NAME, SVELTEKIT_CLI, + SVELTEKIT_DEP_NAME, SVELTEKIT_DEV_DIRNAME } from './constants.ts'; import { package_json_has_dependency } from './package_json.ts'; -import { default_svelte_config, type ParsedSvelteConfig } from './svelte_config.ts'; +import { load_default_svelte_config } from './svelte_config.ts'; import { TaskError } from './task.ts'; -export const has_sveltekit_app = async ( - svelte_config_path: string = SVELTE_CONFIG_FILENAME -): Promise> => { - if (!(await fs_exists(svelte_config_path))) { - return { ok: false, message: `no SvelteKit config found at ${SVELTE_CONFIG_FILENAME}` }; +/** + * Detected from `package.json` rather than the Svelte config, + * because reading the config costs a full Vite config resolution. + * Peer deps don't count - a package peered on SvelteKit is built to work with one, + * not to be one, and counting them would run `vite build` over a library that has no app. + */ +export const has_sveltekit_app = ( + package_json: PackageJson +): Result => { + if (!package_json_has_dependency(SVELTEKIT_DEP_NAME, package_json, false)) { + return { ok: false, message: `no dependency found in package.json for ${SVELTEKIT_DEP_NAME}` }; } - // TODO check for routes? return { ok: true }; }; export const has_sveltekit_library = async ( - package_json: PackageJson, - svelte_config: ParsedSvelteConfig = default_svelte_config, - dep_name = SVELTE_PACKAGE_DEP_NAME + package_json: PackageJson ): Promise> => { - const has_sveltekit_app_result = await has_sveltekit_app(); + const has_sveltekit_app_result = has_sveltekit_app(package_json); if (!has_sveltekit_app_result.ok) { return has_sveltekit_app_result; } - if (!(await fs_exists(svelte_config.lib_path))) { - return { ok: false, message: `no SvelteKit lib directory found at ${svelte_config.lib_path}` }; - } - - if (!package_json_has_dependency(dep_name, package_json)) { + // Checked before the lib directory because it's the cheaper of the two and it's what + // distinguishes a library from an app, so this returns without reading the Svelte config + // for the tasks that call it on its own - `changeset`, `publish`, `release`, `gro sync`. + // `dev` and `build` resolve the config regardless, since `has_server` needs it too. + // Peer deps don't count here either, for the same reason as `has_sveltekit_app`. + if (!package_json_has_dependency(SVELTE_PACKAGE_DEP_NAME, package_json, false)) { return { ok: false, - message: `no dependency found in package.json for ${dep_name}` + message: `no dependency found in package.json for ${SVELTE_PACKAGE_DEP_NAME}` }; } + const { lib_path } = await load_default_svelte_config(); + if (!(await fs_exists(lib_path))) { + return { ok: false, message: `no SvelteKit lib directory found at ${lib_path}` }; + } + return { ok: true }; }; diff --git a/src/lib/task.ts b/src/lib/task.ts index 0ea0b660eb..c0a8160fbf 100644 --- a/src/lib/task.ts +++ b/src/lib/task.ts @@ -34,7 +34,11 @@ export interface Task< export interface TaskContext { args: TArgs; config: GroConfig; - svelte_config: ParsedSvelteConfig; + /** + * Resolved on first access, so tasks that don't touch it + * never pay to read the SvelteKit config. + */ + svelte_config: Promise; filer: Filer; log: Logger; timings: Timings; diff --git a/src/test/build_task.args.test.ts b/src/test/build_task.args.test.ts index 97aa3a40bd..4df877277d 100644 --- a/src/test/build_task.args.test.ts +++ b/src/test/build_task.args.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.cache_persistence.test.ts b/src/test/build_task.cache_persistence.test.ts index 5135e695a7..1548798673 100644 --- a/src/test/build_task.cache_persistence.test.ts +++ b/src/test/build_task.cache_persistence.test.ts @@ -26,7 +26,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.cache_race_conditions.test.ts b/src/test/build_task.cache_race_conditions.test.ts index d69c5702cd..04b340e56e 100644 --- a/src/test/build_task.cache_race_conditions.test.ts +++ b/src/test/build_task.cache_race_conditions.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.cache_validation.test.ts b/src/test/build_task.cache_validation.test.ts index 03326848b1..29d0e6d2ef 100644 --- a/src/test/build_task.cache_validation.test.ts +++ b/src/test/build_task.cache_validation.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.errors.test.ts b/src/test/build_task.errors.test.ts index d192b78598..2c16d14e2b 100644 --- a/src/test/build_task.errors.test.ts +++ b/src/test/build_task.errors.test.ts @@ -26,7 +26,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.optimization.test.ts b/src/test/build_task.optimization.test.ts index bcf44f37fa..83469472e2 100644 --- a/src/test/build_task.optimization.test.ts +++ b/src/test/build_task.optimization.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.plugins.test.ts b/src/test/build_task.plugins.test.ts index fdb2537123..ed3da7775a 100644 --- a/src/test/build_task.plugins.test.ts +++ b/src/test/build_task.plugins.test.ts @@ -24,7 +24,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/build_task.workspace.test.ts b/src/test/build_task.workspace.test.ts index d8ded451fc..5f86169d2c 100644 --- a/src/test/build_task.workspace.test.ts +++ b/src/test/build_task.workspace.test.ts @@ -27,7 +27,8 @@ vi.mock('$lib/clean_fs.ts', () => ({ clean_fs: vi.fn() })); -vi.mock('$lib/plugin.ts', () => ({ +vi.mock('$lib/plugin.ts', async (import_original) => ({ + ...(await import_original()), Plugins: { create: vi.fn() } diff --git a/src/test/esbuild_plugin_svelte.test.ts b/src/test/esbuild_plugin_svelte.test.ts index eff3e8f983..b495878185 100644 --- a/src/test/esbuild_plugin_svelte.test.ts +++ b/src/test/esbuild_plugin_svelte.test.ts @@ -3,7 +3,10 @@ import * as esbuild from 'esbuild'; import { readFile, rm } from 'node:fs/promises'; import { esbuild_plugin_svelte } from '$lib/esbuild_plugin_svelte.ts'; -import { default_svelte_config } from '$lib/svelte_config.ts'; + +// Passed literally rather than read off the project's config, +// which would cost a full Vite config resolution for a value these tests already know. +const base_url = ''; // TODO improve these tests to have automatic caching @@ -14,7 +17,7 @@ test('build for the client', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: default_svelte_config.base_url, + base_url, svelte_compile_options: { generate: 'client' } }) ], @@ -81,7 +84,7 @@ test('build for the server', async () => { plugins: [ esbuild_plugin_svelte({ dev: true, - base_url: default_svelte_config.base_url + base_url }) ], outfile, diff --git a/src/test/gro_config.test.ts b/src/test/gro_config.test.ts index 49ea8042bc..5ff75f6fcf 100644 --- a/src/test/gro_config.test.ts +++ b/src/test/gro_config.test.ts @@ -7,12 +7,13 @@ import { load_gro_config } from '$lib/gro_config.ts'; -test('load_gro_config', async () => { - // Mock the dynamic import to avoid module resolution issues - vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false) - })); +// Makes `load_gro_config` see no `gro.config.ts` and fall back to the default config. +// At the top level because that's where Vitest hoists it to anyway. +vi.mock('node:fs', () => ({ + existsSync: vi.fn().mockReturnValue(false) +})); +test('load_gro_config', async () => { const config = await load_gro_config(); expect(config).toBeTruthy(); expect(config.plugins).toBeDefined(); diff --git a/src/test/package_json.test.ts b/src/test/package_json.test.ts index f3c3c153a7..137934e4f5 100644 --- a/src/test/package_json.test.ts +++ b/src/test/package_json.test.ts @@ -1,7 +1,8 @@ -import { test, expect } from 'vitest'; +import { describe, test, expect } from 'vitest'; import { PackageJson, PackageJsonExports } from '@fuzdev/fuz_util/package_json.ts'; import { + package_json_has_dependency, package_json_load, package_json_parse_repo_url, package_json_serialize, @@ -214,3 +215,27 @@ test('rejects invalid exports', () => { expect(parsed.success).toBe(false); } }); + +describe('package_json_has_dependency', () => { + const package_json: PackageJson = { + name: 'a', + version: '0', + dependencies: { dep: '1' }, + devDependencies: { dev_dep: '1' }, + peerDependencies: { peer_dep: '1' } + }; + + test('finds deps and dev deps', () => { + expect(package_json_has_dependency('dep', package_json)).toBe(true); + expect(package_json_has_dependency('dev_dep', package_json)).toBe(true); + expect(package_json_has_dependency('missing', package_json)).toBe(false); + }); + + // Peer deps count by default because most callers ask "is this available at runtime", + // but detection asks "is this what the project is", and a peer answers neither. + test('counts peer deps only when `include_peer`', () => { + expect(package_json_has_dependency('peer_dep', package_json)).toBe(true); + expect(package_json_has_dependency('peer_dep', package_json, false)).toBe(false); + expect(package_json_has_dependency('dev_dep', package_json, false)).toBe(true); + }); +}); diff --git a/src/test/plugin.test.ts b/src/test/plugin.test.ts index 97520ac113..3f3afcc9c0 100644 --- a/src/test/plugin.test.ts +++ b/src/test/plugin.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from 'vitest'; -import { plugin_replace } from '$lib/plugin.ts'; +import { plugin_replace, to_plugin_context } from '$lib/plugin.ts'; +import { create_mock_task_context } from './test_helpers.ts'; describe('plugin_replace', () => { test('plugin_replace', () => { @@ -54,3 +55,38 @@ describe('plugin_replace', () => { expect(err).toBeTruthy(); }); }); + +describe('to_plugin_context', () => { + test('adds `dev` and `watch` and carries the task context through', () => { + const ctx = create_mock_task_context({ a: 1 }); + const plugin_ctx = to_plugin_context(ctx, true, false); + expect(plugin_ctx.dev).toBe(true); + expect(plugin_ctx.watch).toBe(false); + expect(plugin_ctx.args).toBe(ctx.args); + expect(plugin_ctx.config).toBe(ctx.config); + expect(plugin_ctx.filer).toBe(ctx.filer); + expect(plugin_ctx.log).toBe(ctx.log); + expect(plugin_ctx.timings).toBe(ctx.timings); + expect(plugin_ctx.invoke_task).toBe(ctx.invoke_task); + }); + + // `svelte_config` is a lazy getter on the real task context, and spreading would call it, + // resolving the Svelte config for plugin sets that never read it. + test('does not read a lazy `svelte_config`', async () => { + let reads = 0; + const svelte_config = Promise.resolve('config'); + const ctx = Object.defineProperty(create_mock_task_context(), 'svelte_config', { + enumerable: true, + get: () => { + reads++; + return svelte_config; + } + }); + + const plugin_ctx = to_plugin_context(ctx, false, false); + expect(reads).toBe(0); + + await expect(plugin_ctx.svelte_config).resolves.toBe('config'); + expect(reads).toBe(1); + }); +}); diff --git a/src/test/svelte_config.test.ts b/src/test/svelte_config.test.ts new file mode 100644 index 0000000000..503010f4b0 --- /dev/null +++ b/src/test/svelte_config.test.ts @@ -0,0 +1,221 @@ +import { describe, test, expect, vi } from 'vitest'; +import type { Config as SvelteConfig } from '@sveltejs/kit'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + load_default_svelte_config, + load_svelte_config, + parse_svelte_config, + svelte_config_log +} from '$lib/svelte_config.ts'; + +// The project directory is always the cwd - see `svelte_config.ts` for why it can't be anything else. +const DIR = process.cwd(); + +const parse = (svelte_config: SvelteConfig) => parse_svelte_config({ svelte_config }); + +describe('parse_svelte_config', () => { + test('falls back to the conventional paths when nothing is configured', async () => { + const parsed = await parse({}); + expect(parsed.lib_path).toBe('src/lib'); + expect(parsed.routes_path).toBe('src/routes'); + expect(parsed.assets_path).toBe('static'); + expect(parsed.env_dir).toBe(undefined); + }); + + test('keeps already-relative paths as authored', async () => { + const parsed = await parse({ kit: { files: { lib: 'src/library', routes: 'src/pages' } } }); + expect(parsed.lib_path).toBe('src/library'); + expect(parsed.routes_path).toBe('src/pages'); + }); + + // Resolving through Vite yields absolute paths, but Gro's vocabulary is project-relative. + test('makes absolute paths relative to the project directory', async () => { + const parsed = await parse({ + kit: { + files: { + lib: DIR + '/src/lib', + routes: DIR + '/src/routes', + assets: DIR + '/static' + } + } + }); + expect(parsed.lib_path).toBe('src/lib'); + expect(parsed.routes_path).toBe('src/routes'); + expect(parsed.assets_path).toBe('static'); + }); + + // `env_dir` is serialized into the generated `$env/dynamic/*` modules, + // so an absolute path would bake the build machine's directory into server bundles. + test('makes an absolute env dir relative so it stays portable', async () => { + expect((await parse({ kit: { env: { dir: DIR } } })).env_dir).toBe('.'); + expect((await parse({ kit: { env: { dir: DIR + '/config' } } })).env_dir).toBe('config'); + expect((await parse({ kit: { env: { dir: 'config' } } })).env_dir).toBe('config'); + }); + + describe('alias', () => { + test('points `$lib` at the lib path, like SvelteKit', async () => { + expect((await parse({})).alias.$lib).toBe('src/lib'); + expect((await parse({ kit: { files: { lib: 'src/library' } } })).alias.$lib).toBe( + 'src/library' + ); + }); + + test('includes configured aliases and lets them override `$lib`', async () => { + const parsed = await parse({ kit: { alias: { $routes: 'src/routes', $lib: 'elsewhere' } } }); + expect(parsed.alias.$routes).toBe('src/routes'); + expect(parsed.alias.$lib).toBe('elsewhere'); + }); + }); + + describe('svelte_compile_options', () => { + test('defaults to generating for the server', async () => { + expect((await parse({})).svelte_compile_options.generate).toBe('server'); + expect((await parse({})).svelte_compile_module_options.generate).toBe('server'); + }); + + // `generate` is cast in because SvelteKit omits it from its own `compilerOptions` type - + // it reaches Gro from plain Svelte projects, which configure the compiler through Vite. + test('preserves configured compiler options', async () => { + const parsed = await parse({ + compilerOptions: { runes: true, generate: 'client' } as SvelteConfig['compilerOptions'] + }); + expect(parsed.svelte_compile_options.generate).toBe('client'); + expect(parsed.svelte_compile_options.runes).toBe(true); + }); + + test('does not mutate the source config', async () => { + const compilerOptions = { runes: true }; + await parse({ compilerOptions }); + expect(compilerOptions).toEqual({ runes: true }); + }); + }); + + test('passes the config through unparsed properties', async () => { + const svelte_config: SvelteConfig = { kit: { paths: { base: '/base' } } }; + const parsed = await parse(svelte_config); + expect(parsed.svelte_config).toBe(svelte_config); + expect(parsed.base_url).toBe('/base'); + }); +}); + +describe('load_default_svelte_config', () => { + test('memoizes', () => { + expect(load_default_svelte_config()).toBe(load_default_svelte_config()); + }); + + // Resolves this project's own `vite.config.ts` through Vite, the way every Gro + // invocation does, so it covers reading the config off the SvelteKit plugin. + // `env_dir` is `'.'` rather than undefined because SvelteKit defaults `kit.env.dir` + // to its own cwd, so the real path always yields an absolute one to rebase. + test('resolves the config of the project it runs in', async () => { + await expect(load_default_svelte_config()).resolves.toMatchObject({ + lib_path: 'src/lib', + routes_path: 'src/routes', + env_dir: '.' + }); + }); +}); + +/** + * Runs `fn` in an empty directory. The config is always read from the cwd, + * so moving the cwd is the only way to point `load_svelte_config` somewhere else. + * Note that `process.chdir` throws on a worker thread, so these tests need Vitest's + * default `forks` pool - switching to `threads` would break them. + */ +const in_empty_dir = async (fn: () => Promise): Promise => { + const cwd = process.cwd(); + const dir = mkdtempSync(join(tmpdir(), 'gro_svelte_config_')); + process.chdir(dir); + try { + return await fn(); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } +}; + +describe('load_svelte_config', () => { + // The path that keeps non-Vite projects working. Cheap too - + // it short-circuits before Vite is imported at all. + test('returns null when the project has no Vite config', async () => { + await expect(in_empty_dir(load_svelte_config)).resolves.toBe(null); + }); + + /** + * Loads the config in an empty dir seeded with `files`, capturing anything warned. + * `Logger` defaults to `'off'` under Vitest, so the level is opted back in here. + */ + const load_with_warnings = async ( + files: Record + ): Promise<{ loaded: SvelteConfig | null; warnings: Array }> => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + svelte_config_log.level = 'warn'; + try { + const loaded = await in_empty_dir(async () => { + for (const [filename, content] of Object.entries(files)) writeFileSync(filename, content); + return load_svelte_config(); + }); + return { loaded, warnings: warn.mock.calls.map((c) => c.join(' ')) }; + } finally { + svelte_config_log.clear_level_override(); + warn.mockRestore(); + } + }; + + // A project with neither config isn't a Svelte project, so it gets no warning, + // but one with a Svelte config and no Vite config is silently ignored without this. + test('warns when a Svelte config has no Vite config to be read through', async () => { + const { loaded, warnings } = await load_with_warnings({ + 'svelte.config.js': 'export default {};' + }); + expect(loaded).toBe(null); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('svelte.config.js'); + expect(warnings[0]).toContain('no Vite config'); + }); + + // The same silent ignore, one step further in: a `vite.config.js` that sets up something other + // than Svelte - Vitest, most plausibly - alongside a `svelte.config.js` doing the real work. + test('warns when the Vite config configures no Svelte plugin', async () => { + const { loaded, warnings } = await load_with_warnings({ + 'vite.config.js': 'export default {};', + 'svelte.config.js': 'export default {};' + }); + expect(loaded).toBe(null); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('svelte.config.js'); + expect(warnings[0]).toContain('no Svelte plugin'); + }); + + // Only the Svelte config makes the silence worth warning about - + // a project with a Vite config and no Svelte config is configuring nothing to ignore. + test('stays quiet when there is no Svelte config to ignore', async () => { + const { loaded, warnings } = await load_with_warnings({ + 'vite.config.js': 'export default {};' + }); + expect(loaded).toBe(null); + expect(warnings).toHaveLength(0); + }); + + // Vite's `resolveConfig` writes `NODE_ENV` when it's unset, and the `development` it would + // leave behind is inherited by the `vite build` that `gro build` spawns, which then builds + // for production as if for dev. Uses `load_svelte_config` rather than the memoized wrapper + // so the resolution actually runs. + test('leaves NODE_ENV as it found it', async () => { + const node_env = process.env.NODE_ENV; + delete process.env.NODE_ENV; + try { + await load_svelte_config(); + expect('NODE_ENV' in process.env).toBe(false); + } finally { + if (node_env === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = node_env; + } + } + }); +}); diff --git a/src/test/svelte_config_cache.test.ts b/src/test/svelte_config_cache.test.ts new file mode 100644 index 0000000000..c1962c3c5d --- /dev/null +++ b/src/test/svelte_config_cache.test.ts @@ -0,0 +1,147 @@ +import { describe, test, expect } from 'vitest'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + SVELTE_CONFIG_CACHE_FILENAME, + SVELTE_CONFIG_CACHE_VERSION, + svelte_config_cache_read, + svelte_config_cache_stamps, + svelte_config_cache_write +} from '$lib/svelte_config_cache.ts'; +import { GRO_DIRNAME } from '$lib/constants.ts'; + +const ALIAS = { $lib: 'src/lib', $routes: 'src/routes' }; + +/** What a successful config read produces, in the shape `svelte_config_cache_write` takes. */ +const READ = { alias: ALIAS, svelte_config_found: true }; + +/** + * Runs `fn` in a fresh directory seeded with `files`, and cleans it up after. + * Unlike the `svelte_config` tests this doesn't need the cwd, since every entry point + * takes an explicit `dir` - only the loader relies on the cwd default. + */ +const in_dir = (files: Record, fn: (dir: string) => T): T => { + const dir = mkdtempSync(join(tmpdir(), 'gro_svelte_config_cache_')); + try { + for (const [filename, content] of Object.entries(files)) { + writeFileSync(join(dir, filename), content); + } + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}; + +const cache_path = (dir: string) => join(dir, GRO_DIRNAME, SVELTE_CONFIG_CACHE_FILENAME); + +describe('svelte_config_cache_stamps', () => { + test('stamps present files and records absent ones as null', () => { + in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + expect(stamps['vite.config.ts']).toBeTypeOf('string'); + expect(stamps['package.json']).toBeTypeOf('string'); + // Absent inputs are recorded rather than omitted, so that adding one invalidates. + expect(stamps['svelte.config.js']).toBe(null); + expect(stamps['vite.config.mjs']).toBe(null); + }); + }); +}); + +describe('svelte_config_cache_read', () => { + test('returns null when there is no cache', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + test('round-trips a written cache', () => { + in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + svelte_config_cache_write(stamps, READ, dir); + const cache = svelte_config_cache_read(stamps, dir); + expect(cache?.alias).toEqual(ALIAS); + expect(cache?.svelte_config_found).toBe(true); + expect(cache?.version).toBe(SVELTE_CONFIG_CACHE_VERSION); + }); + }); + + test('carries `svelte_config_found: false` so the warning can repeat', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + svelte_config_cache_write(stamps, { ...READ, svelte_config_found: false }, dir); + expect(svelte_config_cache_read(stamps, dir)?.svelte_config_found).toBe(false); + }); + }); + + test('misses when a tracked file changes', () => { + in_dir({ 'vite.config.ts': 'a', 'package.json': '{}' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); + writeFileSync(join(dir, 'vite.config.ts'), 'a different length'); + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + // The reason absent inputs are stamped: a project that adds a Svelte config has to + // re-resolve, or it would keep running on aliases read before the file existed. + test('misses when a tracked file appears', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); + writeFileSync(join(dir, 'svelte.config.js'), 'export default {};'); + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + test('misses when a tracked file is removed', () => { + in_dir({ 'vite.config.ts': 'a', 'svelte.config.js': 'b' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); + rmSync(join(dir, 'svelte.config.js')); + expect(svelte_config_cache_read(svelte_config_cache_stamps(dir), dir)).toBe(null); + }); + }); + + test('misses on a stale version', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + svelte_config_cache_write(stamps, READ, dir); + const cache = JSON.parse(readFileSync(cache_path(dir), 'utf8')); + writeFileSync( + cache_path(dir), + JSON.stringify({ ...cache, version: SVELTE_CONFIG_CACHE_VERSION + 1 }) + ); + expect(svelte_config_cache_read(stamps, dir)).toBe(null); + }); + }); + + test('misses on malformed contents rather than throwing', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + const stamps = svelte_config_cache_stamps(dir); + mkdirSync(join(dir, GRO_DIRNAME), { recursive: true }); + for (const contents of ['not json', 'null', '[]', '{"version":1}']) { + writeFileSync(cache_path(dir), contents); + expect(svelte_config_cache_read(stamps, dir)).toBe(null); + } + }); + }); +}); + +describe('svelte_config_cache_write', () => { + test('creates the .gro directory', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir); + expect(JSON.parse(readFileSync(cache_path(dir), 'utf8')).alias).toEqual(ALIAS); + }); + }); + + // Caching is an optimization, so an unwritable directory costs speed and nothing else. + test('swallows write failures', () => { + in_dir({ 'vite.config.ts': 'a' }, (dir) => { + // A file where the `.gro` directory would go makes `mkdirSync` fail. + writeFileSync(join(dir, GRO_DIRNAME), ''); + expect(() => + svelte_config_cache_write(svelte_config_cache_stamps(dir), READ, dir) + ).not.toThrow(); + }); + }); +}); diff --git a/src/test/sveltekit_helpers.test.ts b/src/test/sveltekit_helpers.test.ts new file mode 100644 index 0000000000..14de1f1569 --- /dev/null +++ b/src/test/sveltekit_helpers.test.ts @@ -0,0 +1,73 @@ +import { assert, describe, test, expect } from 'vitest'; +import type { PackageJson } from '@fuzdev/fuz_util/package_json.ts'; + +import { has_sveltekit_app, has_sveltekit_library } from '$lib/sveltekit_helpers.ts'; +import { SVELTE_PACKAGE_DEP_NAME, SVELTEKIT_DEP_NAME } from '$lib/constants.ts'; + +const to_package_json = ( + deps: Partial> +): PackageJson => ({ name: 'a', version: '0', ...deps }); + +describe('has_sveltekit_app', () => { + test('detects SvelteKit as a dep or dev dep', () => { + expect( + has_sveltekit_app(to_package_json({ dependencies: { [SVELTEKIT_DEP_NAME]: '2' } })).ok + ).toBe(true); + expect( + has_sveltekit_app(to_package_json({ devDependencies: { [SVELTEKIT_DEP_NAME]: '2' } })).ok + ).toBe(true); + }); + + test('is not detected with no SvelteKit dependency', () => { + expect(has_sveltekit_app(to_package_json({})).ok).toBe(false); + }); + + // A package peered on SvelteKit is built to work with one, not to be one - + // counting the peer would run `vite build` over a library that has no app. + test('ignores a peer dependency', () => { + expect( + has_sveltekit_app(to_package_json({ peerDependencies: { [SVELTEKIT_DEP_NAME]: '2' } })).ok + ).toBe(false); + }); +}); + +describe('has_sveltekit_library', () => { + test('needs SvelteKit first', async () => { + const result = await has_sveltekit_library( + to_package_json({ devDependencies: { [SVELTE_PACKAGE_DEP_NAME]: '2' } }) + ); + assert(!result.ok); + expect(result.message).toContain(SVELTEKIT_DEP_NAME); + }); + + // The packaging dep is checked before the lib directory so a project that isn't a library + // never reads the Svelte config, which is the only one of the three checks that costs anything. + test('needs the packaging dep, and reports it before reading the config', async () => { + const result = await has_sveltekit_library( + to_package_json({ devDependencies: { [SVELTEKIT_DEP_NAME]: '2' } }) + ); + assert(!result.ok); + expect(result.message).toContain(SVELTE_PACKAGE_DEP_NAME); + }); + + // Resolves this project's own config for the lib directory check, so it covers the whole path. + test('detects this project', async () => { + const result = await has_sveltekit_library( + to_package_json({ + devDependencies: { [SVELTEKIT_DEP_NAME]: '2', [SVELTE_PACKAGE_DEP_NAME]: '2' } + }) + ); + expect(result.ok).toBe(true); + }); + + test('ignores peer dependencies', async () => { + const result = await has_sveltekit_library( + to_package_json({ + devDependencies: { [SVELTEKIT_DEP_NAME]: '2' }, + peerDependencies: { [SVELTE_PACKAGE_DEP_NAME]: '2' } + }) + ); + assert(!result.ok); + expect(result.message).toContain(SVELTE_PACKAGE_DEP_NAME); + }); +}); diff --git a/src/test/test_helpers.ts b/src/test/test_helpers.ts index 990ce0b88f..4608a4f263 100644 --- a/src/test/test_helpers.ts +++ b/src/test/test_helpers.ts @@ -321,7 +321,7 @@ export const create_mock_task_context = ( build_cache_config_hash: 'test_hash', ...config_overrides } as GroConfig, - svelte_config: create_mock_svelte_config(), + svelte_config: Promise.resolve(create_mock_svelte_config()), filer: create_mock_filer(), log: create_mock_logger(), timings: create_mock_timings(),