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`
pull/17953/head
Rich Harris 6 months ago committed by GitHub
parent 5faf102782
commit c89f6abae8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': minor
---
feat: allow `css`, `runes`, `customElement` compiler options to be functions

@ -23,6 +23,7 @@ export { print } from './print/index.js';
export function compile(source, options) { export function compile(source, options) {
source = remove_bom(source); source = remove_bom(source);
state.reset({ warning: options.warningFilter, filename: options.filename }); state.reset({ warning: options.warningFilter, filename: options.filename });
const validated = validate_component_options(options, ''); const validated = validate_component_options(options, '');
let parsed = _parse(source); let parsed = _parse(source);
@ -33,7 +34,9 @@ export function compile(source, options) {
const combined_options = { const combined_options = {
...validated, ...validated,
...parsed_options, ...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) { if (parsed.metadata.ts) {

@ -146,6 +146,8 @@ export function migrate(source, { filename, use_ts } = {}) {
...parsed_options, ...parsed_options,
customElementOptions, customElementOptions,
filename: filename ?? UNKNOWN_FILENAME, filename: filename ?? UNKNOWN_FILENAME,
css: 'css' in parsed_options ? () => parsed_options.css ?? 'external' : () => 'external',
runes: 'runes' in parsed_options ? () => parsed_options.runes : () => undefined,
experimental: { experimental: {
async: true async: true
} }

@ -345,6 +345,8 @@ export function analyze_component(root, source, options) {
let synthetic_stores_legacy_check = []; let synthetic_stores_legacy_check = [];
const runes_option = options.runes?.({ filename: options.filename });
// create synthetic bindings for store subscriptions // create synthetic bindings for store subscriptions
for (const [name, references] of module.scope.references) { for (const [name, references] of module.scope.references) {
if (name[0] !== '$' || RESERVED.includes(name)) continue; 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 // If we're not in legacy mode through the compiler option, assume the user
// is referencing a rune and not a global store. // is referencing a rune and not a global store.
if ( if (
options.runes === false || runes_option === false ||
!is_rune(name) || !is_rune(name) ||
(declaration !== null && (declaration !== null &&
// const state = $state(0) is valid // 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); 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])) { if (declaration === null && /[a-z]/.test(store_name[0])) {
e.global_reference_invalid(references[0].node, name); e.global_reference_invalid(references[0].node, name);
} else if (declaration !== null && is_rune(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 component_name = get_component_name(options.filename);
const runes = const runes =
options.runes ?? runes_option ??
(has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune)); (has_await || instance.has_await || Array.from(module.scope.references.keys()).some(is_rune));
if (!runes) { 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); const name = module.scope.generate(options.name ?? component_name);
@ -491,7 +496,7 @@ export function analyze_component(root, source, options) {
maybe_runes: maybe_runes:
!runes && !runes &&
// if they explicitly disabled runes, use the legacy behavior // if they explicitly disabled runes, use the legacy behavior
options.runes !== false && runes_option !== false &&
![...module.scope.references.keys()].some((name) => ![...module.scope.references.keys()].some((name) =>
['$$props', '$$restProps'].includes(name) ['$$props', '$$restProps'].includes(name)
) && ) &&
@ -523,8 +528,8 @@ export function analyze_component(root, source, options) {
needs_props: false, needs_props: false,
event_directive_node: null, event_directive_node: null,
uses_event_attributes: false, uses_event_attributes: false,
custom_element: is_custom_element, custom_element,
inject_styles: options.css === 'injected' || is_custom_element, inject_styles: css === 'injected' || is_custom_element,
accessors: accessors:
is_custom_element || is_custom_element ||
(runes ? false : !!options.accessors) || (runes ? false : !!options.accessors) ||
@ -680,7 +685,7 @@ export function analyze_component(root, source, options) {
w.options_deprecated_accessors(attribute); 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); w.options_missing_custom_element(attribute);
} }

@ -595,7 +595,7 @@ export function client_component(analysis, options) {
); );
} }
const ce = options.customElementOptions ?? options.customElement; const ce = analysis.custom_element;
if (ce) { if (ce) {
const ce_props = typeof ce === 'boolean' ? {} : ce.props || {}; const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};

@ -65,7 +65,7 @@ export function render_stylesheet(source, analysis, options) {
merge_with_preprocessor_map(css, options, css.map.sources[0]); 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()} */`; css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`;
} }

@ -300,7 +300,7 @@ export function server_component(analysis, options) {
const body = [...state.hoisted, ...module.body]; 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 hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code); const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);

@ -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. * 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 * @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`. * 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. * - `'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. * - `'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. * 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. * 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)}`. * 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. * 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 * @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`. * 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<CompileOptions>, Required<CompileOptions>,
| keyof ModuleCompileOptions | keyof ModuleCompileOptions
| 'name' | 'name'
| 'customElement'
| 'compatibility' | 'compatibility'
| 'outputFilename' | 'outputFilename'
| 'cssOutputFilename' | 'cssOutputFilename'
| 'sourcemap' | 'sourcemap'
| 'css'
| 'runes' | 'runes'
> & { > & {
name: CompileOptions['name']; name: CompileOptions['name'];
customElement: (options: { filename: string }) => boolean;
outputFilename: CompileOptions['outputFilename']; outputFilename: CompileOptions['outputFilename'];
cssOutputFilename: CompileOptions['cssOutputFilename']; cssOutputFilename: CompileOptions['cssOutputFilename'];
sourcemap: CompileOptions['sourcemap']; sourcemap: CompileOptions['sourcemap'];
compatibility: Required<Required<CompileOptions>['compatibility']>; compatibility: Required<Required<CompileOptions>['compatibility']>;
runes: CompileOptions['runes']; css: (options: { filename: string }) => 'injected' | 'external';
runes: (options: { filename: string }) => boolean | undefined;
customElementOptions: AST.SvelteOptions['customElement']; customElementOptions: AST.SvelteOptions['customElement'];
hmr: CompileOptions['hmr']; hmr: CompileOptions['hmr'];
}; };

