UNPKG

svelte

Version:

Cybernetically enhanced web apps

461 lines (396 loc) • 12.7 kB
/** @import { Visitors } from 'zimmerframe' */ /** @import { AST, ValidatedCompileOptions } from '#compiler' */ /** @import { ComponentAnalysis } from '../../types.js' */ 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'; import { dev } from '../../../state.js'; /** * @typedef {{ * code: MagicString; * hash: string; * minify: boolean; * selector: string; * keyframes: string[]; * specificity: { * bumped: boolean * } * }} State */ /** * * @param {string} source * @param {ComponentAnalysis} analysis * @param {ValidatedCompileOptions} options */ export function render_stylesheet(source, analysis, options) { const code = new MagicString(source); /** @type {State} */ const state = { code, hash: analysis.css.hash, minify: analysis.inject_styles && !options.dev, selector: `.${analysis.css.hash}`, keyframes: analysis.css.keyframes, specificity: { bumped: false } }; const ast = /** @type {AST.CSS.StyleSheet} */ (analysis.css.ast); walk(/** @type {AST.CSS.Node} */ (ast), state, visitors); code.remove(0, ast.content.start); code.remove(/** @type {number} */ (ast.content.end), source.length); if (state.minify) { remove_preceding_whitespace(ast.content.end, state); } 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 (dev && options.css === 'injected' && css.code) { css.code += `\n/*# sourceMappingURL=${css.map.toUrl()} */`; } return css; } /** @type {Visitors<AST.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, path }) { 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 if (!is_in_global_block(path)) { state.code.prependRight(start, `${state.hash}-`); } return; // don't transform anything within } next(); }, Declaration(node, { state }) { 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++; } } else if (state.minify) { remove_preceding_whitespace(node.start, state); // Don't minify whitespace in custom properties, since some browsers (Chromium < 99) // treat --foo: ; and --foo:; differently if (!node.property.startsWith('--')) { let start = node.start + node.property.length + 1; let end = start; while (/\s/.test(state.code.original[end])) end++; if (end > start) state.code.remove(start, end); } } }, Rule(node, { state, next, visit, path }) { if (state.minify) { remove_preceding_whitespace(node.start, state); remove_preceding_whitespace(node.block.end - 1, state); } // keep empty rules in dev, because it's convenient to // see them in devtools if (!dev && is_empty(node, is_in_global_block(path))) { if (state.minify) { state.code.remove(node.start, node.end); } else { state.code.prependRight(node.start, '/* (empty) '); state.code.appendLeft(node.end, '*/'); escape_comment_close(node, state.code); } return; } if (!is_used(node) && !is_in_global_block(path)) { if (state.minify) { state.code.remove(node.start, node.end); } else { state.code.prependRight(node.start, '/* (unused) '); state.code.appendLeft(node.end, '*/'); escape_comment_close(node, state.code); } return; } if (node.metadata.is_global_block) { const selector = node.prelude.children[0]; if (selector.children.length === 1 && selector.children[0].selectors.length === 1) { // `:global {...}` if (state.minify) { state.code.remove(node.start, node.block.start + 1); state.code.remove(node.block.end - 1, node.end); } else { state.code.prependRight(node.start, '/* '); state.code.appendLeft(node.block.start + 1, '*/'); state.code.prependRight(node.block.end - 1, '/*'); state.code.appendLeft(node.block.end, '*/'); } // don't recurse into selectors but visit the body visit(node.block); return; } } next(); }, SelectorList(node, { state, next, path }) { // Only add comments if we're not inside a complex selector that itself is unused or a global block if ( !is_in_global_block(path) && !path.find((n) => n.type === 'ComplexSelector' && !n.metadata.used) ) { const children = node.children; let pruning = false; let prune_start = children[0].start; let last = prune_start; let has_previous_used = false; for (let i = 0; i < children.length; i += 1) { const selector = children[i]; if (selector.metadata.used === pruning) { if (pruning) { let i = selector.start; while (state.code.original[i] !== ',') i--; if (state.minify) { state.code.remove(prune_start, has_previous_used ? i : i + 1); } else { state.code.appendRight(has_previous_used ? i : i + 1, '*/'); } } else { if (i === 0) { if (state.minify) { prune_start = selector.start; } else { state.code.prependRight(selector.start, '/* (unused) '); } } else { if (state.minify) { prune_start = last; } else { state.code.overwrite(last, selector.start, ` /* (unused) `); } } } pruning = !pruning; } if (!pruning && selector.metadata.used) { has_previous_used = true; } last = selector.end; } if (pruning) { if (state.minify) { state.code.remove(prune_start, last); } else { state.code.appendLeft(last, '*/'); } } } // if we're in a `:is(...)` or whatever, keep existing specificity bump state let specificity = state.specificity; // if this selector list belongs to a rule, require a specificity bump for the // first scoped selector but only if we're at the top level let parent = path.at(-1); if (parent?.type === 'Rule') { specificity = { bumped: false }; /** @type {AST.CSS.Rule | null} */ let rule = parent.metadata.parent_rule; while (rule) { if (rule.metadata.has_local_selectors) { specificity = { bumped: true }; break; } rule = rule.metadata.parent_rule; } } next({ ...state, specificity }); }, ComplexSelector(node, context) { const before_bumped = context.state.specificity.bumped; for (const relative_selector of node.children) { if (relative_selector.metadata.is_global) { const global = /** @type {AST.CSS.PseudoClassSelector} */ (relative_selector.selectors[0]); remove_global_pseudo_class(global, relative_selector.combinator, context.state); if ( node.metadata.rule?.metadata.parent_rule && global.args === null && relative_selector.combinator === null ) { // div { :global.x { ... } } becomes div { &.x { ... } } context.state.code.prependRight(global.start, '&'); } continue; } else { // for any :global() or :global at the middle of compound selector for (const selector of relative_selector.selectors) { if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { remove_global_pseudo_class(selector, null, context.state); } } } if (relative_selector.metadata.scoped) { if (relative_selector.selectors.length === 1) { // skip standalone :is/:where/& selectors const selector = relative_selector.selectors[0]; if ( selector.type === 'PseudoClassSelector' && (selector.name === 'is' || selector.name === 'where') ) { continue; } } if (relative_selector.selectors.some((s) => s.type === 'NestingSelector')) { continue; } // 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 (context.state.specificity.bumped) modifier = `:where(${modifier})`; context.state.specificity.bumped = true; 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; } } } context.next(); context.state.specificity.bumped = before_bumped; }, PseudoClassSelector(node, context) { if (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not') { context.next(); } } }; /** * * @param {Array<AST.CSS.Node>} path */ function is_in_global_block(path) { return path.some((node) => node.type === 'Rule' && node.metadata.is_global_block); } /** * @param {AST.CSS.PseudoClassSelector} selector * @param {AST.CSS.Combinator | null} combinator * @param {State} state */ function remove_global_pseudo_class(selector, combinator, state) { if (selector.args === null) { let start = selector.start; if (combinator?.name === ' ') { // div :global.x becomes div.x while (/\s/.test(state.code.original[start - 1])) start--; } state.code.remove(start, selector.start + ':global'.length); } else { state.code .remove(selector.start, selector.start + ':global('.length) .remove(selector.end - 1, selector.end); } } /** * Walk backwards until we find a non-whitespace character * @param {number} end * @param {State} state */ function remove_preceding_whitespace(end, state) { let start = end; while (/\s/.test(state.code.original[start - 1])) start--; if (start < end) state.code.remove(start, end); } /** * @param {AST.CSS.Rule} rule * @param {boolean} is_in_global_block */ function is_empty(rule, is_in_global_block) { if (rule.metadata.is_global_block) { return rule.block.children.length === 0; } for (const child of rule.block.children) { if (child.type === 'Declaration') { return false; } if (child.type === 'Rule') { if ((is_used(child) || is_in_global_block) && !is_empty(child, is_in_global_block)) { return false; } } if (child.type === 'Atrule') { if (child.block === null || child.block.children.length > 0) return false; } } return true; } /** @param {AST.CSS.Rule} rule */ function is_used(rule) { return rule.prelude.children.some((selector) => selector.metadata.used); } /** * * @param {AST.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; } } } }