diff --git a/packages/svelte/src/compiler/css/Stylesheet.js b/packages/svelte/src/compiler/css/Stylesheet.js deleted file mode 100644 index 1337cf19e0..0000000000 --- a/packages/svelte/src/compiler/css/Stylesheet.js +++ /dev/null @@ -1,549 +0,0 @@ -import MagicString from 'magic-string'; -import { walk } from 'zimmerframe'; -import { ComplexSelector } from './Selector.js'; -import { hash } from './utils.js'; -import { create_attribute } from '../phases/nodes.js'; // TODO move this -import { merge_with_preprocessor_map } from '../utils/mapped_code.js'; - -const regex_css_browser_prefix = /^-((webkit)|(moz)|(o)|(ms))-/; -const regex_name_boundary = /^[\s,;}]$/; -/** - * @param {string} name - * @returns {string} - */ -function remove_css_prefix(name) { - return name.replace(regex_css_browser_prefix, ''); -} - -/** @param {import('#compiler').Css.Atrule} node */ -const is_keyframes_node = (node) => remove_css_prefix(node.name) === 'keyframes'; - -/** - * - * @param {import('#compiler').Css.Rule} node - * @param {MagicString} code - */ -function escape_comment_close(node, code) { - let escaped = false; - let in_comment = false; - - for (let i = node.start; i < node.end; i++) { - if (escaped) { - escaped = false; - } else { - const char = code.original[i]; - if (in_comment) { - if (char === '*' && code.original[i + 1] === '/') { - code.prependRight(++i, '\\'); - in_comment = false; - } - } else if (char === '\\') { - escaped = true; - } else if (char === '/' && code.original[++i] === '*') { - in_comment = true; - } - } - } -} - -class Rule { - /** @type {ComplexSelector[]} */ - selectors; - - /** @type {import('#compiler').Css.Rule} */ - node; - - /** @type {Stylesheet | Atrule} */ - parent; - - /** @type {Declaration[]} */ - declarations; - - /** - * @param {import('#compiler').Css.Rule} node - * @param {any} stylesheet - * @param {Stylesheet | Atrule} parent - */ - constructor(node, stylesheet, parent) { - this.node = node; - this.parent = parent; - this.selectors = node.prelude.children.map((node) => new ComplexSelector(node, stylesheet)); - - this.declarations = /** @type {import('#compiler').Css.Declaration[]} */ ( - node.block.children - ).map((node) => new Declaration(node)); - } - - /** @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node */ - apply(node) { - this.selectors.forEach((selector) => selector.apply(node)); // TODO move the logic in here? - } - - /** @returns {boolean} */ - is_empty() { - if (this.declarations.length > 0) return false; - - return true; - } - - /** @returns {boolean} */ - is_used() { - if (this.parent instanceof Atrule && is_keyframes_node(this.parent.node)) { - return true; - } - - for (const selector of this.selectors) { - if (selector.used) return true; - } - - return false; - } - - /** - * @param {import('magic-string').default} code - * @param {string} id - * @param {Map} keyframes - */ - transform(code, id, keyframes) { - if (this.parent instanceof Atrule && is_keyframes_node(this.parent.node)) { - return; - } - - const modifier = `.${id}`; - this.selectors.forEach((selector) => selector.transform(code, modifier)); - this.declarations.forEach((declaration) => declaration.transform(code, keyframes)); - } - - /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ - validate(analysis) { - this.selectors.forEach((selector) => { - selector.validate(analysis); - }); - } - - /** @param {(selector: ComplexSelector) => void} handler */ - warn_on_unused_selector(handler) { - this.selectors.forEach((selector) => { - if (!selector.used) handler(selector); - }); - } - - /** - * @param {MagicString} code - * @param {boolean} dev - */ - prune(code, dev) { - if (this.parent instanceof Atrule && is_keyframes_node(this.parent.node)) { - return; - } - - // keep empty rules in dev, because it's convenient to - // see them in devtools - if (!dev && this.is_empty()) { - code.prependRight(this.node.start, '/* (empty) '); - code.appendLeft(this.node.end, '*/'); - escape_comment_close(this.node, code); - return; - } - - const used = this.selectors.filter((s) => s.used); - - if (used.length === 0) { - code.prependRight(this.node.start, '/* (unused) '); - code.appendLeft(this.node.end, '*/'); - escape_comment_close(this.node, code); - - return; - } - - if (used.length < this.selectors.length) { - let pruning = false; - let last = this.selectors[0].node.start; - - for (let i = 0; i < this.selectors.length; i += 1) { - const selector = this.selectors[i]; - - if (selector.used === pruning) { - if (pruning) { - let i = selector.node.start; - while (code.original[i] !== ',') i--; - - code.overwrite(i, i + 1, '*/'); - } else { - if (i === 0) { - code.prependRight(selector.node.start, '/* (unused) '); - } else { - code.overwrite(last, selector.node.start, ' /* (unused) '); - } - } - - pruning = !pruning; - } - - last = selector.node.end; - } - - if (pruning) { - code.appendLeft(last, '*/'); - } - } - } -} - -class Declaration { - /** @type {import('#compiler').Css.Declaration} */ - node; - - /** @param {import('#compiler').Css.Declaration} node */ - constructor(node) { - this.node = node; - } - - /** - * @param {import('magic-string').default} code - * @param {Map} keyframes - */ - transform(code, keyframes) { - const property = this.node.property && remove_css_prefix(this.node.property.toLowerCase()); - if (property === 'animation' || property === 'animation-name') { - let index = this.node.start + this.node.property.length + 1; - let name = ''; - - while (index < code.original.length) { - const character = code.original[index]; - - if (regex_name_boundary.test(character)) { - const keyframe = keyframes.get(name); - - if (keyframe) { - code.update(index - name.length, index, keyframe); - } - - if (character === ';' || character === '}') { - break; - } - - name = ''; - } else { - name += character; - } - - index++; - } - } - } -} - -class Atrule { - /** @type {import('#compiler').Css.Atrule} */ - node; - - /** @type {Array} */ - children; - - /** @type {Declaration[]} */ - declarations; - - /** @param {import('#compiler').Css.Atrule} node */ - constructor(node) { - this.node = node; - this.children = []; - this.declarations = []; - } - - /** @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node */ - apply(node) { - if ( - this.node.name === 'container' || - this.node.name === 'media' || - this.node.name === 'supports' || - this.node.name === 'layer' - ) { - this.children.forEach((child) => { - child.apply(node); - }); - } else if (is_keyframes_node(this.node)) { - /** @type {Rule[]} */ (this.children).forEach((rule) => { - rule.selectors.forEach((selector) => { - selector.used = true; - }); - }); - } - } - - is_empty() { - return false; // TODO - } - - is_used() { - return true; // TODO - } - - /** - * @param {import('magic-string').default} code - * @param {string} id - * @param {Map} keyframes - */ - transform(code, id, keyframes) { - if (is_keyframes_node(this.node)) { - let start = this.node.start + this.node.name.length + 1; - while (code.original[start] === ' ') start += 1; - let end = start; - while (code.original[end] !== '{' && code.original[end] !== ' ') end += 1; - - if (this.node.prelude.startsWith('-global-')) { - code.remove(start, start + 8); - /** @type {Rule[]} */ (this.children).forEach((rule) => { - rule.selectors.forEach((selector) => { - selector.used = true; - }); - }); - } else { - const keyframe = /** @type {string} */ (keyframes.get(this.node.prelude)); - code.update(start, end, keyframe); - } - } - this.children.forEach((child) => { - child.transform(code, id, keyframes); - }); - } - - /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ - validate(analysis) { - this.children.forEach((child) => { - child.validate(analysis); - }); - } - - /** @param {(selector: ComplexSelector) => void} handler */ - warn_on_unused_selector(handler) { - if (this.node.name !== 'media') return; - this.children.forEach((child) => { - child.warn_on_unused_selector(handler); - }); - } - - /** - * @param {MagicString} code - * @param {boolean} dev - */ - prune(code, dev) { - // TODO prune children - } -} - -export class Stylesheet { - /** @type {import('#compiler').Style | null} */ - ast; - - /** @type {string} Path of Svelte file the CSS is in */ - filename; - - /** @type {boolean} */ - has_styles; - - /** @type {string} */ - id; - - /** @type {Array} */ - children = []; - - /** @type {Map} */ - keyframes = new Map(); - - /** @type {Set} */ - nodes_with_css_class = new Set(); - - /** - * @param {{ - * ast: import('#compiler').Style | null; - * filename: string; - * component_name: string; - * get_css_hash: import('#compiler').CssHashGetter; - * }} params - */ - constructor({ ast, component_name, filename, get_css_hash }) { - this.ast = ast; - this.filename = filename; - - if (!ast || ast.children.length === 0) { - this.has_styles = false; - this.id = ''; - return; - } - - this.id = get_css_hash({ - filename, - name: component_name, - css: ast.content.styles, - hash - }); - this.has_styles = true; - - const state = { - /** @type {Stylesheet | Atrule | Rule} */ - current: this - }; - - walk(/** @type {import('#compiler').Css.Node} */ (ast), state, { - Atrule: (node, context) => { - const atrule = new Atrule(node); - - if (is_keyframes_node(node)) { - if (!node.prelude.startsWith('-global-')) { - this.keyframes.set(node.prelude, `${this.id}-${node.prelude}`); - } - } - - // @ts-expect-error temporary, until nesting is implemented - context.state.current.children.push(atrule); - context.next({ current: atrule }); - }, - Declaration: (node, context) => { - const declaration = new Declaration(node); - /** @type {Atrule | Rule} */ (context.state.current).declarations.push(declaration); - }, - Rule: (node, context) => { - // @ts-expect-error temporary, until nesting is implemented - const rule = new Rule(node, this, context.state.current); - - // @ts-expect-error temporary, until nesting is implemented - context.state.current.children.push(rule); - context.next({ current: rule }); - } - }); - } - - /** @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node */ - apply(node) { - if (!this.has_styles) return; - for (let i = 0; i < this.children.length; i += 1) { - const child = this.children[i]; - child.apply(node); - } - } - /** @param {boolean} is_dom_mode */ - reify(is_dom_mode) { - nodes: for (const node of this.nodes_with_css_class) { - // Dynamic elements in dom mode always use spread for attributes and therefore shouldn't have a class attribute added to them - // TODO this happens during the analysis phase, which shouldn't know anything about client vs server - if (node.type === 'SvelteElement' && is_dom_mode) continue; - - /** @type {import('#compiler').Attribute | undefined} */ - let class_attribute = undefined; - - for (const attribute of node.attributes) { - if (attribute.type === 'SpreadAttribute') { - // The spread method appends the hash to the end of the class attribute on its own - continue nodes; - } - - if (attribute.type !== 'Attribute') continue; - if (attribute.name.toLowerCase() !== 'class') continue; - - class_attribute = attribute; - } - - if (class_attribute && class_attribute.value !== true) { - const chunks = class_attribute.value; - - if (chunks.length === 1 && chunks[0].type === 'Text') { - chunks[0].data += ` ${this.id}`; - } else { - chunks.push({ - type: 'Text', - data: ` ${this.id}`, - raw: ` ${this.id}`, - start: -1, - end: -1, - parent: null - }); - } - } else { - node.attributes.push( - create_attribute('class', -1, -1, [ - { type: 'Text', data: this.id, raw: this.id, parent: null, start: -1, end: -1 } - ]) - ); - } - } - } - - /** - * @param {string} source - * @param {import('#compiler').ValidatedCompileOptions} options - */ - render(source, options) { - // TODO neaten this up - if (!this.ast) throw new Error('Unexpected error'); - - const code = new MagicString(source); - - // Generate source mappings for the style sheet nodes we have. - // Note that resolution is a bit more coarse than in Svelte 4 because - // our own CSS AST is not as detailed with regards to the node values. - walk(/** @type {import('#compiler').Css.Node} */ (this.ast), null, { - _: (node, { next }) => { - code.addSourcemapLocation(node.start); - code.addSourcemapLocation(node.end); - next(); - } - }); - - for (const child of this.children) { - child.transform(code, this.id, this.keyframes); - } - - code.remove(0, this.ast.content.start); - - for (const child of this.children) { - child.prune(code, options.dev); - } - - code.remove(/** @type {number} */ (this.ast.content.end), source.length); - - const css = { - code: code.toString(), - map: code.generateMap({ - // include source content; makes it easier/more robust looking up the source map code - includeContent: true, - // generateMap takes care of calculating source relative to file - source: this.filename, - file: options.cssOutputFilename || this.filename - }) - }; - merge_with_preprocessor_map(css, options, css.map.sources[0]); - if (options.dev && options.css === 'injected' && css.code) { - css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`; - } - - return css; - } - - /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ - validate(analysis) { - this.children.forEach((child) => { - child.validate(analysis); - }); - } - - /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ - warn_on_unused_selectors(analysis) { - // const ignores = !this.ast - // ? [] - // : extract_ignores_above_position(this.ast.css.start, this.ast.html.children); - // analysis.push_ignores(ignores); - // this.children.forEach((child) => { - // child.warn_on_unused_selector((selector) => { - // analysis.warn(selector.node, { - // code: 'css-unused-selector', - // message: `Unused CSS selector "${this.source.slice( - // selector.node.start, - // selector.node.end - // )}"` - // }); - // }); - // }); - // analysis.pop_ignores(); - } -} diff --git a/packages/svelte/src/compiler/legacy.js b/packages/svelte/src/compiler/legacy.js index 363742e7b1..0593c77acd 100644 --- a/packages/svelte/src/compiler/legacy.js +++ b/packages/svelte/src/compiler/legacy.js @@ -102,14 +102,7 @@ export function convert(source, ast) { }, instance, module, - css: ast.css - ? walk(ast.css, null, { - _(node) { - // @ts-ignore - delete node.parent; - } - }) - : undefined + css: ast.css ? visit(ast.css) : undefined }; }, AnimateDirective(node) { @@ -192,6 +185,24 @@ export function convert(source, ast) { ClassDirective(node) { return { ...node, type: 'Class' }; }, + ComplexSelector(node, { visit }) { + const children = []; + + for (const child of node.children) { + if (child.combinator) { + children.push(child.combinator); + } + + children.push(...child.selectors); + } + + return { + type: 'Selector', + start: node.start, + end: node.end, + children + }; + }, Component(node, { visit }) { return { type: 'InlineComponent', @@ -389,6 +400,13 @@ export function convert(source, ast) { SpreadAttribute(node) { return { ...node, type: 'Spread' }; }, + StyleSheet(node, context) { + return { + ...node, + ...context.next(), + type: 'Style' + }; + }, SvelteBody(node, { visit }) { return { type: 'Body', diff --git a/packages/svelte/src/compiler/phases/1-parse/read/style.js b/packages/svelte/src/compiler/phases/1-parse/read/style.js index 8f30b55162..c53f4a123a 100644 --- a/packages/svelte/src/compiler/phases/1-parse/read/style.js +++ b/packages/svelte/src/compiler/phases/1-parse/read/style.js @@ -18,7 +18,7 @@ const REGEX_HTML_COMMENT_CLOSE = /-->/; * @param {import('../index.js').Parser} parser * @param {number} start * @param {Array} attributes - * @returns {import('#compiler').Style} + * @returns {import('#compiler').Css.StyleSheet} */ export default function read_style(parser, start, attributes) { const content_start = parser.index; @@ -28,7 +28,7 @@ export default function read_style(parser, start, attributes) { parser.read(/^<\/style\s*>/); return { - type: 'Style', + type: 'StyleSheet', start, end: parser.index, attributes, @@ -37,8 +37,7 @@ export default function read_style(parser, start, attributes) { start: content_start, end: content_end, styles: parser.template.slice(content_start, content_end) - }, - parent: null + } }; } @@ -187,42 +186,66 @@ function read_selector_list(parser, inside_pseudo_class = false) { function read_selector(parser, inside_pseudo_class = false) { const list_start = parser.index; - /** @type {Array} */ + /** @type {import('#compiler').Css.RelativeSelector[]} */ const children = []; + /** + * @param {import('#compiler').Css.Combinator | null} combinator + * @param {number} start + * @returns {import('#compiler').Css.RelativeSelector} + */ + function create_selector(combinator, start) { + return { + type: 'RelativeSelector', + combinator, + selectors: [], + start, + end: -1, + metadata: { + is_global: false, + is_host: false, + is_root: false, + scoped: false + } + }; + } + + /** @type {import('#compiler').Css.RelativeSelector} */ + let relative_selector = create_selector(null, parser.index); + while (parser.index < parser.template.length) { - const start = parser.index; + let start = parser.index; if (parser.eat('*')) { let name = '*'; - if (parser.match('|')) { + + if (parser.eat('|')) { // * is the namespace (which we ignore) - parser.index++; name = read_identifier(parser); } - children.push({ + relative_selector.selectors.push({ type: 'TypeSelector', name, start, end: parser.index }); } else if (parser.eat('#')) { - children.push({ + relative_selector.selectors.push({ type: 'IdSelector', name: read_identifier(parser), start, end: parser.index }); } else if (parser.eat('.')) { - children.push({ + relative_selector.selectors.push({ type: 'ClassSelector', name: read_identifier(parser), start, end: parser.index }); } else if (parser.eat('::')) { - children.push({ + relative_selector.selectors.push({ type: 'PseudoElementSelector', name: read_identifier(parser), start, @@ -247,7 +270,7 @@ function read_selector(parser, inside_pseudo_class = false) { error(parser.index, 'invalid-css-global-selector'); } - children.push({ + relative_selector.selectors.push({ type: 'PseudoClassSelector', name, args, @@ -276,7 +299,7 @@ function read_selector(parser, inside_pseudo_class = false) { parser.allow_whitespace(); parser.eat(']', true); - children.push({ + relative_selector.selectors.push({ type: 'AttributeSelector', start, end: parser.index, @@ -288,37 +311,28 @@ function read_selector(parser, inside_pseudo_class = false) { } else if (inside_pseudo_class && parser.match_regex(REGEX_NTH_OF)) { // nth of matcher must come before combinator matcher to prevent collision else the '+' in '+2n-1' would be parsed as a combinator - children.push({ + relative_selector.selectors.push({ type: 'Nth', value: /**@type {string} */ (parser.read(REGEX_NTH_OF)), start, end: parser.index }); - } else if (parser.match_regex(REGEX_COMBINATOR_WHITESPACE)) { - parser.allow_whitespace(); - const start = parser.index; - children.push({ - type: 'Combinator', - name: /** @type {string} */ (parser.read(REGEX_COMBINATOR)), - start, - end: parser.index - }); - parser.allow_whitespace(); } else if (parser.match_regex(REGEX_PERCENTAGE)) { - children.push({ + relative_selector.selectors.push({ type: 'Percentage', value: /** @type {string} */ (parser.read(REGEX_PERCENTAGE)), start, end: parser.index }); - } else { + } else if (!parser.match_regex(REGEX_COMBINATOR)) { let name = read_identifier(parser); - if (parser.match('|')) { + + if (parser.eat('|')) { // we ignore the namespace when trying to find matching element classes - parser.index++; name = read_identifier(parser); } - children.push({ + + relative_selector.selectors.push({ type: 'TypeSelector', name, start, @@ -330,29 +344,85 @@ function read_selector(parser, inside_pseudo_class = false) { parser.allow_whitespace(); if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) { + // rewind, so we know whether to continue building the selector list parser.index = index; + relative_selector.end = index; + children.push(relative_selector); + return { - type: 'Selector', + type: 'ComplexSelector', start: list_start, end: index, - children + children, + metadata: { + used: false + } }; } - if (parser.index !== index && !parser.match_regex(REGEX_COMBINATOR)) { - children.push({ - type: 'Combinator', - name: ' ', - start: index, - end: parser.index - }); + parser.index = index; + const combinator = read_combinator(parser); + + if (combinator) { + if (relative_selector.selectors.length === 0) { + if (!inside_pseudo_class) { + error(start, 'invalid-css-selector'); + } + } else { + relative_selector.end = index; + children.push(relative_selector); + } + + // ...and start a new one + relative_selector = create_selector(combinator, combinator.start); + + parser.allow_whitespace(); + + if (parser.match(',') || (inside_pseudo_class ? parser.match(')') : parser.match('{'))) { + error(parser.index, 'invalid-css-selector'); + } } } error(parser.template.length, 'unexpected-eof'); } +/** + * @param {import('../index.js').Parser} parser + * @returns {import('#compiler').Css.Combinator | null} + */ +function read_combinator(parser) { + const start = parser.index; + parser.allow_whitespace(); + + const index = parser.index; + const name = parser.read(REGEX_COMBINATOR); + + if (name) { + const end = parser.index; + parser.allow_whitespace(); + + return { + type: 'Combinator', + name, + start: index, + end + }; + } + + if (parser.index !== start) { + return { + type: 'Combinator', + name: ' ', + start, + end: parser.index + }; + } + + return null; +} + /** * @param {import('../index.js').Parser} parser * @returns {import('#compiler').Css.Block} diff --git a/packages/svelte/src/compiler/phases/2-analyze/css/css-analyze.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-analyze.js new file mode 100644 index 0000000000..a647cca810 --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-analyze.js @@ -0,0 +1,124 @@ +import { error } from '../../../errors.js'; +import { is_keyframes_node } from '../../css.js'; +import { merge } from '../../visitors.js'; + +/** + * @typedef {import('zimmerframe').Visitors< + * import('#compiler').Css.Node, + * NonNullable + * >} Visitors + */ + +/** @param {import('#compiler').Css.RelativeSelector} relative_selector */ +function is_global(relative_selector) { + const first = relative_selector.selectors[0]; + + return ( + first.type === 'PseudoClassSelector' && + first.name === 'global' && + relative_selector.selectors.every( + (selector) => + selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector' + ) + ); +} + +/** @type {Visitors} */ +const analysis = { + Atrule(node, context) { + if (is_keyframes_node(node)) { + if (!node.prelude.startsWith('-global-')) { + context.state.keyframes.push(node.prelude); + } + } + }, + ComplexSelector(node, context) { + context.next(); // analyse relevant selectors first + + node.metadata.used = node.children.every( + ({ metadata }) => metadata.is_global || metadata.is_host || metadata.is_root + ); + }, + RelativeSelector(node, context) { + node.metadata.is_global = + node.selectors.length >= 1 && + node.selectors[0].type === 'PseudoClassSelector' && + node.selectors[0].name === 'global' && + node.selectors.every( + (selector) => + selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector' + ); + + if (node.selectors.length === 1) { + const first = node.selectors[0]; + node.metadata.is_host = first.type === 'PseudoClassSelector' && first.name === 'host'; + } + + node.metadata.is_root = !!node.selectors.find( + (child) => child.type === 'PseudoClassSelector' && child.name === 'root' + ); + + context.next(); + } +}; + +/** @type {Visitors} */ +const validation = { + ComplexSelector(node, context) { + // ensure `:global(...)` is not used in the middle of a selector + { + const a = node.children.findIndex((child) => !is_global(child)); + const b = node.children.findLastIndex((child) => !is_global(child)); + + if (a !== b) { + for (let i = a; i <= b; i += 1) { + if (is_global(node.children[i])) { + error(node.children[i].selectors[0], 'invalid-css-global-placement'); + } + } + } + } + + // ensure `:global(...)`contains a single selector + // (standalone :global() with multiple selectors is OK) + if (node.children.length > 1 || node.children[0].selectors.length > 1) { + for (const relative_selector of node.children) { + for (const selector of relative_selector.selectors) { + if ( + selector.type === 'PseudoClassSelector' && + selector.name === 'global' && + selector.args !== null && + selector.args.children.length > 1 + ) { + error(selector, 'invalid-css-global-selector'); + } + } + } + } + + // ensure `:global(...)` is not part of a larger compound selector + for (const relative_selector of node.children) { + for (let i = 0; i < relative_selector.selectors.length; i++) { + const selector = relative_selector.selectors[i]; + + if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { + const child = selector.args?.children[0].children[0]; + if ( + child?.selectors[0].type === 'TypeSelector' && + !/[.:#]/.test(child.selectors[0].name[0]) && + (i !== 0 || + relative_selector.selectors + .slice(1) + .some( + (s) => s.type !== 'PseudoElementSelector' && s.type !== 'PseudoClassSelector' + )) + ) { + error(selector, 'invalid-css-global-selector-list'); + } + } + } + } + } +}; + +export const css_visitors = merge(analysis, validation); diff --git a/packages/svelte/src/compiler/css/Selector.js b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js similarity index 62% rename from packages/svelte/src/compiler/css/Selector.js rename to packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js index dc5360c893..57ff05449b 100644 --- a/packages/svelte/src/compiler/css/Selector.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/css-prune.js @@ -1,7 +1,14 @@ +import { walk } from 'zimmerframe'; import { get_possible_values } from './utils.js'; -import { regex_starts_with_whitespace, regex_ends_with_whitespace } from '../phases/patterns.js'; -import { error } from '../errors.js'; -import { Stylesheet } from './Stylesheet.js'; +import { regex_ends_with_whitespace, regex_starts_with_whitespace } from '../../patterns.js'; + +/** + * @typedef {{ + * stylesheet: import('#compiler').Css.StyleSheet; + * element: import('#compiler').RegularElement | import('#compiler').SvelteElement; + * }} State + */ +/** @typedef {typeof NodeExist[keyof typeof NodeExist]} NodeExistsValue */ const NO_MATCH = 'NO_MATCH'; const POSSIBLE_MATCH = 'POSSIBLE_MATCH'; @@ -12,216 +19,59 @@ const NodeExist = /** @type {const} */ ({ Definitely: 1 }); -/** @typedef {typeof NodeExist[keyof typeof NodeExist]} NodeExistsValue */ - const whitelist_attribute_selector = new Map([ - ['details', new Set(['open'])], - ['dialog', new Set(['open'])] + ['details', ['open']], + ['dialog', ['open']] ]); -export class ComplexSelector { - /** @type {import('#compiler').Css.ComplexSelector} */ - node; - - /** @type {import('./Stylesheet.js').Stylesheet} */ - stylesheet; - - /** @type {RelativeSelector[]} */ - relative_selectors; - - /** - * The `relative_selectors`, minus any trailing global selectors - * (which includes `:root` and `:host`) since we ignore these - * when determining if a selector is used. - * @type {RelativeSelector[]} - */ - local_relative_selectors; - - used = false; - - /** - * @param {import('#compiler').Css.ComplexSelector} node - * @param {import('./Stylesheet.js').Stylesheet} stylesheet - */ - constructor(node, stylesheet) { - this.node = node; - this.stylesheet = stylesheet; - - this.relative_selectors = group_selectors(node); - - // take trailing :global(...) selectors out of consideration - const i = this.relative_selectors.findLastIndex((s) => !s.can_ignore()); - this.local_relative_selectors = this.relative_selectors.slice(0, i + 1); - - // if we have a `:root {...}` or `:global(...) {...}` selector, we need to mark - // this selector as `used` even if the component doesn't contain any nodes - this.used = this.local_relative_selectors.length === 0; - } - - /** @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node */ - apply(node) { - if (apply_selector(this.local_relative_selectors.slice(), node, this.stylesheet)) { - this.used = true; - } - } - - /** - * @param {import('magic-string').default} code - * @param {string} modifier - */ - transform(code, modifier) { - /** @param {import('#compiler').Css.SimpleSelector} selector */ - function remove_global_pseudo_class(selector) { - code - .remove(selector.start, selector.start + ':global('.length) - .remove(selector.end - 1, selector.end); - } - - /** - * @param {RelativeSelector} relative_selector - * @param {string} modifier - */ - function encapsulate_block(relative_selector, modifier) { - for (const selector of relative_selector.selectors) { - if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { - remove_global_pseudo_class(selector); - } - } - - let i = relative_selector.selectors.length; - while (i--) { - const selector = relative_selector.selectors[i]; - - if (selector.type === 'PseudoElementSelector' || selector.type === 'PseudoClassSelector') { - if (selector.name !== 'root' && selector.name !== 'host') { - if (i === 0) code.prependRight(selector.start, modifier); - } - continue; - } - - if (selector.type === 'TypeSelector' && selector.name === '*') { - code.update(selector.start, selector.end, modifier); - } else { - code.appendLeft(selector.end, modifier); - } +/** + * + * @param {import('#compiler').Css.StyleSheet} stylesheet + * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element + */ +export function prune(stylesheet, element) { + /** @type {State} */ + const state = { stylesheet, element }; - break; - } - } + walk(stylesheet, state, visitors); +} - let first = true; - for (const relative_selector of this.relative_selectors) { - if (relative_selector.is_global) { - remove_global_pseudo_class(relative_selector.selectors[0]); - } +/** @type {import('zimmerframe').Visitors} */ +const visitors = { + ComplexSelector(node, context) { + context.next(); - if (relative_selector.should_encapsulate) { - // for the first occurrence, we use a classname selector, so that every - // encapsulated selector gets a +0-1-0 specificity bump. thereafter, - // we use a `:where` selector, which does not affect specificity - encapsulate_block(relative_selector, first ? modifier : `:where(${modifier})`); - first = false; - } - } - } + const i = node.children.findLastIndex((child) => { + return !child.metadata.is_global && !child.metadata.is_host && !child.metadata.is_root; + }); - /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ - validate(analysis) { - this.validate_global_placement(); - this.validate_global_with_multiple_selectors(); - this.validate_global_compound_selector(); - this.validate_invalid_combinator_without_selector(analysis); - } + const relative_selectors = node.children.slice(0, i + 1); - validate_global_placement() { - let start = 0; - let end = this.relative_selectors.length; - for (; start < end; start += 1) { - if (!this.relative_selectors[start].is_global) break; - } - for (; end > start; end -= 1) { - if (!this.relative_selectors[end - 1].is_global) break; - } - for (let i = start; i < end; i += 1) { - if (this.relative_selectors[i].is_global) { - error(this.relative_selectors[i].selectors[0], 'invalid-css-global-placement'); - } + if (apply_selector(relative_selectors, context.state.element, context.state.stylesheet)) { + node.metadata.used = true; } + }, + RelativeSelector(node, context) { + // for now, don't visit children (i.e. inside `:foo(...)`) + // this will likely change when we implement `:is(...)` etc } - - validate_global_with_multiple_selectors() { - if (this.relative_selectors.length === 1 && this.relative_selectors[0].selectors.length === 1) { - // standalone :global() with multiple selectors is OK - return; - } - for (const relative_selector of this.relative_selectors) { - for (const selector of relative_selector.selectors) { - if ( - selector.type === 'PseudoClassSelector' && - selector.name === 'global' && - selector.args !== null && - selector.args.children.length > 1 - ) { - error(selector, 'invalid-css-global-selector'); - } - } - } - } - - /** @param {import('../phases/types.js').ComponentAnalysis} analysis */ - validate_invalid_combinator_without_selector(analysis) { - for (let i = 0; i < this.relative_selectors.length; i++) { - const relative_selector = this.relative_selectors[i]; - if (relative_selector.selectors.length === 0) { - error(this.node, 'invalid-css-selector'); - } - } - } - - validate_global_compound_selector() { - for (const relative_selector of this.relative_selectors) { - if (relative_selector.selectors.length === 1) continue; - - for (let i = 0; i < relative_selector.selectors.length; i++) { - const selector = relative_selector.selectors[i]; - - if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { - const child = selector.args?.children[0].children[0]; - if ( - child?.type === 'TypeSelector' && - !/[.:#]/.test(child.name[0]) && - (i !== 0 || - relative_selector.selectors - .slice(1) - .some( - (s) => s.type !== 'PseudoElementSelector' && s.type !== 'PseudoClassSelector' - )) - ) { - error(selector, 'invalid-css-global-selector-list'); - } - } - } - } - } -} +}; /** - * @param {RelativeSelector[]} relative_selectors - * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement | null} node - * @param {Stylesheet} stylesheet + * @param {import('#compiler').Css.RelativeSelector[]} relative_selectors + * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement | null} element + * @param {import('#compiler').Css.StyleSheet} stylesheet * @returns {boolean} */ -function apply_selector(relative_selectors, node, stylesheet) { +function apply_selector(relative_selectors, element, stylesheet) { + if (!element) { + return relative_selectors.every(({ metadata }) => metadata.is_global || metadata.is_host); + } + const relative_selector = relative_selectors.pop(); if (!relative_selector) return false; - if (!node) { - return ( - (relative_selector.is_global && - relative_selectors.every((relative_selector) => relative_selector.is_global)) || - (relative_selector.is_host && relative_selectors.length === 0) - ); - } - const applies = block_might_apply_to_node(relative_selector, node); + + const applies = relative_selector_might_apply_to_node(relative_selector, element); if (applies === NO_MATCH) { return false; @@ -230,17 +80,17 @@ function apply_selector(relative_selectors, node, stylesheet) { /** * Mark both the compound selector and the node it selects as encapsulated, * for transformation in a later step - * @param {RelativeSelector} relative_selector - * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node + * @param {import('#compiler').Css.RelativeSelector} relative_selector + * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} element */ - function mark(relative_selector, node) { - relative_selector.should_encapsulate = true; - stylesheet.nodes_with_css_class.add(node); + function mark(relative_selector, element) { + relative_selector.metadata.scoped = true; + element.metadata.scoped = true; return true; } if (applies === UNKNOWN_SELECTOR) { - return mark(relative_selector, node); + return mark(relative_selector, element); } if (relative_selector.combinator) { @@ -248,87 +98,99 @@ function apply_selector(relative_selectors, node, stylesheet) { relative_selector.combinator.type === 'Combinator' && relative_selector.combinator.name === ' ' ) { - for (const ancestor_block of relative_selectors) { - if (ancestor_block.is_global) { + for (const ancestor_selector of relative_selectors) { + if (ancestor_selector.metadata.is_global) { continue; } - if (ancestor_block.is_host) { - return mark(relative_selector, node); + + if (ancestor_selector.metadata.is_host) { + return mark(relative_selector, element); } + /** @type {import('#compiler').RegularElement | import('#compiler').SvelteElement | null} */ - let parent = node; + let parent = element; let matched = false; while ((parent = get_element_parent(parent))) { - if (block_might_apply_to_node(ancestor_block, parent) !== NO_MATCH) { - mark(ancestor_block, parent); + if (relative_selector_might_apply_to_node(ancestor_selector, parent) !== NO_MATCH) { + mark(ancestor_selector, parent); matched = true; } } + if (matched) { - return mark(relative_selector, node); + return mark(relative_selector, element); } } - if (relative_selectors.every((relative_selector) => relative_selector.is_global)) { - return mark(relative_selector, node); + + if (relative_selectors.every((relative_selector) => relative_selector.metadata.is_global)) { + return mark(relative_selector, element); } + return false; - } else if (relative_selector.combinator.name === '>') { + } + + if (relative_selector.combinator.name === '>') { const has_global_parent = relative_selectors.every( - (relative_selector) => relative_selector.is_global + (relative_selector) => relative_selector.metadata.is_global ); + if ( has_global_parent || - apply_selector(relative_selectors, get_element_parent(node), stylesheet) + apply_selector(relative_selectors, get_element_parent(element), stylesheet) ) { - return mark(relative_selector, node); + return mark(relative_selector, element); } + return false; - } else if ( - relative_selector.combinator.name === '+' || - relative_selector.combinator.name === '~' - ) { + } + + if (relative_selector.combinator.name === '+' || relative_selector.combinator.name === '~') { const siblings = get_possible_element_siblings( - node, + element, relative_selector.combinator.name === '+' ); + let has_match = false; // NOTE: if we have :global(), we couldn't figure out what is selected within `:global` due to the // css-tree limitation that does not parse the inner selector of :global // so unless we are sure there will be no sibling to match, we will consider it as matched const has_global = relative_selectors.some( - (relative_selector) => relative_selector.is_global + (relative_selector) => relative_selector.metadata.is_global ); + if (has_global) { - if (siblings.size === 0 && get_element_parent(node) !== null) { + if (siblings.size === 0 && get_element_parent(element) !== null) { return false; } - return mark(relative_selector, node); + return mark(relative_selector, element); } + for (const possible_sibling of siblings.keys()) { if (apply_selector(relative_selectors.slice(), possible_sibling, stylesheet)) { - mark(relative_selector, node); + mark(relative_selector, element); has_match = true; } } + return has_match; } // TODO other combinators - return mark(relative_selector, node); + return mark(relative_selector, element); } - return mark(relative_selector, node); + return mark(relative_selector, element); } const regex_backslash_and_following_character = /\\(.)/g; /** - * @param {RelativeSelector} relative_selector + * @param {import('#compiler').Css.RelativeSelector} relative_selector * @param {import('#compiler').RegularElement | import('#compiler').SvelteElement} node * @returns {NO_MATCH | POSSIBLE_MATCH | UNKNOWN_SELECTOR} */ -function block_might_apply_to_node(relative_selector, node) { - if (relative_selector.is_host || relative_selector.is_root) return NO_MATCH; +function relative_selector_might_apply_to_node(relative_selector, node) { + if (relative_selector.metadata.is_host || relative_selector.metadata.is_root) return NO_MATCH; let i = relative_selector.selectors.length; while (i--) { @@ -356,7 +218,7 @@ function block_might_apply_to_node(relative_selector, node) { if (selector.type === 'AttributeSelector') { const whitelisted = whitelist_attribute_selector.get(node.name.toLowerCase()); if ( - !whitelisted?.has(selector.name.toLowerCase()) && + !whitelisted?.includes(selector.name.toLowerCase()) && !attribute_matches( node, selector.name, @@ -802,77 +664,3 @@ function loop_child(children, adjacent_only) { } return result; } - -/** - * Represents a compound selector (aka an array of simple selectors) plus - * a preceding combinator (if not the first in the list). Given this... - * - * ```css - * .a + .b.c {...} - * ``` - * - * ...both `.a` and `+ .b.c` are relative selectors. - * Combined, they are a complex selector. - */ -class RelativeSelector { - /** @type {import('#compiler').Css.Combinator | null} */ - combinator; - - /** @type {import('#compiler').Css.SimpleSelector[]} */ - selectors = []; - - is_host = false; - is_root = false; - should_encapsulate = false; - start = -1; - end = -1; - - /** @param {import('#compiler').Css.Combinator | null} combinator */ - constructor(combinator) { - this.combinator = combinator; - } - - /** @param {import('#compiler').Css.SimpleSelector} selector */ - add(selector) { - if (this.selectors.length === 0) { - this.start = selector.start; - this.is_host = selector.type === 'PseudoClassSelector' && selector.name === 'host'; - } - this.is_root = - this.is_root || (selector.type === 'PseudoClassSelector' && selector.name === 'root'); - this.selectors.push(selector); - this.end = selector.end; - } - - can_ignore() { - return this.is_global || this.is_host || this.is_root; - } - - get is_global() { - return ( - this.selectors.length >= 1 && - this.selectors[0].type === 'PseudoClassSelector' && - this.selectors[0].name === 'global' && - this.selectors.every( - (selector) => - selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector' - ) - ); - } -} - -/** @param {import('#compiler').Css.ComplexSelector} selector */ -function group_selectors(selector) { - let relative_selector = new RelativeSelector(null); - const relative_selectors = [relative_selector]; - - selector.children.forEach((child) => { - if (child.type === 'Combinator') { - relative_selector = new RelativeSelector(child); - relative_selectors.push(relative_selector); - } else { - relative_selector.add(child); - } - }); - return relative_selectors; -} diff --git a/packages/svelte/src/compiler/css/utils.js b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js similarity index 71% rename from packages/svelte/src/compiler/css/utils.js rename to packages/svelte/src/compiler/phases/2-analyze/css/utils.js index d3554c3437..893ed5871b 100644 --- a/packages/svelte/src/compiler/css/utils.js +++ b/packages/svelte/src/compiler/phases/2-analyze/css/utils.js @@ -1,18 +1,3 @@ -const regex_return_characters = /\r/g; - -/** - * @param {string} str - * @returns {string} - */ -export function hash(str) { - str = str.replace(regex_return_characters, ''); - let hash = 5381; - let i = str.length; - - while (i--) hash = ((hash << 5) - hash) ^ str.charCodeAt(i); - return (hash >>> 0).toString(36); -} - const UNKNOWN = {}; /** diff --git a/packages/svelte/src/compiler/phases/2-analyze/index.js b/packages/svelte/src/compiler/phases/2-analyze/index.js index 3bbc6819eb..286821cc58 100644 --- a/packages/svelte/src/compiler/phases/2-analyze/index.js +++ b/packages/svelte/src/compiler/phases/2-analyze/index.js @@ -13,7 +13,6 @@ import * as b from '../../utils/builders.js'; import { ReservedKeywords, Runes, SVGElements } from '../constants.js'; import { Scope, ScopeRoot, create_scopes, get_rune, set_scope } from '../scope.js'; import { merge } from '../visitors.js'; -import { Stylesheet } from '../../css/Stylesheet.js'; import { validation_legacy, validation_runes, validation_runes_js } from './validation.js'; import { warn } from '../../warnings.js'; import check_graph_for_cycles from './utils/check_graph_for_cycles.js'; @@ -21,6 +20,9 @@ import { regex_starts_with_newline } from '../patterns.js'; import { create_attribute, is_element_node } from '../nodes.js'; import { DelegatedEvents, namespace_svg } from '../../../constants.js'; import { should_proxy_or_freeze } from '../3-transform/client/utils.js'; +import { css_visitors } from './css/css-analyze.js'; +import { prune } from './css/css-prune.js'; +import { hash } from './utils.js'; /** * @param {import('#compiler').Script | null} script @@ -340,13 +342,6 @@ export function analyze_component(root, options) { instance, template, elements: [], - stylesheet: new Stylesheet({ - ast: root.css, - // TODO are any of these necessary or can we just pass in the whole `analysis` object later? - filename: options.filename || 'input.svelte', - component_name, - get_css_hash: options.cssHash - }), runes, immutable: runes || options.immutable, exports: [], @@ -360,7 +355,19 @@ export function analyze_component(root, options) { reactive_statements: new Map(), binding_groups: new Map(), slot_names: new Set(), - warnings + warnings, + css: { + ast: root.css, + hash: root.css + ? options.cssHash({ + css: root.css.content.styles, + filename: options.filename ?? '', + name: component_name, + hash + }) + : '', + keyframes: [] + } }; if (analysis.runes) { @@ -452,13 +459,68 @@ export function analyze_component(root, options) { } } - analysis.stylesheet.validate(analysis); + if (analysis.css.ast) { + // validate + walk(analysis.css.ast, analysis.css, css_visitors); - for (const element of analysis.elements) { - analysis.stylesheet.apply(element); - } + // mark nodes as scoped/unused/empty etc + for (const element of analysis.elements) { + prune(analysis.css.ast, element); + } + + outer: for (const element of analysis.elements) { + if (element.metadata.scoped) { + // Dynamic elements in dom mode always use spread for attributes and therefore shouldn't have a class attribute added to them + // TODO this happens during the analysis phase, which shouldn't know anything about client vs server + if (element.type === 'SvelteElement' && options.generate === 'client') continue; + + /** @type {import('#compiler').Attribute | undefined} */ + let class_attribute = undefined; + + for (const attribute of element.attributes) { + if (attribute.type === 'SpreadAttribute') { + // The spread method appends the hash to the end of the class attribute on its own + continue outer; + } - analysis.stylesheet.reify(options.generate === 'client'); + if (attribute.type !== 'Attribute') continue; + if (attribute.name.toLowerCase() !== 'class') continue; + + class_attribute = attribute; + } + + if (class_attribute && class_attribute.value !== true) { + const chunks = class_attribute.value; + + if (chunks.length === 1 && chunks[0].type === 'Text') { + chunks[0].data += ` ${analysis.css.hash}`; + } else { + chunks.push({ + type: 'Text', + data: ` ${analysis.css.hash}`, + raw: ` ${analysis.css.hash}`, + start: -1, + end: -1, + parent: null + }); + } + } else { + element.attributes.push( + create_attribute('class', -1, -1, [ + { + type: 'Text', + data: analysis.css.hash, + raw: analysis.css.hash, + parent: null, + start: -1, + end: -1 + } + ]) + ); + } + } + } + } // TODO // analysis.stylesheet.warn_on_unused_selectors(analysis); diff --git a/packages/svelte/src/compiler/phases/2-analyze/utils.js b/packages/svelte/src/compiler/phases/2-analyze/utils.js new file mode 100644 index 0000000000..3bb9b3559e --- /dev/null +++ b/packages/svelte/src/compiler/phases/2-analyze/utils.js @@ -0,0 +1,14 @@ +const regex_return_characters = /\r/g; + +/** + * @param {string} str + * @returns {string} + */ +export function hash(str) { + str = str.replace(regex_return_characters, ''); + let hash = 5381; + let i = str.length; + + while (i--) hash = ((hash << 5) - hash) ^ str.charCodeAt(i); + return (hash >>> 0).toString(36); +} 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 187329fe2e..ee47cc33ae 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 @@ -8,6 +8,7 @@ import { javascript_visitors } from './visitors/javascript.js'; import { javascript_visitors_runes } from './visitors/javascript-runes.js'; import { javascript_visitors_legacy } from './visitors/javascript-legacy.js'; import { is_state_source, serialize_get_binding } from './utils.js'; +import { render_stylesheet } from '../css/index.js'; /** * This function ensures visitor sets don't accidentally clobber each other @@ -271,15 +272,15 @@ export function client_component(source, analysis, options) { ]); const append_styles = - analysis.inject_styles && analysis.stylesheet.has_styles + analysis.inject_styles && analysis.css.ast ? () => component_block.body.push( b.stmt( b.call( '$.append_styles', b.id('$$anchor'), - b.literal(analysis.stylesheet.id), - b.literal(analysis.stylesheet.render(source, options).code) + b.literal(analysis.css.hash), + b.literal(render_stylesheet(source, analysis, options).code) ) ) ) diff --git a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js index c297a04a68..8900a6cf2c 100644 --- a/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js +++ b/packages/svelte/src/compiler/phases/3-transform/client/visitors/template.js @@ -322,7 +322,7 @@ function serialize_element_spread_attributes(attributes, context, element, eleme element_id, b.thunk(b.array(values)), lowercase_attributes, - b.literal(context.state.analysis.stylesheet.id) + b.literal(context.state.analysis.css.hash) ) ); @@ -345,7 +345,7 @@ function serialize_element_spread_attributes(attributes, context, element, eleme b.id(id), b.array(values), lowercase_attributes, - b.literal(context.state.analysis.stylesheet.id) + b.literal(context.state.analysis.css.hash) ) ) ) @@ -364,9 +364,9 @@ function serialize_element_spread_attributes(attributes, context, element, eleme */ function serialize_dynamic_element_attributes(attributes, context, element_id) { if (attributes.length === 0) { - if (context.state.analysis.stylesheet.id) { + if (context.state.analysis.css.hash) { context.state.init.push( - b.stmt(b.call('$.class_name', element_id, b.literal(context.state.analysis.stylesheet.id))) + b.stmt(b.call('$.class_name', element_id, b.literal(context.state.analysis.css.hash))) ); } return false; @@ -399,7 +399,7 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) { '$.spread_dynamic_element_attributes_effect', element_id, b.thunk(b.array(values)), - b.literal(context.state.analysis.stylesheet.id) + b.literal(context.state.analysis.css.hash) ) ); @@ -420,7 +420,7 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) { element_id, b.id(id), b.array(values), - b.literal(context.state.analysis.stylesheet.id) + b.literal(context.state.analysis.css.hash) ) ) ) @@ -434,7 +434,7 @@ function serialize_dynamic_element_attributes(attributes, context, element_id) { element_id, b.literal(null), b.array(values), - b.literal(context.state.analysis.stylesheet.id) + b.literal(context.state.analysis.css.hash) ) ) ); diff --git a/packages/svelte/src/compiler/phases/3-transform/css/index.js b/packages/svelte/src/compiler/phases/3-transform/css/index.js new file mode 100644 index 0000000000..40f9776006 --- /dev/null +++ b/packages/svelte/src/compiler/phases/3-transform/css/index.js @@ -0,0 +1,252 @@ +import MagicString from 'magic-string'; +import { walk } from 'zimmerframe'; +import { is_keyframes_node, regex_css_name_boundary, remove_css_prefix } from '../../css.js'; +import { merge_with_preprocessor_map } from '../../../utils/mapped_code.js'; + +/** @typedef {{ code: MagicString, dev: boolean, hash: string, selector: string, keyframes: string[] }} State */ + +/** + * + * @param {string} source + * @param {import('../../types.js').ComponentAnalysis} analysis + * @param {import('#compiler').ValidatedCompileOptions} options + */ +export function render_stylesheet(source, analysis, options) { + const code = new MagicString(source); + + /** @type {State} */ + const state = { + code, + dev: options.dev, + hash: analysis.css.hash, + selector: `.${analysis.css.hash}`, + keyframes: analysis.css.keyframes + }; + + const ast = /** @type {import('#compiler').Css.StyleSheet} */ (analysis.css.ast); + + walk(/** @type {import('#compiler').Css.Node} */ (ast), state, visitors); + + code.remove(0, ast.content.start); + code.remove(/** @type {number} */ (ast.content.end), source.length); + + const css = { + code: code.toString(), + map: code.generateMap({ + // include source content; makes it easier/more robust looking up the source map code + includeContent: true, + // generateMap takes care of calculating source relative to file + source: options.filename, + file: options.cssOutputFilename || options.filename + }) + }; + + merge_with_preprocessor_map(css, options, css.map.sources[0]); + + if (options.dev && options.css === 'injected' && css.code) { + css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`; + } + + return css; +} + +/** @type {import('zimmerframe').Visitors} */ +const visitors = { + _: (node, context) => { + context.state.code.addSourcemapLocation(node.start); + context.state.code.addSourcemapLocation(node.end); + context.next(); + }, + Atrule(node, { state, next }) { + if (is_keyframes_node(node)) { + let start = node.start + node.name.length + 1; + while (state.code.original[start] === ' ') start += 1; + let end = start; + while (state.code.original[end] !== '{' && state.code.original[end] !== ' ') end += 1; + + if (node.prelude.startsWith('-global-')) { + state.code.remove(start, start + 8); + } else { + state.code.prependRight(start, `${state.hash}-`); + } + + return; // don't transform anything within + } + + next(); + }, + Declaration(node, { state, next }) { + const property = node.property && remove_css_prefix(node.property.toLowerCase()); + if (property === 'animation' || property === 'animation-name') { + let index = node.start + node.property.length + 1; + let name = ''; + + while (index < state.code.original.length) { + const character = state.code.original[index]; + + if (regex_css_name_boundary.test(character)) { + if (state.keyframes.includes(name)) { + state.code.prependRight(index - name.length, `${state.hash}-`); + } + + if (character === ';' || character === '}') { + break; + } + + name = ''; + } else { + name += character; + } + + index++; + } + } + }, + Rule(node, { state, next }) { + // keep empty rules in dev, because it's convenient to + // see them in devtools + if (!state.dev && is_empty(node)) { + state.code.prependRight(node.start, '/* (empty) '); + state.code.appendLeft(node.end, '*/'); + escape_comment_close(node, state.code); + return; + } + + const used = node.prelude.children.filter((s) => s.metadata.used); + + if (used.length === 0) { + state.code.prependRight(node.start, '/* (unused) '); + state.code.appendLeft(node.end, '*/'); + escape_comment_close(node, state.code); + + return; + } + + if (used.length < node.prelude.children.length) { + let pruning = false; + let last = node.prelude.children[0].start; + + for (let i = 0; i < node.prelude.children.length; i += 1) { + const selector = node.prelude.children[i]; + + if (selector.metadata.used === pruning) { + if (pruning) { + let i = selector.start; + while (state.code.original[i] !== ',') i--; + + state.code.overwrite(i, i + 1, '*/'); + } else { + if (i === 0) { + state.code.prependRight(selector.start, '/* (unused) '); + } else { + state.code.overwrite(last, selector.start, ' /* (unused) '); + } + } + + pruning = !pruning; + } + + last = selector.end; + } + + if (pruning) { + state.code.appendLeft(last, '*/'); + } + } + + next(); + }, + ComplexSelector(node, context) { + /** @param {import('#compiler').Css.SimpleSelector} selector */ + function remove_global_pseudo_class(selector) { + context.state.code + .remove(selector.start, selector.start + ':global('.length) + .remove(selector.end - 1, selector.end); + } + + let first = true; + + for (const relative_selector of node.children) { + if (relative_selector.metadata.is_global) { + remove_global_pseudo_class(relative_selector.selectors[0]); + } + + if (relative_selector.metadata.scoped) { + // for the first occurrence, we use a classname selector, so that every + // encapsulated selector gets a +0-1-0 specificity bump. thereafter, + // we use a `:where` selector, which does not affect specificity + let modifier = context.state.selector; + if (!first) modifier = `:where(${modifier})`; + + first = false; + + // TODO err... can this happen? + for (const selector of relative_selector.selectors) { + if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { + remove_global_pseudo_class(selector); + } + } + + let i = relative_selector.selectors.length; + while (i--) { + const selector = relative_selector.selectors[i]; + + if ( + selector.type === 'PseudoElementSelector' || + selector.type === 'PseudoClassSelector' + ) { + if (selector.name !== 'root' && selector.name !== 'host') { + if (i === 0) context.state.code.prependRight(selector.start, modifier); + } + continue; + } + + if (selector.type === 'TypeSelector' && selector.name === '*') { + context.state.code.update(selector.start, selector.end, modifier); + } else { + context.state.code.appendLeft(selector.end, modifier); + } + + break; + } + first = false; + } + } + + context.next(); + } +}; + +/** @param {import('#compiler').Css.Rule} rule */ +function is_empty(rule) { + if (rule.block.children.length > 0) return false; + return true; +} + +/** + * + * @param {import('#compiler').Css.Rule} node + * @param {MagicString} code + */ +function escape_comment_close(node, code) { + let escaped = false; + let in_comment = false; + + for (let i = node.start; i < node.end; i++) { + if (escaped) { + escaped = false; + } else { + const char = code.original[i]; + if (in_comment) { + if (char === '*' && code.original[i + 1] === '/') { + code.prependRight(++i, '\\'); + in_comment = false; + } + } else if (char === '\\') { + escaped = true; + } else if (char === '/' && code.original[++i] === '*') { + in_comment = true; + } + } + } +} diff --git a/packages/svelte/src/compiler/phases/3-transform/index.js b/packages/svelte/src/compiler/phases/3-transform/index.js index 37e9e57263..708125f19e 100644 --- a/packages/svelte/src/compiler/phases/3-transform/index.js +++ b/packages/svelte/src/compiler/phases/3-transform/index.js @@ -3,6 +3,7 @@ import { VERSION } from '../../../version.js'; import { server_component, server_module } from './server/transform-server.js'; import { client_component, client_module } from './client/transform-client.js'; import { getLocator } from 'locate-character'; +import { render_stylesheet } from './css/index.js'; import { merge_with_preprocessor_map, get_source_name } from '../../utils/mapped_code.js'; /** @@ -51,8 +52,8 @@ export function transform_component(analysis, source, options) { merge_with_preprocessor_map(js, options, js_source_name); const css = - analysis.stylesheet.has_styles && !analysis.inject_styles - ? analysis.stylesheet.render(source, options) + analysis.css.ast && !analysis.inject_styles + ? render_stylesheet(source, analysis, options) : null; return { 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 7db7cae37c..cd2b897311 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 @@ -760,7 +760,7 @@ function serialize_element_spread_attributes( b.array(values), lowercase_attributes, is_svg, - b.literal(context.state.analysis.stylesheet.id) + b.literal(context.state.analysis.css.hash) ]; if (style_directives.length > 0 || class_directives.length > 0) { diff --git a/packages/svelte/src/compiler/phases/css.js b/packages/svelte/src/compiler/phases/css.js new file mode 100644 index 0000000000..c04ab119fe --- /dev/null +++ b/packages/svelte/src/compiler/phases/css.js @@ -0,0 +1,13 @@ +const regex_css_browser_prefix = /^-((webkit)|(moz)|(o)|(ms))-/; +export const regex_css_name_boundary = /^[\s,;}]$/; + +/** + * @param {string} name + * @returns {string} + */ +export function remove_css_prefix(name) { + return name.replace(regex_css_browser_prefix, ''); +} + +/** @param {import('#compiler').Css.Atrule} node */ +export const is_keyframes_node = (node) => remove_css_prefix(node.name) === 'keyframes'; diff --git a/packages/svelte/src/compiler/phases/types.d.ts b/packages/svelte/src/compiler/phases/types.d.ts index c8397e371e..df880bf783 100644 --- a/packages/svelte/src/compiler/phases/types.d.ts +++ b/packages/svelte/src/compiler/phases/types.d.ts @@ -1,5 +1,6 @@ import type { Binding, + Css, Fragment, RegularElement, SvelteElement, @@ -7,7 +8,6 @@ import type { SvelteOptions } from '#compiler'; import type { Identifier, LabeledStatement, Program } from 'estree'; -import { Stylesheet } from '../css/Stylesheet.js'; import type { Scope, ScopeRoot } from './scope.js'; export interface Js { @@ -51,7 +51,6 @@ export interface ComponentAnalysis extends Analysis { root: ScopeRoot; instance: Js; template: Template; - stylesheet: Stylesheet; elements: Array; runes: boolean; exports: Array<{ name: string; alias: string | null }>; @@ -69,6 +68,11 @@ export interface ComponentAnalysis extends Analysis { /** Identifiers that make up the `bind:group` expression -> internal group binding name */ binding_groups: Map<[key: string, bindings: Array], Identifier>; slot_names: Set; + css: { + ast: Css.StyleSheet | null; + hash: string; + keyframes: string[]; + }; } declare module 'estree' { diff --git a/packages/svelte/src/compiler/css/types.d.ts b/packages/svelte/src/compiler/types/css.ts similarity index 72% rename from packages/svelte/src/compiler/css/types.d.ts rename to packages/svelte/src/compiler/types/css.ts index f275e114ba..b273ba5abc 100644 --- a/packages/svelte/src/compiler/css/types.d.ts +++ b/packages/svelte/src/compiler/types/css.ts @@ -1,10 +1,19 @@ -import type { Style } from '../types/template'; - export interface BaseNode { start: number; end: number; } +export interface StyleSheet extends BaseNode { + type: 'StyleSheet'; + attributes: any[]; // TODO + children: Array; + content: { + start: number; + end: number; + styles: string; + }; +} + export interface Atrule extends BaseNode { type: 'Atrule'; name: string; @@ -24,8 +33,23 @@ export interface SelectorList extends BaseNode { } export interface ComplexSelector extends BaseNode { - type: 'Selector'; - children: Array; + type: 'ComplexSelector'; + children: RelativeSelector[]; + metadata: { + used: boolean; + }; +} + +export interface RelativeSelector extends BaseNode { + type: 'RelativeSelector'; + combinator: null | Combinator; + selectors: SimpleSelector[]; + metadata: { + is_global: boolean; + is_host: boolean; + is_root: boolean; + scoped: boolean; + }; } export interface TypeSelector extends BaseNode { @@ -99,4 +123,12 @@ export interface Declaration extends BaseNode { } // for zimmerframe -export type Node = Style | Rule | Atrule | Declaration; +export type Node = + | StyleSheet + | Rule + | Atrule + | ComplexSelector + | RelativeSelector + | Combinator + | SimpleSelector + | Declaration; diff --git a/packages/svelte/src/compiler/types/index.d.ts b/packages/svelte/src/compiler/types/index.d.ts index fa3f75bd91..11e71f63a1 100644 --- a/packages/svelte/src/compiler/types/index.d.ts +++ b/packages/svelte/src/compiler/types/index.d.ts @@ -10,7 +10,7 @@ import type { Location } from 'locate-character'; import type { SourceMap } from 'magic-string'; import type { Context } from 'zimmerframe'; import type { Scope } from '../phases/scope.js'; -import * as Css from '../css/types.js'; +import * as Css from './css.js'; import type { EachBlock, Namespace, SvelteNode } from './template.js'; /** The return value of `compile` from `svelte/compiler` */ diff --git a/packages/svelte/src/compiler/types/legacy-nodes.d.ts b/packages/svelte/src/compiler/types/legacy-nodes.d.ts index ba89520e20..45b26aa70f 100644 --- a/packages/svelte/src/compiler/types/legacy-nodes.d.ts +++ b/packages/svelte/src/compiler/types/legacy-nodes.d.ts @@ -1,4 +1,4 @@ -import type { StyleDirective as LegacyStyleDirective, Text } from '#compiler'; +import type { StyleDirective as LegacyStyleDirective, Text, Css } from '#compiler'; import type { ArrayExpression, AssignmentExpression, @@ -227,9 +227,28 @@ export type LegacyElementLike = | LegacyTitle | LegacyWindow; +export interface LegacyStyle extends BaseNode { + type: 'Style'; + attributes: any[]; + content: { + start: number; + end: number; + styles: string; + }; + children: any[]; +} + +export interface LegacySelector extends BaseNode { + type: 'Selector'; + children: Array; +} + +export type LegacyCssNode = LegacyStyle | LegacySelector; + export type LegacySvelteNode = | LegacyConstTag | LegacyElementLike | LegacyAttributeLike | LegacyAttributeShorthand + | LegacyCssNode | Text; diff --git a/packages/svelte/src/compiler/types/template.d.ts b/packages/svelte/src/compiler/types/template.d.ts index 0cbe05c57b..ec8319e575 100644 --- a/packages/svelte/src/compiler/types/template.d.ts +++ b/packages/svelte/src/compiler/types/template.d.ts @@ -1,8 +1,7 @@ -import type { Binding } from '#compiler'; +import type { Binding, Css } from '#compiler'; import type { ArrayExpression, ArrowFunctionExpression, - ArrayPattern, VariableDeclaration, VariableDeclarator, Expression, @@ -16,6 +15,7 @@ import type { Program, SpreadElement } from 'estree'; +import type { Atrule, Rule } from './css'; export interface BaseNode { type: string; @@ -53,7 +53,7 @@ export interface Root extends BaseNode { options: SvelteOptions | null; fragment: Fragment; /** The parsed `