<h1><code>CSSRule</code> Debugger</h1>
<p><em>How does a browser interpet your CSS?</em></p>

<h2>Input CSS</h2>

<p>Load one of the pre-built snippets, or write your own <code>CSSRule</code></p>

<button data-id=0>Load snippet #0</button>
<button data-id=1>Load snippet #1</button>
<button data-id=2>Load snippet #2</button>

<style block>.foo {
    width: fit-content;

  @media screen {
    background-color: red;
  }
  
  background-color: green;
}</style>

<h2>Visual output</h2>
<div class="foo">I am foo</div>

<h2>Parsed CSS</h2>
<div class="warning"><p>⚠️ Your browser does not support <code>CSSNestedDeclarations</code> so expect some differences compared to the input.</p></div>
<pre id="parsed"></pre>

<h2>Serialized CSS</h2>
<pre id="serialized"></pre>
<div class="warning warning--info" style="display: block"><p>ℹ️  The <code>specificity</code> and <code>resolvedSelectorText</code> attributes included in the serialized output are not part of the CSSOM’s <code>CSSStyleRule</code> and were added for demonstration purposes.</p></div>

<footer><p>This page is a demo for <a href="https://web.dev/blog/css-nesting-cssnesteddeclarations" target="_top">https://web.dev/blog/css-nesting-cssnesteddeclarations</a></p></footer>
@layer layout {
  @layer general {
    html {
      font-family: sans-serif;
      line-height: 1.42;
    }
    body {
      padding: 3rem 0 10rem;
      width: 90%;
      max-width: 52em;
      margin: 0 auto;
    }
    h2 {
      margin-top: 2em;
    }
    a {
      color: #0000aa;
      text-decoration: none;
      &:hover {
        color: blue;
      }
    }
    footer {
      text-align: center;
      font-style: italic;
      margin: 4em 0;
    }
  }

  @layer code {
    style[contenteditable], script[block], pre {
      display: block;
      white-space: pre;
      border: 1px solid #dedede;
      border-left: 0.5em solid LinkText;
      background: #fafafa;
      padding: 0.75em;
      font-family: monospace;
      overflow-x: auto;
      margin: 1em 0;
      tab-size: 2;
      font-size: 1.25em;
      border-radius: 0.25em;
      tab-size: 2;
    }

    code:not(pre code), /* output:not(code:has(output) output) */ {
      background: #f7f7f7;
      border: 1px solid rgb(0 0 0 / 0.2);
      padding: 0.1rem 0.3rem;
      margin: 0.1rem 0;
      border-radius: 0.2rem;
      display: inline-block;
    }
  }

@layer warning {
    .warning {
      display: none;
      box-sizing: border-box;
      padding: 1em;
      margin: 1em 0;
      border: 1px solid #ccc;
      background: rgba(255 255 205 / 0.8);
    }

    .warning > :first-child {
      margin-top: 0;
    }

    .warning > :last-child {
      margin-bottom: 0;
    }

    .warning a {
      color: blue;
    }
    .warning--info {
      border: 1px solid #123456;
      background: rgb(205 230 255 / 0.8);
    }
    .warning--alarm {
      border: 1px solid red;
      background: #ff000010;
    }
  }
}
import { format } from "https://esm.sh/@projectwallace/format-css@1";
import Specificity from "https://esm.sh/@bramus/specificity@2";

// Show warning when there is no `CSSNestedDeclarations` support.
if (!("CSSNestedDeclarations" in self && "style" in CSSNestedDeclarations.prototype)) {
  document.querySelector('.warning').style.display = 'block';
}

const examples = [
  `
  .foo {
    width: fit-content;

    @media screen {
      background-color: red;
    }

    background-color: green;
  }
  `,
  `
  #foo,
  .foo,
  .foo::before {
    width: fit-content;
    background-color: red;

    @media screen {
      background-color: green;
    }
  }
  .foo::before {
    content: '(before)';
    margin-right: 1ch;
  }
  `,
  `
  .foo {
    width: fit-content;
    background-color: green;

    @starting-style {
      opacity: 0;
      scale: 0.5;
    }
      
    transition: all 0.5s ease;
    opacity: 1;
    scale: 1;
  }
  `
];

const go = (exampleIndex = 0) => {
  console.clear();
  
  // Use the CSS in the document.
  // Use @projectwallace/format-css for the formatting.
  const $input = document.querySelector('style[block]');
  $input.innerText = format(examples[exampleIndex]);
    
  process();
};

