@sveltejs/vite-plugin-svelte
Version:
The official [Svelte](https://svelte.dev) plugin for [Vite](https://vitejs.dev).
271 lines (244 loc) • 7.79 kB
JavaScript
/** @import { Code } from '../types/compile.js' */
/** @import { ResolvedOptions } from '../types/options.js' */
/** @import { PluginAPI } from '../types/plugin-api.js' */
/** @import { StatCollection } from '../types/vite-plugin-svelte-stats.js' */
/** @import { CompileOptions } from 'svelte/compiler' */
/** @import { Plugin, ResolvedConfig, Rolldown, UserConfig } from 'vite' */
import fs from 'node:fs/promises';
import path from 'node:path';
import * as svelte from 'svelte/compiler';
import { log } from '../utils/log.js';
import { toRollupError } from '../utils/error.js';
import { SVELTE_IMPORTS } from '../utils/constants.js';
import { isDepExcluded } from 'vitefu';
/**
* @typedef {NonNullable<Rolldown.Plugin>} RollupPlugin
*/
const optimizeSveltePluginName = 'vite-plugin-svelte:optimize';
const optimizeSvelteModulePluginName = 'vite-plugin-svelte:optimize-module';
/**
* @param {PluginAPI} api
* @returns {Plugin}
*/
export function setupOptimizer(api) {
/** @type {ResolvedConfig} */
let viteConfig;
return {
name: 'vite-plugin-svelte:setup-optimizer',
apply: 'serve',
configEnvironment(name, config) {
// fall back to vite behavior when consumer isn't set
const consumer = (config.consumer ?? name === 'client') ? 'client' : 'server';
/** @type {UserConfig['optimizeDeps']} */
const optimizeDeps = {
// Experimental Vite API to allow these extensions to be scanned and prebundled
extensions: ['.svelte']
};
// Add optimizer plugins to prebundle Svelte files.
optimizeDeps.rolldownOptions = {
plugins: [
rolldownOptimizerPlugin(api, consumer, true),
rolldownOptimizerPlugin(api, consumer, false)
]
};
if (consumer === 'server' && !isDepExcluded('svelte', config.optimizeDeps?.exclude ?? [])) {
optimizeDeps.include = [...SVELTE_IMPORTS];
}
return { optimizeDeps };
},
configResolved(c) {
viteConfig = c;
},
async buildStart() {
if (!api.options.prebundleSvelteLibraries) return;
const changed = await svelteMetadataChanged(viteConfig.cacheDir, api.options);
if (changed) {
// Force Vite to optimize again. Although we mutate the config here, it works because
// Vite's optimizer runs after `buildStart()`.
viteConfig.optimizeDeps.force = true;
}
}
};
}
/**
* @param {import('../types/plugin-api.d.ts').PluginAPI} api
* @param {'server'|'client'} consumer
* @param {boolean} components
* @return {Rolldown.Plugin}
*/
function rolldownOptimizerPlugin(api, consumer, components) {
const name = components ? optimizeSveltePluginName : optimizeSvelteModulePluginName;
const compileFn = components ? compileSvelte : compileSvelteModule;
const statsName = components ? 'prebundle library components' : 'prebundle library modules';
const includeRe = components ? /^[^?#]+\.svelte(?:[?#]|$)/ : /^[^?#]+\.svelte\.[jt]s(?:[?#]|$)/;
const generate = consumer === 'server' ? 'server' : 'client';
/** @type {StatCollection | undefined} */
let statsCollection;
/**@type {Rolldown.Plugin}*/
const plugin = {
name
};
plugin.options = (opts) => {
// @ts-expect-error plugins is an array here
const isScanner = opts.plugins.some(
(/** @type {{ name: string; } | undefined} */ p) => p?.name === 'vite:dep-scan:resolve'
);
if (isScanner) {
delete plugin.buildStart;
delete plugin.transform;
delete plugin.buildEnd;
} else {
plugin.transform = {
filter: { id: includeRe },
/**
* @param {string} code
* @param {string} filename
*/
async handler(code, filename) {
try {
return await compileFn(api.options, { filename, code }, generate, statsCollection);
} catch (e) {
throw toRollupError(e, api.options);
}
}
};
plugin.buildStart = () => {
statsCollection = api.options.stats?.startCollection(statsName, {
logResult: (c) => c.stats.length > 1
});
};
plugin.buildEnd = () => {
statsCollection?.finish();
};
}
};
return plugin;
}
/**
* @param {ResolvedOptions} options
* @param {{ filename: string, code: string }} input
* @param {'client'|'server'} generate
* @param {StatCollection} [statsCollection]
* @returns {Promise<Code>}
*/
async function compileSvelte(options, { filename, code }, generate, statsCollection) {
let css = options.compilerOptions.css;
if (css !== 'injected') {
// TODO ideally we'd be able to externalize prebundled styles too, but for now always put them in the js
css = 'injected';
}
/** @type {CompileOptions} */
const compileOptions = {
dev: true, // default to dev: true because prebundling is only used in dev
...options.compilerOptions,
css,
filename,
generate
};
let preprocessed;
if (options.preprocess) {
try {
preprocessed = await svelte.preprocess(code, options.preprocess, { filename });
} catch (e) {
e.message = `Error while preprocessing ${filename}${e.message ? ` - ${e.message}` : ''}`;
throw e;
}
if (preprocessed.map) compileOptions.sourcemap = preprocessed.map;
}
const finalCode = preprocessed ? preprocessed.code : code;
const dynamicCompileOptions = await options?.dynamicCompileOptions?.({
filename,
code: finalCode,
compileOptions
});
if (dynamicCompileOptions && log.debug.enabled) {
log.debug(
`dynamic compile options for ${filename}: ${JSON.stringify(dynamicCompileOptions)}`,
undefined,
'compile'
);
}
const finalCompileOptions = dynamicCompileOptions
? {
...compileOptions,
...dynamicCompileOptions
}
: compileOptions;
const endStat = statsCollection?.start(filename);
const compiled = svelte.compile(finalCode, finalCompileOptions);
if (endStat) {
endStat();
}
return {
...compiled.js,
moduleType: 'js'
};
}
/**
* @param {ResolvedOptions} options
* @param {{ filename: string; code: string }} input
* @param {'client'|'server'} generate
* @param {StatCollection} [statsCollection]
* @returns {Promise<Code>}
*/
async function compileSvelteModule(options, { filename, code }, generate, statsCollection) {
const endStat = statsCollection?.start(filename);
const compiled = svelte.compileModule(code, {
dev: options.compilerOptions?.dev ?? true, // default to dev: true because prebundling is only used in dev
filename,
generate
});
if (endStat) {
endStat();
}
return {
...compiled.js,
moduleType: 'js'
};
}
// List of options that changes the prebundling result
/** @type {(keyof ResolvedOptions)[]} */
const PREBUNDLE_SENSITIVE_OPTIONS = [
'compilerOptions',
'configFile',
'experimental',
'extensions',
'preprocess'
];
/**
* stores svelte metadata in cache dir and compares if it has changed
*
* @param {string} cacheDir
* @param {ResolvedOptions} options
* @returns {Promise<boolean>} Whether the Svelte metadata has changed
*/
async function svelteMetadataChanged(cacheDir, options) {
const svelteMetadata = generateSvelteMetadata(options);
const svelteMetadataPath = path.resolve(cacheDir, '_svelte_metadata.json');
const currentSvelteMetadata = JSON.stringify(svelteMetadata, (_, value) => {
// Handle preprocessors
return typeof value === 'function' ? value.toString() : value;
});
/** @type {string | undefined} */
let existingSvelteMetadata;
try {
existingSvelteMetadata = await fs.readFile(svelteMetadataPath, 'utf8');
} catch {
// ignore
}
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(svelteMetadataPath, currentSvelteMetadata);
return currentSvelteMetadata !== existingSvelteMetadata;
}
/**
* @param {ResolvedOptions} options
* @returns {Partial<ResolvedOptions>}
*/
function generateSvelteMetadata(options) {
/** @type {Record<string, any>} */
const metadata = {};
for (const key of PREBUNDLE_SENSITIVE_OPTIONS) {
metadata[key] = options[key];
}
return metadata;
}