diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-warn.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-warn.js new file mode 100644 index 0000000000..32dfab32ab --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-warn.js @@ -0,0 +1,30 @@ +import { walk } from 'zimmerframe'; +import { warn } from '../../../warnings.js'; + +/** + * @param {import('#compiler').Css.StyleSheet} stylesheet + * @param {import('../../types.js').RawWarning[]} warnings + */ +export function warn_unused(stylesheet, warnings) { + walk(stylesheet, { warnings, stylesheet }, visitors); +} + +/** @type {import('zimmerframe').Visitors} */ +const visitors = { + ComplexSelector(node, context) { + if (!node.metadata.used) { + for (let i = context.path.length - 1; i >= 0; i--) { + const parent = context.path[i]; + if (parent.type === 'RelativeSelector' && parent.metadata.is_global) { + return; // no need to recurse; everything below is global, too + } + } + + const content = context.state.stylesheet.content; + const text = content.styles.substring(node.start - content.start, node.end - content.start); + warn(context.state.warnings, node, context.path, 'css-unused-selector', text); + } + + context.next(); + } +}; diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 887c961734..d9c6dfe64a 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -23,6 +23,7 @@ import { should_proxy_or_freeze } from '../3-transform/client/utils.js'; import { analyze_css } from './css/css-analyze.js'; import { prune } from './css/css-prune.js'; import { hash } from './utils.js'; +import { warn_unused } from './css/css-warn.js'; /** * @param {import('#compiler').Script | null} script @@ -548,6 +549,7 @@ export function analyze_component(root, source, options) { for (const element of analysis.elements) { prune(analysis.css.ast, element); } + warn_unused(analysis.css.ast, analysis.warnings); outer: for (const element of analysis.elements) { if (element.metadata.scoped) { diff --git a/packages/svelte/src/compiler/warnings.js b/packages/svelte/src/compiler/warnings.js index 7b863ef2b1..98dc07d7a0 100644 --- a/packages/svelte/src/compiler/warnings.js +++ b/packages/svelte/src/compiler/warnings.js @@ -7,7 +7,8 @@ import { /** @satisfies {Warnings} */ const css = { - 'unused-selector': () => 'Unused CSS selector' + /** @param {string} name */ + 'css-unused-selector': (name) => `Unused CSS selector "${name}"` }; /** @satisfies {Warnings} */ diff --git a/packages/svelte/tests/css/samples/attribute-selector-case-sensitive/_config.js b/packages/svelte/tests/css/samples/attribute-selector-case-sensitive/_config.js new file mode 100644 index 0000000000..4be32cf365 --- /dev/null +++ b/packages/svelte/tests/css/samples/attribute-selector-case-sensitive/_config.js @@ -0,0 +1,20 @@ +import { test } from '../../test'; + +export default test({ + warnings: [ + { + code: 'css-unused-selector', + end: { + character: 44, + column: 14, + line: 4 + }, + message: 'Unused CSS selector "p[type=\'B\' s]"', + start: { + character: 31, + column: 1, + line: 4 + } + } + ] +}); diff --git a/packages/svelte/tests/css/samples/descendant-selector-unmatched/_config.js b/packages/svelte/tests/css/samples/descendant-selector-unmatched/_config.js new file mode 100644 index 0000000000..509e5faec9 --- /dev/null +++ b/packages/svelte/tests/css/samples/descendant-selector-unmatched/_config.js @@ -0,0 +1,20 @@ +import { test } from '../../test'; + +export default test({ + warnings: [ + { + code: 'css-unused-selector', + end: { + character: 33, + column: 6, + line: 6 + }, + message: 'Unused CSS selector "x y z"', + start: { + character: 28, + column: 1, + line: 6 + } + } + ] +}); diff --git a/packages/svelte/tests/css/samples/general-siblings-combinator-rendertag-global/_config.js b/packages/svelte/tests/css/samples/general-siblings-combinator-rendertag-global/_config.js index 2143e5e575..2038788f67 100644 --- a/packages/svelte/tests/css/samples/general-siblings-combinator-rendertag-global/_config.js +++ b/packages/svelte/tests/css/samples/general-siblings-combinator-rendertag-global/_config.js @@ -2,12 +2,19 @@ import { test } from '../../test'; export default test({ warnings: [ - // TODO - // { - // code: 'css-unused-selector', - // message: 'Unused CSS selector ".a ~ .b"', - // start: { character: 111, column: 1, line: 10 }, - // end: { character: 118, column: 8, line: 10 } - // }, + { + code: 'css-unused-selector', + end: { + character: 479, + column: 19, + line: 22 + }, + message: 'Unused CSS selector ":global(.x) + .bar"', + start: { + character: 461, + column: 1, + line: 22 + } + } ] }); diff --git a/packages/svelte/tests/css/samples/general-siblings-combinator-slot-global/_config.js b/packages/svelte/tests/css/samples/general-siblings-combinator-slot-global/_config.js index 2143e5e575..2038788f67 100644 --- a/packages/svelte/tests/css/samples/general-siblings-combinator-slot-global/_config.js +++ b/packages/svelte/tests/css/samples/general-siblings-combinator-slot-global/_config.js @@ -2,12 +2,19 @@ import { test } from '../../test'; export default test({ warnings: [ - // TODO - // { - // code: 'css-unused-selector', - // message: 'Unused CSS selector ".a ~ .b"', - // start: { character: 111, column: 1, line: 10 }, - // end: { character: 118, column: 8, line: 10 } - // }, + { + code: 'css-unused-selector', + end: { + character: 479, + column: 19, + line: 22 + }, + message: 'Unused CSS selector ":global(.x) + .bar"', + start: { + character: 461, + column: 1, + line: 22 + } + } ] }); diff --git a/packages/svelte/tests/css/test.ts b/packages/svelte/tests/css/test.ts index 71b8e9644c..d17f7e9c6b 100644 --- a/packages/svelte/tests/css/test.ts +++ b/packages/svelte/tests/css/test.ts @@ -8,12 +8,17 @@ import { mount, unmount } from 'svelte'; import { suite, type BaseTest } from '../suite.js'; import type { CompileOptions, Warning } from '#compiler'; -// function normalize_warning(warning) { -// warning.frame = warning.frame.replace(/^\n/, '').replace(/^\t+/gm, '').replace(/\s+$/gm, ''); -// delete warning.filename; -// delete warning.toString; -// return warning; -// } +function normalize_warning(warning: Warning) { + delete warning.filename; + return warning; +} + +function load_warnings(path: string) { + if (!fs.existsSync(path)) { + return []; + } + return JSON.parse(fs.readFileSync(path, 'utf-8')).map(normalize_warning); +} interface CssTest extends BaseTest { compileOptions?: Partial; @@ -22,9 +27,6 @@ interface CssTest extends BaseTest { } const { test, run } = suite(async (config, cwd) => { - // TODO - // const expected_warnings = (config.warnings || []).map(normalize_warning); - await compile_directory(cwd, 'client', { cssHash: () => 'svelte-xyz', ...config.compileOptions }); await compile_directory(cwd, 'server', { cssHash: () => 'svelte-xyz', ...config.compileOptions }); @@ -33,11 +35,11 @@ const { test, run } = suite(async (config, cwd) => { assert.equal(dom_css, ssr_css); - // TODO reenable - // const dom_warnings = dom.warnings.map(normalize_warning); - // const ssr_warnings = ssr.warnings.map(normalize_warning); - // assert.deepEqual(dom_warnings, ssr_warnings); - // assert.deepEqual(dom_warnings.map(normalize_warning), expected_warnings); + const dom_warnings = load_warnings(`${cwd}/_output/client/input.svelte.warnings.json`); + const ssr_warnings = load_warnings(`${cwd}/_output/server/input.svelte.warnings.json`); + const expected_warnings = (config.warnings || []).map(normalize_warning); + assert.deepEqual(dom_warnings, ssr_warnings); + assert.deepEqual(dom_warnings.map(normalize_warning), expected_warnings); const expected = { html: try_read_file(`${cwd}/expected.html`), diff --git a/packages/svelte/tests/helpers.js b/packages/svelte/tests/helpers.js index 16c923391d..f640e519f9 100644 --- a/packages/svelte/tests/helpers.js +++ b/packages/svelte/tests/helpers.js @@ -71,7 +71,7 @@ export async function compile_directory( for (const file of glob('**', { cwd, filesOnly: true })) { if (file.startsWith('_')) continue; - let text = fs.readFileSync(`${cwd}/${file}`, 'utf-8'); + let text = fs.readFileSync(`${cwd}/${file}`, 'utf-8').replace(/\r\n/g, '\n'); let opts = { filename: path.join(cwd, file), ...compileOptions, @@ -138,6 +138,10 @@ export async function compile_directory( write(`${output_dir}/${file}.css.map`, JSON.stringify(compiled.css.map, null, '\t')); } } + + if (compiled.warnings.length > 0) { + write(`${output_dir}/${file}.warnings.json`, JSON.stringify(compiled.warnings, null, '\t')); + } } } }