const process = () => {
  // The CSSRule to process
  const cssRule = document.styleSheets[1].cssRules[0];
  console.log(cssRule);
  
  // Re-insert .foo to make sure things like starting-style are visible
  const $newFoo = document.createElement('div');
  $newFoo.classList.add('foo');
  $newFoo.innerText = 'I am foo';
  document.querySelector('.foo').replaceWith($newFoo);

  // Get the parsed CSS from the cssRule’s cssText.
  // Use @projectwallace/format-css for the formatting.
  const $parsed = document.querySelector('pre#parsed');
  $parsed.innerText = format(cssRule?.cssText ?? '');

  // Manually build the structure of the cssRule
  const $serialized = document.querySelector('pre#serialized');
  $serialized.innerText = serialize(cssRule);
}

const ruleTypes = {
  1: 'STYLE_RULE',
  2: 'CHARSET_RULE',
  3: 'IMPORT_RULE',
  4: 'MEDIA_RULE',
  5: 'FONT_FACE_RULE',
  6: 'PAGE_RULE',
  9: 'MARGIN_RULE',
  10: 'NAMESPACE_RULE',
};

const extractDeclarationsFromStyle = (style) => {
  return Array.from(style)
    .map(p => `${p}: ${style[p]}`)
};

// Polyfill for https://github.com/w3c/csswg-drafts/issues/10246
const resolveSelector = (cssRule) => {
  let selector = '';

  const selectorTexts = [];
  do {
    if (cssRule.selectorText) {
      selectorTexts.unshift(cssRule.selectorText);
    }
    cssRule = cssRule.parentRule;
  } while (cssRule);

  for (let selectorText of selectorTexts) {
    selector = selectorText.replaceAll('&', `:is(${selector})`);
  }

  return selector;
};

const padLines = (text, level = 0) => {
  if (level == 0) return text;
  return text.split('\n').map(l => ' '.repeat((level) * 2) + l).join('\n');
};

const serialize = (cssRule, level = 0) => {
  if (!cssRule) return null;
  
  const { type, selectorText, style, cssRules } = cssRule;

  let toReturn = `↳ ${cssRule.constructor.name}\n`;
  
  if (type) {
    toReturn += `  .type = ${ruleTypes[type]}\n`;
  }

  if (selectorText) {
    const resolvedSelectorText = resolveSelector(cssRule);
    const specificity = Specificity.calculate(resolvedSelectorText);
    toReturn += `  .selectorText = "${selectorText}"\n`;
    toReturn += `  .resolvedSelectorText = "${resolvedSelectorText}"\n`;
    toReturn += `  .specificity = ${specificity}\n`;
  }
  
  if (style && style.length) {
    toReturn += `  .style (CSSStyleDeclaration, ${style.length}) =\n`;
    const declarations = extractDeclarationsFromStyle(style);
    for (let declaration of declarations) {
      toReturn += `    - ${declaration}\n`;
    }
    // toReturn += `  \n`;
  }
  
  if (cssRules && cssRules.length) {
    toReturn += `  .cssRules (CSSRuleList, ${cssRules.length}) =\n`;
    for (let childCssRule of cssRules) {
      toReturn += `    ${serialize(childCssRule, level + 1)}\n`;
    }
    toReturn += `  \n`;
  }
  
  return padLines(toReturn, level).trim('\n');
}

const $buttons = document.querySelectorAll('button');
$buttons.forEach($button => {
  $button.addEventListener('click', (e) => {
    e.preventDefault();
    go(parseInt($button.dataset.id, 10));
  });
});

go(new URL(window.location).searchParams.get('demo') ?? 0);

// Allow live editing of the CSS
// The logic is explained in https://www.bram.us/2024/02/18/custom-highlight-api-for-syntax-highlighting/
const codeBlock = document.querySelector('style[block]');

// Allow only plaintext editing
// Firefox doesn’t do 'plaintext-only', but does do 'true'
codeBlock.setAttribute('contenteditable', 'plaintext-only');
if (codeBlock.contentEditable != 'plaintext-only') {
  codeBlock.setAttribute('contenteditable', 'true');
}

codeBlock.addEventListener('keyup', () => {
  process();
});

codeBlock.addEventListener('keydown', e => {
  // The tab key should insert a tab character
  if (e.keyCode == 9) {
    document.execCommand('insertHTML', false, ' ');
    e.preventDefault();
  }
});

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.