From c89f6abae863c6db00557e0a283aa0e6f31b45c6 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Tue, 17 Mar 2026 16:11:32 -0400 Subject: [PATCH] feat: allow `css`, `runes`, `customElement` compiler options to be functions (#17951) Alternative to #17950. Closes #17952 The goal of this is to allow svelte.config.js to contain functions for setting certain options, so that there's a single source of truth for everything that needs to interact with Svelte config (plugins, editor extensions, etc): ```js // svelte.config.js export default { compilerOptions: { css: ({ filename }) => filename.endsWith('/OG.svelte') ? 'injected' : 'external', experimental: { async: true }, runes: ({ filename }) => !filename.split(/\/\\/).includes('node_modules') } }; ``` Once this ships, we can deprecate `dynamicCompileOptions` in `vite-plugin-svelte`. ### Before submitting the PR, please make sure you do the following - [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs - [x] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [x] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. - [x] If this PR changes code within `packages/svelte/src`, add a changeset (`npx changeset`). ### Tests and linting - [x] Run the tests with `pnpm test` and lint the project with `pnpm lint` --- .changeset/evil-nails-live.md | 5 ++ packages/svelte/src/compiler/index.js | 5 +- packages/svelte/src/compiler/migrate/index.js | 2 + .../src/compiler/phases/2-analyze/index.js | 21 ++++-- .../3-transform/client/transform-client.js | 2 +- .../compiler/phases/3-transform/css/index.js | 2 +- .../3-transform/server/transform-server.js | 2 +- packages/svelte/src/compiler/types/index.d.ts | 16 +++- .../svelte/src/compiler/validate-options.js | 73 ++++++++++++++----- packages/svelte/types/index.d.ts | 20 +++-- 10 files changed, 108 insertions(+), 40 deletions(-) create mode 100644 .changeset/evil-nails-live.md diff --git a/.changeset/evil-nails-live.md b/.changeset/evil-nails-live.md new file mode 100644 index 0000000000..16dc3f633e --- /dev/null +++ b/.changeset/evil-nails-live.md @@ -0,0 +1,5 @@ +--- +'svelte': minor +--- + +feat: allow `css`, `runes`, `customElement` compiler options to be functions diff --git a/packages/svelte/src/compiler/index.js b/packages/svelte/src/compiler/index.js index e864c4a1f4..1d822514b9 100644 --- a/packages/svelte/src/compiler/index.js +++ b/packages/svelte/src/compiler/index.js @@ -23,6 +23,7 @@ export { print } from './print/index.js'; export function compile(source, options) { source = remove_bom(source); state.reset({ warning: options.warningFilter, filename: options.filename }); + const validated = validate_component_options(options, ''); let parsed = _parse(source); @@ -33,7 +34,9 @@ export function compile(source, options) { const combined_options = { ...validated, ...parsed_options, - customElementOptions + customElementOptions, + css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : validated.css, + runes: 'runes' in parsed_options ? () => parsed_options.runes : validated.runes }; if (parsed.metadata.ts) { diff --git a/packages/svelte/src/compiler/migrate/index.js b/packages/svelte/src/compiler/migrate/index.js index ce5387f4dd..0370155c12 100644 --- a/packages/svelte/src/compiler/migrate/index.js +++ b/packages/svelte/src/compiler/migrate/index.js @@ -146,6 +146,8 @@ export function migrate(source, { filename, use_ts } = {}) { ...parsed_options, customElementOptions, filename: filename ?? UNKNOWN_FILENAME, + css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external', + runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined, experimental: { async: true } diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index fbd2e1cb8a..cadd159b3e 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -345,6 +345,8 @@ export function analyze_component(root, source, options) { let synthetic_stores_legacy_check = []; + const runes_option = options.runes?.({ filename: options.filename }); + // create synthetic bindings for store subscriptions for (const [name, references] of module.scope.references) { if (name[0] !== '$' || RESERVED.includes(name)) continue; @@ -359,7 +361,7 @@ export function analyze_component(root, source, options) { // If we're not in legacy mode through the compiler option, assume the user // is referencing a rune and not a global store. if ( - options.runes === false || + runes_option === false || !is_rune(name) || (declaration !== null && // const state = $state(0) is valid @@ -395,7 +397,7 @@ export function analyze_component(root, source, options) { e.store_invalid_scoped_subscription(is_nested_store_subscription_node); } - if (options.runes !== false) { + if (runes_option !== false) { if (declaration === null && /[a-z]/.test(store_name[0])) { e.global_reference_invalid(references[0].node, name); } else if (declaration !== null && is_rune(name)) { @@ -447,7 +449,7 @@ export function analyze_component(root, source, options) { const component_name = get_component_name(options.filename); const runes = - options.runes ?? + runes_option ?? (has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune)); if (!runes) { @@ -463,7 +465,10 @@ export function analyze_component(root, source, options) { } } - const is_custom_element = !!options.customElementOptions || options.customElement; + const custom_element_from_option = options.customElement({ filename: options.filename }); + const css = options.css({ filename: options.filename }); + const custom_element = options.customElementOptions ?? custom_element_from_option; + const is_custom_element = !!options.customElementOptions || custom_element_from_option; const name = module.scope.generate(options.name ?? component_name); @@ -491,7 +496,7 @@ export function analyze_component(root, source, options) { maybe_runes: !runes && // if they explicitly disabled runes, use the legacy behavior - options.runes !== false && + runes_option !== false && ![...module.scope.references.keys()].some((name) => ['$$props', '$$restProps'].includes(name) ) && @@ -523,8 +528,8 @@ export function analyze_component(root, source, options) { needs_props: false, event_directive_node: null, uses_event_attributes: false, - custom_element: is_custom_element, - inject_styles: options.css === 'injected' || is_custom_element, + custom_element, + inject_styles: css === 'injected' || is_custom_element, accessors: is_custom_element || (runes ? false : !!options.accessors) || @@ -680,7 +685,7 @@ export function analyze_component(root, source, options) { w.options_deprecated_accessors(attribute); } - if (attribute.name === 'customElement' && !options.customElement) { + if (attribute.name === 'customElement' && !custom_element_from_option) { w.options_missing_custom_element(attribute); } diff --git a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js index b50a73b8b6..9328d20be3 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/transform-client.js @@ -595,7 +595,7 @@ export function client_component(analysis, options) { ); } - const ce = options.customElementOptions ?? options.customElement; + const ce = analysis.custom_element; if (ce) { const ce_props = typeof ce === 'boolean' ? {} : ce.props || {}; diff --git a/packages/svelte/src/compiler/phases/3-transform/css/index.js b/packages/svelte/src/compiler/phases/3-transform/css/index.js index cee7ab2791..92b2706f22 100644 --- a/packages/svelte/src/compiler/phases/3-transform/css/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/css/index.js @@ -65,7 +65,7 @@ export function render_stylesheet(source, analysis, options) { merge_with_preprocessor_map(css, options, css.map.sources[0]); - if (dev && options.css === 'injected' && css.code) { + if (dev && analysis.inject_styles && css.code) { css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`; } diff --git a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js index b9f1441bff..90a693b99a 100644 --- a/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js +++ b/packages/svelte/src/compiler/phases/3-transform/server/transform-server.js @@ -300,7 +300,7 @@ export function server_component(analysis, options) { const body = [...state.hoisted, ...module.body]; - if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) { + if (analysis.css.ast !== null && analysis.inject_styles && !analysis.custom_element) { const hash = b.literal(analysis.css.hash); const code = b.literal(render_stylesheet(analysis.source, analysis, options).code); diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index fce3f62c5c..c04466a24d 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -73,9 +73,11 @@ export interface CompileOptions extends ModuleCompileOptions { /** * If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component. * + * You can also pass a function that receives `{ filename }` and returns a boolean. + * * @default false */ - customElement?: boolean; + customElement?: boolean | ((options: { filename: string }) => boolean); /** * If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`. * @@ -101,8 +103,10 @@ export interface CompileOptions extends ModuleCompileOptions { * - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root. * - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files. * This is always `'injected'` when compiling with `customElement` mode. + * + * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`. */ - css?: 'injected' | 'external'; + css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'); /** * A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS. * It defaults to returning `svelte-${hash(filename ?? css)}`. @@ -142,7 +146,7 @@ export interface CompileOptions extends ModuleCompileOptions { * which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead. * @default undefined */ - runes?: boolean | undefined; + runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. * @@ -248,18 +252,22 @@ export type ValidatedCompileOptions = ValidatedModuleCompileOptions & Required, | keyof ModuleCompileOptions | 'name' + | 'customElement' | 'compatibility' | 'outputFilename' | 'cssOutputFilename' | 'sourcemap' + | 'css' | 'runes' > & { name: CompileOptions['name']; + customElement: (options: { filename: string }) => boolean; outputFilename: CompileOptions['outputFilename']; cssOutputFilename: CompileOptions['cssOutputFilename']; sourcemap: CompileOptions['sourcemap']; compatibility: Required['compatibility']>; - runes: CompileOptions['runes']; + css: (options: { filename: string }) => 'injected' | 'external'; + runes: (options: { filename: string }) => boolean | undefined; customElementOptions: AST.SvelteOptions['customElement']; hmr: CompileOptions['hmr']; }; diff --git a/packages/svelte/src/compiler/validate-options.js b/packages/svelte/src/compiler/validate-options.js index a94a553311..f7c56aa33c 100644 --- a/packages/svelte/src/compiler/validate-options.js +++ b/packages/svelte/src/compiler/validate-options.js @@ -51,24 +51,28 @@ const common_options = { const component_options = { accessors: deprecate(w.options_deprecated_accessors, boolean(false)), - css: validator('external', (input) => { - if (input === true || input === false) { - throw_error( - 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true' - ); - } - if (input === 'none') { - throw_error( - 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.' - ); - } + /** @type {Validator<'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'), (options: { filename: string }) => 'injected' | 'external'>} */ + css: parametric( + /** @type {(options: { filename: string }) => 'injected' | 'external'} */ (() => 'external'), + (input) => { + if (input === true || input === false) { + throw_error( + 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true' + ); + } + if (input === 'none') { + throw_error( + 'css: "none" is no longer a valid option. If this was crucial for you, please open an issue on GitHub with your use case.' + ); + } - if (input !== 'external' && input !== 'injected') { - throw_error(`css should be either "external" (default, recommended) or "injected"`); - } + if (input !== 'external' && input !== 'injected') { + throw_error(`css should be either "external" (default, recommended) or "injected"`); + } - return input; - }), + return /** @type {'external' | 'injected'} */ (input); + } + ), cssHash: fun(({ css, filename, hash }) => { return `svelte-${hash(filename === '(unknown)' ? css : filename ?? css)}`; @@ -77,7 +81,17 @@ const component_options = { // TODO this is a sourcemap option, would be good to put under a sourcemap namespace cssOutputFilename: string(undefined), - customElement: boolean(false), + /** @type {Validator boolean), (options: { filename: string }) => boolean>} */ + customElement: parametric( + /** @type {(options: { filename: string }) => boolean} */ (() => false), + (input, keypath) => { + if (typeof input !== 'boolean') { + throw_error(`${keypath} should be true or false`); + } + + return /** @type {boolean} */ (input); + } + ), discloseVersion: boolean(true), @@ -107,7 +121,8 @@ const component_options = { preserveWhitespace: boolean(false), - runes: boolean(undefined), + /** @type {Validator boolean | undefined), () => boolean | undefined>} */ + runes: parametric(() => /** @type {boolean | undefined} */ (undefined)), hmr: boolean(false), @@ -318,6 +333,28 @@ function fun(fallback) { }); } +/** + * @template {(...args: any[]) => any} F + * @param {F} fallback + * @param {(value: unknown, keypath: string) => ReturnType} [normalize] + * @returns {Validator} + */ +function parametric(fallback, normalize = (value) => /** @type {ReturnType} */ (value)) { + return validator(fallback, (input, keypath) => { + if (typeof input === 'function') { + /** @type {(...args: Parameters) => ReturnType} */ + const normalized = (...args) => normalize(input(...args), keypath); + + return /** @type {F} */ (/** @type {unknown} */ (normalized)); + } + + /** @type {(...args: Parameters) => ReturnType} */ + const normalized = (..._args) => normalize(input, keypath); + + return /** @type {F} */ (/** @type {unknown} */ (normalized)); + }); +} + /** @param {string} msg */ function throw_error(msg) { e.options_invalid_value(null, msg); diff --git a/packages/svelte/types/index.d.ts b/packages/svelte/types/index.d.ts index 31ef9110df..371a823225 100644 --- a/packages/svelte/types/index.d.ts +++ b/packages/svelte/types/index.d.ts @@ -1030,9 +1030,11 @@ declare module 'svelte/compiler' { /** * If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component. * + * You can also pass a function that receives `{ filename }` and returns a boolean. + * * @default false */ - customElement?: boolean; + customElement?: boolean | ((options: { filename: string }) => boolean); /** * If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`. * @@ -1058,8 +1060,10 @@ declare module 'svelte/compiler' { * - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root. * - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files. * This is always `'injected'` when compiling with `customElement` mode. + * + * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`. */ - css?: 'injected' | 'external'; + css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'); /** * A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS. * It defaults to returning `svelte-${hash(filename ?? css)}`. @@ -1099,7 +1103,7 @@ declare module 'svelte/compiler' { * which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead. * @default undefined */ - runes?: boolean | undefined; + runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. * @@ -3006,9 +3010,11 @@ declare module 'svelte/types/compiler/interfaces' { /** * If `true`, tells the compiler to generate a custom element constructor instead of a regular Svelte component. * + * You can also pass a function that receives `{ filename }` and returns a boolean. + * * @default false */ - customElement?: boolean; + customElement?: boolean | ((options: { filename: string }) => boolean); /** * If `true`, getters and setters will be created for the component's props. If `false`, they will only be created for readonly exported values (i.e. those declared with `const`, `class` and `function`). If compiling with `customElement: true` this option defaults to `true`. * @@ -3034,8 +3040,10 @@ declare module 'svelte/types/compiler/interfaces' { * - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root. * - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files. * This is always `'injected'` when compiling with `customElement` mode. + * + * You can also pass a function that receives `{ filename }` and returns either `'injected'` or `'external'`. */ - css?: 'injected' | 'external'; + css?: 'injected' | 'external' | ((options: { filename: string }) => 'injected' | 'external'); /** * A function that takes a `{ hash, css, name, filename }` argument and returns the string that is used as a classname for scoped CSS. * It defaults to returning `svelte-${hash(filename ?? css)}`. @@ -3075,7 +3083,7 @@ declare module 'svelte/types/compiler/interfaces' { * which is likely not what you want. If you're using Vite, consider using [dynamicCompileOptions](https://github.com/sveltejs/vite-plugin-svelte/blob/main/docs/config.md#dynamiccompileoptions) instead. * @default undefined */ - runes?: boolean | undefined; + runes?: boolean | undefined | ((options: { filename: string }) => boolean | undefined); /** * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`. *