sitez

An element's CSS is its own

Every top-level selector in an element's <style> starts with its tag, so its CSS can't reach the rest of the page by accident (elementz.md). Rules inside @media, @supports, @container, @layer and @scope are at the top too, and are checked. Other at-rules, such as @keyframes and @font-face, hold no selectors for the page and are left alone. A selector nested inside a rule is the rule's, so it's never checked.

This reads CSS only as far as the check needs: comments, strings, brackets and blocks. What isn't valid CSS is left to the bundler.

import type { FileError } from "./errors.ts";
import type { Part } from "./file.ts";

const GROUPS = new Set(["media", "supports", "container", "layer", "scope", "starting-style"]);

export function checkStyle(
  style: Part,
  name: string,
  fail: (at: number, message: string) => FileError,
): void {
  // Comments and strings blanked, so a brace or a comma in one is never read as CSS.
  const css = style.text.replace(/\/\*[\s\S]*?\*\/|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, (s) =>
    s[0] === "/" ? " ".repeat(s.length) : s[0] + "x".repeat(s.length - 2) + s[0],
  );
  const own = new RegExp(`^${name}(?![\\w-])`);
  const rules = (from: number, to: number): void => {
    let at = from;
    while (at < to) {
      const brace = find(css, "{", at, to);
      const semicolon = find(css, ";", at, to);
      if (semicolon !== -1 && (brace === -1 || semicolon < brace)) {
        at = semicolon + 1;
        continue;
      }
      if (brace === -1) return;
      const prelude = css.slice(at, brace);
      const end = closingBrace(css, brace, to);
      const atRule = /^\s*@([\w-]+)/.exec(prelude);
      if (atRule) {
        if (GROUPS.has(atRule[1]!.toLowerCase())) rules(brace + 1, end);
      } else {
        let offset = at;
        for (const selector of splitTop(prelude)) {
          const trimmed = selector.trim();
          if (trimmed && !own.test(trimmed)) {
            const start = style.start + offset + selector.indexOf(trimmed);
            throw fail(
              start,
              `the rule ${trimmed} doesn't start with ${name}. Every top-level selector in an element's <style> starts with its tag, as in ${name} ${trimmed}, so its CSS stays its own.`,
            );
          }
          offset += selector.length + 1;
        }
      }
      at = end + 1;
    }
  };
  rules(0, css.length);
}

/** The first `char` at the top level of `css` between `from` and `to`, or -1. */
function find(css: string, char: string, from: number, to: number): number {
  let depth = 0;
  for (let i = from; i < to; i++) {
    const c = css[i];
    if (c === "(" || c === "[") depth++;
    else if (c === ")" || c === "]") depth--;
    else if (c === char && depth === 0) return i;
  }
  return -1;
}

/** The `}` that closes the block opened at `open`, or `to` when it's never closed. */
function closingBrace(css: string, open: number, to: number): number {
  let depth = 0;
  for (let i = open; i < to; i++) {
    if (css[i] === "{") depth++;
    else if (css[i] === "}" && --depth === 0) return i;
  }
  return to;
}

/** A selector list split at its top-level commas, each piece as written. */
function splitTop(prelude: string): string[] {
  const parts: string[] = [];
  let depth = 0;
  let start = 0;
  for (let i = 0; i < prelude.length; i++) {
    const c = prelude[i];
    if (c === "(" || c === "[") depth++;
    else if (c === ")" || c === "]") depth--;
    else if (c === "," && depth === 0) {
      parts.push(prelude.slice(start, i));
      start = i + 1;
    }
  }
  parts.push(prelude.slice(start));
  return parts;
}