mirror of https://github.com/sveltejs/svelte
chore: use zimmerframe for CSS analysis/transformation (#10482)
* fix type * parse selectors properly the first time * partial fix * fix * start moving CSS validation into analysis phase * finish moving validation * fix tests * regenerate types * start porting scoping logic etc * move Style to Css.StyleSheet * some encouraging progress * more progress * more progress * fix a bunch of cases * more fixes * tweak * almost there * keyframes * fix * all CSS tests passing * legacy stuff * all tests passing * delete old code * regenerate types * defer analysis, address TODO * unused * tidy up * rename stuff * read selectors before combinators --------- Co-authored-by: Rich Harris <rich.harris@vercel.com>pull/10479/head
parent
f8ff2b6ea3
commit
cec3540ac2
@ -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<string, string>} 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<string, string>} 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<Atrule | Rule>} */
|
|
||||||
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<string, string>} 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<Rule | Atrule>} */
|
|
||||||
children = [];
|
|
||||||
|
|
||||||
/** @type {Map<string, string>} */
|
|
||||||
keyframes = new Map();
|
|
||||||
|
|
||||||
/** @type {Set<import('#compiler').RegularElement | import('#compiler').SvelteElement>} */
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
@ -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<import('../../types.js').ComponentAnalysis['css']>
|
||||||
|
* >} 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);
|
@ -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 = {};
|
const UNKNOWN = {};
|
||||||
|
|
||||||
/**
|
/**
|
@ -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);
|
||||||
|
}
|
@ -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<import('#compiler').Css.Node, State>} */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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';
|
@ -1,3 +1,3 @@
|
|||||||
/* (empty) .foo.svelte-xyz {
|
/* (empty) .foo {
|
||||||
/* empty *\/
|
/* empty *\/
|
||||||
}*/
|
}*/
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
/* (unused) .foo .bar {
|
/* (unused) :global(.foo) .bar {
|
||||||
color: red;
|
color: red;
|
||||||
}*/
|
}*/
|
||||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue