<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.