@ -51,7 +51,10 @@ const common_options = {
const component_options = { const component_options = {
accessors: deprecate(w.options_deprecated_accessors, boolean(false)), accessors: deprecate(w.options_deprecated_accessors, boolean(false)),
css: validator('external', (input) => { /** @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) { if (input === true || input === false) {
throw_error( throw_error(
'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true' 'The boolean options have been removed from the css option. Use "external" instead of false and "injected" instead of true'
@ -67,8 +70,9 @@ const component_options = {
throw_error(`css should be either "external" (default, recommended) or "injected"`); throw_error(`css should be either "external" (default, recommended) or "injected"`);
} }
return input; return /** @type {'external' | 'injected'} */ (input);
}), }
),
cssHash: fun(({ css, filename, hash }) => { cssHash: fun(({ css, filename, hash }) => {
return `svelte-${hash(filename === '(unknown)' ? css : filename ?? css)}`; 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 // TODO this is a sourcemap option, would be good to put under a sourcemap namespace
cssOutputFilename: string(undefined), cssOutputFilename: string(undefined),
customElement: boolean(false), /** @type {Validator<boolean | ((options: { filename: string }) => 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), discloseVersion: boolean(true),
@ -107,7 +121,8 @@ const component_options = {
preserveWhitespace: boolean(false), preserveWhitespace: boolean(false),
runes: boolean(undefined), /** @type {Validator<boolean | undefined | (() => boolean | undefined), () => boolean | undefined>} */
runes: parametric(() => /** @type {boolean | undefined} */ (undefined)),
hmr: boolean(false), hmr: boolean(false),
@ -318,6 +333,28 @@ function fun(fallback) {
}); });
} }
/**
* @template {(...args: any[]) => any} F
* @param {F} fallback
* @param {(value: unknown, keypath: string) => ReturnType<F>} [normalize]
* @returns {Validator}
*/
function parametric(fallback, normalize = (value) => /** @type {ReturnType<F>} */ (value)) {
return validator(fallback, (input, keypath) => {
if (typeof input === 'function') {
/** @type {(...args: Parameters<F>) => ReturnType<F>} */
const normalized = (...args) => normalize(input(...args), keypath);
return /** @type {F} */ (/** @type {unknown} */ (normalized));
}
/** @type {(...args: Parameters<F>) => ReturnType<F>} */
const normalized = (..._args) => normalize(input, keypath);
return /** @type {F} */ (/** @type {unknown} */ (normalized));
});
}
/** @param {string} msg */ /** @param {string} msg */
function throw_error(msg) { function throw_error(msg) {
e.options_invalid_value(null, msg); e.options_invalid_value(null, msg);

@ -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. * 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 * @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`. * 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. * - `'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. * - `'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. * 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. * 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)}`. * 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. * 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 * @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`. * 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. * 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 * @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`. * 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. * - `'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. * - `'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. * 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. * 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)}`. * 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. * 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 * @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`. * If `true`, exposes the Svelte major version in the browser by adding it to a `Set` stored in the global `window.__svelte.v`.
* *

Loading…
Cancel
Save