summaryrefslogtreecommitdiff
path: root/src/compiler/transform/postcss-scoped-styles/index.ts
blob: 23350869cd68acdebd41f94a1cd8246485aa6ec6 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import { Declaration, Plugin } from 'postcss';

interface AstroScopedOptions {
  className: string;
}

interface Selector {
  start: number;
  end: number;
  value: string;
}

const CSS_SEPARATORS = new Set([' ', ',', '+', '>', '~']);
const KEYFRAME_PERCENT = /\d+\.?\d*%/;

/** HTML tags that should never get scoped classes */
export const NEVER_SCOPED_TAGS = new Set<string>(['base', 'body', 'font', 'frame', 'frameset', 'head', 'html', 'link', 'meta', 'noframes', 'noscript', 'script', 'style', 'title']);

/**
 * Scope Rules
 * Given a selector string (`.btn>span,.nav>span`), add an additional CSS class to every selector (`.btn.myClass>span.myClass,.nav.myClass>span.myClass`)
 * @param {string} selector The minified selector string to parse. Cannot contain arbitrary whitespace (other than child selector syntax).
 * @param {string} className The CSS class to apply.
 */
export function scopeRule(selector: string, className: string) {
  // if this is a keyframe keyword, return original selector
  if (selector === 'from' || selector === 'to' || KEYFRAME_PERCENT.test(selector)) {
    return selector;
  }

  // For everything else, parse & scope
  const c = className.replace(/^\.?/, '.'); // make sure class always has leading '.'
  const selectors: Selector[] = [];
  let ss = selector; // final output

  // Pass 1: parse selector string; extract top-level selectors
  {
    let start = 0;
    let lastValue = '';
    let parensOpen = false;
    for (let n = 0; n < ss.length; n++) {
      const isEnd = n === selector.length - 1;
      if (selector[n] === '(') parensOpen = true;
      if (selector[n] === ')') parensOpen = false;
      if (isEnd || (parensOpen === false && CSS_SEPARATORS.has(selector[n]))) {
        lastValue = selector.substring(start, isEnd ? undefined : n);
        if (!lastValue) continue;
        selectors.push({ start, end: isEnd ? n + 1 : n, value: lastValue });
        start = n + 1;
      }
    }
  }

  // Pass 2: starting from end, transform selectors w/ scoped class
  for (let i = selectors.length - 1; i >= 0; i--) {
    const { start, end, value } = selectors[i];
    const head = ss.substring(0, start);
    const tail = ss.substring(end);

    // replace '*' with className
    if (value === '*') {
      ss = head + c + tail;
      continue;
    }

    // leave :global() alone!
    if (value.startsWith(':global(')) {
      ss =
        head +
        ss
          .substring(start, end)
          .replace(/^:global\(/, '')
          .replace(/\)$/, '') +
        tail;
      continue;
    }

    // don‘t scope body, title, etc.
    if (NEVER_SCOPED_TAGS.has(value)) {
      ss = head + value + tail;
      continue;
    }

    // scope everything else
    let newSelector = ss.substring(start, end);
    const pseudoIndex = newSelector.indexOf(':');
    if (pseudoIndex > 0) {
      // if there‘s a pseudoclass (:focus)
      ss = head + newSelector.substring(start, pseudoIndex) + c + newSelector.substr(pseudoIndex) + tail;
    } else {
      ss = head + newSelector + c + tail;
    }
  }

  return ss;
}

/** PostCSS Scope plugin */
export default function astroScopedStyles(options: AstroScopedOptions): Plugin {
  return {
    postcssPlugin: '@astro/postcss-scoped-styles',
    Rule(rule) {
      rule.selector = scopeRule(rule.selector, options.className);
    },
  };
}