fix: better `customRenderer` validation

svelte-custom-renderer
paoloricciuti 5 days ago
parent 9b97b7fbf7
commit 1e5dc328cd

@ -37,8 +37,8 @@ export function compile(source, options) {
// resolve the per-component custom renderer, taking `<svelte:options customRenderer={...} />` // resolve the per-component custom renderer, taking `<svelte:options customRenderer={...} />`
// into account. The normalized option is always a function returning `string | null | undefined` // into account. The normalized option is always a function returning `string | null | undefined`
// (see `validate-options.js`). A string opts in to a specific renderer module, `null`/`false` // (see `validate-options.js`). A string opts in to a specific renderer module, `null`
// opts out to plain DOM (while keeping the feature enabled) and `true`/absent inherits whatever // opts out to plain DOM (while keeping the feature enabled) and an absent option inherits whatever
// the global option resolves to. // the global option resolves to.
let custom_renderer_option = validated.experimental.customRenderer; let custom_renderer_option = validated.experimental.customRenderer;
@ -56,7 +56,7 @@ export function compile(source, options) {
if (typeof custom_renderer === 'string') { if (typeof custom_renderer === 'string') {
custom_renderer_option = () => custom_renderer; custom_renderer_option = () => custom_renderer;
} else if (custom_renderer === false || custom_renderer === null) { } else if (custom_renderer === null) {
custom_renderer_option = () => null; custom_renderer_option = () => null;
} }
} }

@ -37,7 +37,23 @@ export default function read_options(node) {
break; // eslint doesn't know this is unnecessary break; // eslint doesn't know this is unnecessary
} }
case 'customRenderer': { case 'customRenderer': {
component_options.customRenderer = get_static_value(attribute); const { value } = attribute;
const chunk = Array.isArray(value) ? value[0] : value;
if (chunk === true || !chunk || (Array.isArray(value) && value.length !== 1)) {
e.svelte_options_invalid_attribute_value(attribute, 'a string or null');
}
if (chunk.type === 'Text') {
component_options.customRenderer = chunk.data;
} else if (
chunk.expression?.type === 'Literal' &&
(typeof chunk.expression.value === 'string' || chunk.expression.value === null)
) {
component_options.customRenderer = chunk.expression.value;
} else {
e.svelte_options_invalid_attribute_value(attribute, 'a string or null');
}
break; break;
} }
case 'customElement': { case 'customElement': {

@ -34,12 +34,10 @@ export function RegularElement(node, context) {
context.state.analysis.elements.push(node); context.state.analysis.elements.push(node);
// Special case: Move the children of <textarea> into a value attribute if they are dynamic // Special case: Move the children of <textarea> into a value attribute if they are dynamic
if (node.name === 'textarea' && node.fragment.nodes.length > 0) { if (!custom_renderer && node.name === 'textarea' && node.fragment.nodes.length > 0) {
for (const attribute of node.attributes) { for (const attribute of node.attributes) {
if (attribute.type === 'Attribute' && attribute.name === 'value') { if (attribute.type === 'Attribute' && attribute.name === 'value') {
if (!custom_renderer) { e.textarea_invalid_content(node);
e.textarea_invalid_content(node);
}
} }
} }

@ -85,7 +85,7 @@ export namespace AST {
preserveWhitespace?: boolean; preserveWhitespace?: boolean;
namespace?: Namespace; namespace?: Namespace;
css?: 'injected'; css?: 'injected';
customRenderer?: string | boolean | null; customRenderer?: string | null;
customElement?: { customElement?: {
tag?: string; tag?: string;
shadow?: 'open' | 'none' | ObjectExpression | undefined; shadow?: 'open' | 'none' | ObjectExpression | undefined;

@ -69,7 +69,15 @@ const common_options = {
} }
if (typeof input === 'function') { if (typeof input === 'function') {
return input; return (/** @type {{ filename: string }} */ options) => {
const result = input(options);
if (result !== null && result !== undefined && typeof result !== 'string') {
throw_error(`${keypath} function must return a string, null or undefined`);
}
return result;
};
} }
throw_error(`${keypath} should be true, a string or a function, if specified`); throw_error(`${keypath} should be true, a string or a function, if specified`);

@ -0,0 +1,78 @@
import { assert, describe, it } from 'vitest';
import { compile } from 'svelte/compiler';
const enabled = { generate: 'client', experimental: { customRenderer: 'my-renderer' } } as const;
describe('customRenderer option parsing', () => {
const valid: Array<[string, string]> = [
['null', '<svelte:options customRenderer={null} />'],
['string', '<svelte:options customRenderer="my-renderer" />'],
['string literal', '<svelte:options customRenderer={"my-renderer"} />']
];
for (const [name, source] of valid) {
it(`accepts ${name}`, () => {
assert.doesNotThrow(() => compile(source, enabled));
});
}
const invalid: Array<[string, string]> = [
['true', '<svelte:options customRenderer={true} />'],
['false', '<svelte:options customRenderer={false} />'],
['bare', '<svelte:options customRenderer />'],
['identifier', '<svelte:options customRenderer={renderer} />'],
['call expression', '<svelte:options customRenderer={get()} />'],
['template literal', '<svelte:options customRenderer={`x`} />'],
['mixed text', '<svelte:options customRenderer="a{b}" />'],
['numeric literal', '<svelte:options customRenderer={42} />'],
['object literal', '<svelte:options customRenderer={{}} />'],
['undefined', '<svelte:options customRenderer={undefined} />']
];
for (const [name, source] of invalid) {
it(`rejects ${name}`, () => {
assert.throws(() => compile(source, enabled), /svelte_options_invalid_attribute_value/);
});
}
});
describe('customRenderer function resolver validation', () => {
const invalid: Array<[string, unknown]> = [
['number', () => 42],
['true', () => true],
['false', () => false],
['object', () => ({})]
];
for (const [name, resolver] of invalid) {
it(`rejects a resolver returning ${name}`, () => {
assert.throws(
() =>
compile('<div></div>', {
generate: 'client',
// @ts-expect-error intentionally invalid return value
experimental: { customRenderer: resolver }
}),
/customRenderer/
);
});
}
const valid: Array<[string, unknown]> = [
['string', () => 'my-renderer'],
['null', () => null],
['undefined', () => undefined]
];
for (const [name, resolver] of valid) {
it(`accepts a resolver returning ${name}`, () => {
assert.doesNotThrow(() =>
compile('<div></div>', {
generate: 'client',
// @ts-expect-error resolver typed loosely for the test table
experimental: { customRenderer: resolver }
})
);
});
}
});

@ -1297,7 +1297,7 @@ declare module 'svelte/compiler' {
preserveWhitespace?: boolean; preserveWhitespace?: boolean;
namespace?: Namespace; namespace?: Namespace;
css?: 'injected'; css?: 'injected';
customRenderer?: string | boolean | null; customRenderer?: string | null;
customElement?: { customElement?: {
tag?: string; tag?: string;
shadow?: 'open' | 'none' | ObjectExpression | undefined; shadow?: 'open' | 'none' | ObjectExpression | undefined;

Loading…
Cancel
Save