Web Inspector

Allows you to inspect web pages

// ==UserScript==
// @name         Web Inspector
// @namespace    http://tampermonkey.net/
// @version      1.1.4
// @description  Allows you to inspect web pages
// @author       https://gf.qytechs.cn/en/users/85040-dan-wl-danwl
// @license      MIT
// @match        *://*/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

// MIT License

// Copyright(c) 2024 DanWL

// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// 	in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:

// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.

// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// 	FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// 	OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.


// This userscript defines a function on the web page you visit
// For the function to do anything, use this bookmarklet:
// javascript:(function(){WEB_INSPECTOR();})();
// A bookmarklet is essentially a regular browser bookmark/favorite but with a JavaScript url

// jshint esnext: false, esversion: 8

(() => {
	function debugAlert(str) {
		const debug = false;

		if (debug) {
			alert(str);
		}
	}

	function el(tagName, className) {
		const ret = document.createElement(tagName);

		if (className) {
			ret.className = className;
		}

		return ret;
	}

	function spanNode(className, innerTextOrText) {
		const span = el('span', className);

		if (typeof innerTextOrText !== 'undefined') {
			if (typeof innerTextOrText === 'string') {
				innerTextOrText = textNode(innerTextOrText);
			}

			if (typeof innerTextOrText === 'object' && isFinite(innerTextOrText.nodeType) && innerTextOrText.nodeType === Node.TEXT_NODE) {
				// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
				// using instanceof doesnt always work

				append(span, innerTextOrText);
			}
			else {
				throw new Error('spanNode(className, innerTextOrText) innerTextOrText is ' + innerTextOrText);
			}
		}

		return span;
	}

	function brNode() {
		return el('br');
	}

	function textNode(txt) {
		return document.createTextNode(txt);
	}

	function append(parent, nodes) {
		// enables much better minimising

		if (!Array.isArray(nodes)) {
			nodes = [nodes];
		}

		nodes.forEach((node) => {
			parent.appendChild(node);
		});
	}

	function htmlSymbol(symbol) {
		return spanNode('html-symbol', symbol);
	}

	function createTagNameNode(tagName) {
		return spanNode('tag-name', tagName);
	}

	function createTagAttributeValueNode(attribute) {
		const isLink = ['href', 'src'].includes(attribute.name);
		const isStyle = attribute.name === 'style';
		const span = spanNode('tag-attribute-value');

		if (isLink) {
			append(span, [textNode('"'), createLink(attribute.value, attribute.value), textNode('"')]);
		}
		else if (isStyle) {
			append(span, [textNode('"'), parseStyle(attribute.ownerElement.style), textNode('"')]);
		}
		else {
			append(span, textNode(JSON.stringify(attribute.value)));
		}

		return span;
	}

	function createPlainTextNode(node) {
		// TODO html entities highlighting

		return spanNode('text', textNode(applyHTMLWhitespaceRules(node.textContent)));
	}

	function elementDoesNotNeedToBeClosed(tagName) {
		// https://developer.mozilla.org/en-US/docs/Web/HTML/Element

		return ['base', 'link', 'meta', 'hr', 'br', 'wbr', 'area', 'img', 'track', 'embed', 'source', 'input'].includes(tagName);
	}

	function applyHTMLWhitespaceRules(text) {
		// https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Whitespace

		return text
			.replace(/^[\t ]+/mg, '')
			.replace(/[\t\r\n]/g, ' ')
			.replace(/ {2,}/g, ' ');
	}

	function createSpacer(spacing) {
		const spacer = el('pre', 'spacer');

		spacer.innerHTML = spacing;

		return spacer;
	}

	function createIndentSpacer(indentLevel) {
		const space = '\t';
		let spacing = '';

		while (indentLevel > 0) {
			spacing += space;
			indentLevel--;
		}

		const spacer = createSpacer(spacing);

		spacer.className += ' indentation';

		return spacer;
	}

	function createLink(url, displayText) {
		const link = el('a');

		link.href = url;
		link.target = '_blank';

		append(link, textNode(displayText));

		return link;
	}

	function createExpandCollapseBtn(element) {
		// https://www.amp-what.com &#9660

		const btn = el('button', 'expand-collapse-button');

		btn.innerHTML = '▼';

		return btn;
	}

	function setupExpandCollapseBtns(output) {
		// outerHTML doesnt pass event handlers, so add them all after finished generating the content

		const btns = output.querySelectorAll('button.expand-collapse-button');

		for (let i = 0; i < btns.length; i++) {
			btns[i].onclick = function(e) {
				const btn = e.target;
				let element;

				if (btn.parentNode.className.match(/^html-line\b/)) {
					element = btn.parentNode.querySelector('.tag-inner');
				}
				else if (btn.parentNode.className.match(/^[a-z\-]+-rule\b/)) {
					element = btn.parentNode.querySelector('.css-brace-content');
				}
				else {
					console.error('btn', btn);

					throw new Error('setupExpandCollapseBtns(output) btns[i].onclick showing/collapsing button does not correctly control html or css');
				}

				if (element.className.match(/ collapsed /)) {
					element.className = element.className.replace(/ collapsed /, '');
				}
				else {
					element.className += ' collapsed ';
				}
			};
		}
	}

	function getUrlOrigin(link) {
		debugAlert('getUrlOrigin(link) link = ' + link);

		if (typeof URL !== 'function') {
			throw new Error('Your browser does not support the URL constructor');
		}

		let url;

		try {
			url = new URL(link);
		}
		catch(error) {
			throw new Error('getUrlOrigin(link) link is not a valid url. link = ' + link);
		}
		finally {
			if (url && !url.origin) {
				throw new Error('getUrlOrigin(link) link when turned into a URL does not have an origin. link = ' + link);
			}

			if (url.origin === 'null') {
				// some urls like about:blank have a 'null' origin
				return null;
			}

			return url.origin;
		}
	}

	async function getIframeContent(node, tagNode, indentLevel) {
		const iframeSrcOrigin = getUrlOrigin(node.src);
		const tagInnerNode = spanNode('tag-inner collapsed ');
		let _resolve;

		function appendIframeOuterHTML(iframeOuterHTML) {
			try {
				window.removeEventListener('message', receiveParseHTMLOutputMessage);
				tagInnerNode.insertAdjacentHTML('beforeend', iframeOuterHTML);
				append(tagNode, tagInnerNode);
				_resolve();
			}
			catch(error) {
				throw error;
			}
		}

		function receiveParseHTMLOutputMessage(event) {
			// security does not matter as much for receiving the messages
			// at worst its the incorrect html

			try {
				if (event.origin !== iframeSrcOrigin) {
					return;
				}

				if (!(event.data && event.data.WEB_INSPECTOR && event.data.WEB_INSPECTOR.parseHTMLOutput)) {
					return;
				}

				appendIframeOuterHTML(event.data.WEB_INSPECTOR.parseHTMLOutput);
			}
			catch(error) {
				throw error;
			}
		}

		return await new Promise((resolve) => {
			_resolve = resolve;

			if (!iframeSrcOrigin) {
				appendIframeOuterHTML(spanNode('userscript-error', 'Error: unable to get iframe content because iframe src has a "null" origin. postMessage does not allow "null" origins to be used to communicate between windows.').outerHTML);
				return;
			}

			window.addEventListener('message', receiveParseHTMLOutputMessage);
			node.contentWindow.postMessage({WEB_INSPECTOR: {parseHTML: indentLevel + 1}}, iframeSrcOrigin);
		});
	}

	function parseHTML_addNewLineSpacing(parent, indentLevel) {
		append(parent, brNode());

		if (indentLevel > 0) {
			append(parent, createIndentSpacer(indentLevel));
		}

		const spacing = createSpacer('  ');

		append(parent, spacing);

		return spacing;
	}

	function parseHTML_parseElementAttributes(tagNode, node) {
		const nodeAttributes = node.attributes;

		if (nodeAttributes.length) {
			const tagAttributesNode = spanNode('tag-attributes');

			for (const attribute of nodeAttributes) {
				append(tagAttributesNode, [htmlSymbol(' '), spanNode('tag-attribute-name', attribute.name), htmlSymbol('='), createTagAttributeValueNode(attribute)]);
			}

			append(tagNode, tagAttributesNode);
		}
	}

	function parseHTML_parseElementCloseTag(indentLevel, line, tagNode, tagName) {
		if (tagNode.querySelectorAll('.tag, .css, .script').length > 0) {
			append(tagNode, brNode());

			if (indentLevel > 0) {
				append(tagNode, createIndentSpacer(indentLevel));
			}

			const spacing = parseHTML_addNewLineSpacing(line, indentLevel);
			const expandCollapseBtn = createExpandCollapseBtn(tagNode.querySelector('.tag-inner'));

			spacing.insertAdjacentElement('afterend', expandCollapseBtn);
			expandCollapseBtn.insertAdjacentHTML('afterend', '<pre class="spacer"> </pre>');
			append(tagNode, spacing);
		}

		append(tagNode, [htmlSymbol('</'), createTagNameNode(tagName), htmlSymbol('>')]);
	}
	
	async function parseHTML(node, parent, indentLevel) {
		// https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType
		// using instanceof doesnt always work

		const isElement = node.nodeType === Node.ELEMENT_NODE;
		const isText = node.nodeType === Node.TEXT_NODE;
		const isComment = node.nodeType === Node.COMMENT_NODE;
		const isDoctype = node.nodeType === Node.DOCUMENT_TYPE_NODE;

		const line = spanNode('html-line');

		if (isElement) {
			const tagNode = spanNode('tag');
			const tagName = node.tagName.toLowerCase();
			const elementIsSelfClosing = elementDoesNotNeedToBeClosed(tagName);
			const style = getComputedStyle(node);

			// FIXME isHidden detection isn't fully correct https://developer.mozilla.org/en-US/docs/Web/CSS/visibility
			const isHidden = style.display === 'none' || style.visibility !== 'visible';

			if (isHidden) {
				tagNode.className += ' hidden-tag';
			}

			append(tagNode, [htmlSymbol('<'), createTagNameNode(tagName)]);
			parseHTML_parseElementAttributes(tagNode, node);
			append(tagNode, htmlSymbol((elementIsSelfClosing ? ' /' : '') + '>'));

			if (tagName === 'iframe') {
				if (node.srcdoc && node.src === 'about:srcdoc') {
					// TODO support doctype declaration
					const tagInnerNode = spanNode('tag-inner collapsed ');
					const tmp = el('html');

					tmp.innerHTML = node.srcdoc;

					for (const child of tmp.childNodes) {
						await parseHTML(child, tagInnerNode, indentLevel + 1).then(() => {}, (error) => {
							debugAlert('in parseHTML iframe.srcdoc .then error');
							throw error;
						});
					}

					append(tagNode, tagInnerNode);
				}
				else if (node.src) {
					await getIframeContent(node, tagNode, indentLevel).then(() => {}, (error) => {
						debugAlert('in parseHTML getIframeContent.then error');
						throw error;
					});
				}
			}
			else if (node.childNodes.length > 0) {
				const tagInnerNode = spanNode('tag-inner');

				if (isHidden || node.childNodes.length > 1) {
					// initialise to collapsed, dont make it collapse again unless done so by user
					tagInnerNode.className += ' collapsed ';
				}

				switch(tagName) {
					case 'style': {
						append(tagInnerNode, parseStyle(node.sheet, 0));
						break;
					}
					case 'script': {
						append(tagInnerNode, parseScript(node));
						break;
					}
					default: {
						for (const child of node.childNodes) {
							await parseHTML(child, tagInnerNode, indentLevel + 1).then(() => {}, (error) => {
								debugAlert('in parseHTML parseHTML.then error');
								throw error;
							});
						}
					}
				}

				append(tagNode, tagInnerNode);
			}

			if (!elementIsSelfClosing) {
				parseHTML_parseElementCloseTag(indentLevel, line, tagNode, tagName);
			}

			append(line, tagNode);
		}
		else if (isText) {
			append(line, createPlainTextNode(node));
		}
		else if (isComment) {
			parseHTML_addNewLineSpacing(line, indentLevel);

			append(line, spanNode('comment', '<!-- ' + node.textContent + '-->'));
		}
		else if (isDoctype) {
			parseHTML_addNewLineSpacing(line, indentLevel);

			append(line, spanNode('document-type', '<!DOCTYPE ' + node.nodeName + '>'));
		}
		else {
			debugAlert('unexpected node');

			console.log('isElement', isElement);
			window._node = node;
			console.error(node);
			throw new Error('parseHTML(node, parent, indentLevel) unexpected node');
		}

		append(parent, line);
	}

	function validateIndentLevel(indentLevel) {
		if (indentLevel === undefined || isNaN(indentLevel)) {
			// any of these + 1 gives NaN
			return true;
		}

		if (typeof indentLevel === 'number' && isFinite(indentLevel) && indentLevel >= 0) {
			return true;
		}

		throw new Error('validateIndentLevel(indentLevel) indentLevel must be a number >= 0, undefined or NaN');
	}

	function cssSymbol(symbol) {
		return spanNode('css-symbol', symbol);
	}

	function atRuleNameNode(name) {
		return spanNode('css-at-rule-name', name);
	}

	function cssSelectorText(selectorText) {
		// parsing selector text is very complex
		// so just leave it as it is for now
		// https://www.npmjs.com/package/css-selector-parser
		// https://github.com/mdevils/css-selector-parser/blob/master/src/parser.ts

		return spanNode('css-full-selector', selectorText);
	}

	function previewCSSColorNode(property, value) {
		if (!property.match(/(^|-)color$/)) {
			// properties with a color as a value are either 'color' or end with '-color'
			return;
		}

		if (property.match(/^-/)) {
			// could be a css varable which might not be a color value
			return;
		}

		if (value.match(/^(-|var\()/i)) {
			// cant easily preview variable colors
			return;
		}

		if (value.match(/^(currentcolor|inherit|initial|revert|revert-layer|unset)$/i)) {
			// cant easily preview global colors
			return;
		}

		// the outline adds contrast 
		// getComputedStyle(preview) gives empty string so use the very new css invert function
		// https://developer.mozilla.org/en-US/docs/Web/CSS/filter-function/invert

		const span = spanNode('css-color-preview-container');
		const preview = spanNode('css-color-preview');
		const previewInner = spanNode();

		preview.style.outlineColor = value;
		previewInner.style.backgroundColor = value;

		append(preview, previewInner);
		append(span, [createSpacer(' '), preview]);

		return span;
	}

	function parseStyle(cssStyleDeclaration, indentLevel) {
		validateIndentLevel(indentLevel);

		// https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model
		// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting/Using_CSS_nesting#nested_declarations_rule
		// https://developer.mozilla.org/en-US/docs/Web/API/CSSRule

		const style = spanNode('css');

		function addNewLineSpacing(parent, indentLevel, spacer) {
			if (!isFinite(indentLevel)) {
				return;
			}

			append(parent, [brNode(), createIndentSpacer(indentLevel), spacer ? spacer : createSpacer('  ')]);
		}

		function parseDeclaration(property, value, indentLevel, isLastDeclaration) {
			validateIndentLevel(indentLevel);

			const decNode = spanNode('css-declaration');
			const propNode = spanNode('css-declaration-property', property);
			const valNode = spanNode('css-declaration-value', value);
			const colorPreviewNode = previewCSSColorNode(property, value);

			addNewLineSpacing(decNode, indentLevel);

			append(decNode, [propNode, cssSymbol(': ')]);

			if (colorPreviewNode) {
				append(valNode, colorPreviewNode);
			}

			append(decNode, [valNode, cssSymbol(';')]);

			if (!isFinite(indentLevel) && !isLastDeclaration) {
				append(decNode, cssSymbol(' '));
			}

			return decNode;
		}

		function parseRuleCSSRules(rule, indentLevel) {
			if (!rule.cssRules.length) {
				return textNode('');
			}

			const ruleRulesNode = spanNode();

			for (const ruleRule of rule.cssRules) {
				parseRule(ruleRulesNode, ruleRule, indentLevel + 1);
			}

			return ruleRulesNode;
		}

		function parseRule(parentElement, rule, indentLevel) {
			validateIndentLevel(indentLevel);

			const ruleNode = spanNode();
			const braceLeadingNode = spanNode('css-brace-leading');
			const braceContentNode = spanNode('css-brace-content');
			const spacer = createSpacer('  ');

			function insertExpandCollapseBtn() {
				spacer.insertAdjacentElement('beforebegin', createExpandCollapseBtn(braceContentNode));
				spacer.innerHTML = ' ';
			}

			addNewLineSpacing(ruleNode, indentLevel, spacer);

			switch (rule.constructor.name) {
				case 'CSSStyleRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'style-rule';

					append(braceLeadingNode, cssSelectorText(rule.selectorText));
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, [parseRuleCSSRules(rule, indentLevel), parseStyle(rule.style, indentLevel)]);
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSImportRule': {
					ruleNode.className = 'import-rule';

					const url = spanNode();
					const layer = spanNode(0, rule.layerName === null ? '' : (' ' + (rule.layerName ? `layer(${rule.layerName})` : rule.layerName)));
					const supports = spanNode(0, rule.supportsText === null ? '' : ` supports(${rule.supportsText})`);

					append(url, [textNode('url("'), createLink(rule.styleSheet.href, rule.href), textNode('")')]);
					append(ruleNode, [atRuleNameNode('@import '), url, layer, supports, spanNode(0, rule.media.mediaText)]);

					break;
				}
				case 'CSSMediaRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'media-rule';

					append(braceLeadingNode, [atRuleNameNode('@media '), textNode(rule.conditionText)]);
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, parseRuleCSSRules(rule, indentLevel));
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSFontFaceRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'font-face-rule';

					append(braceLeadingNode, atRuleNameNode('@font-face'));
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, parseStyle(rule.style, indentLevel + 1));
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSPageRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'page-rule';

					append(braceLeadingNode, atRuleNameNode('@page'));

					if (rule.selectorText) {
						append(braceLeadingNode, [cssSymbol(' '), cssSelectorText(rule.selectorText)]);
					}

					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, [parseRuleCSSRules(rule, indentLevel), parseStyle(rule.style, indentLevel + 1)]);
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSNamespaceRule': {
					ruleNode.className = 'namespace-rule';

					append(ruleNode, atRuleNameNode('@namespace '));

					if (rule.prefix) {
						append(ruleNode, rule.prefix + ' ');
					}

					append(rule, [textNode('url("'), createLink(rule.namespaceURI, rule.namespaceURI), textNode('")')]);

					break;
				}
				case 'CSSKeyframesRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'keyframes-rule';

					append(braceLeadingNode, [atRuleNameNode('@keyframes '), textNode(rule.name)]);
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, parseRuleCSSRules(rule, indentLevel + 1));
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSKeyframeRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'keyframe-rule';

					append(braceLeadingNode, textNode(rule.keyText));
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, parseStyle(rule.style, indentLevel + 1));
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSCounterStyleRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'counter-style-rule';

					append(braceLeadingNode, [atRuleNameNode('@counter-style '), textNode(rule.name)]);
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					[
						['system', rule.system],
						['symbols', rule.symbols],
						['additiveSymbols', rule.additiveSymbols],
						['negative', rule.negative],
						['prefix', rule.prefix],
						['suffix', rule.suffix],
						['range', rule.range],
						['pad', rule.pad],
						['speak-as', rule.speakAs],
						['fallback', rule.fallback]
					].forEach((declaration) => {
						if (declaration[1]) {
							append(braceContentNode, parseDeclaration(declaration[0], declaration[1], indentLevel + 1));
						}
					});
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSSupportsRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'supports-rule';

					append(braceLeadingNode, [atRuleNameNode('@supports '), textNode(rule.conditionText)]);
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, parseRuleCSSRules(rule, indentLevel + 1));
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSFontFeatureValuesRule': {
					ruleNode.className = 'font-feature-values-rule';

					// TODO test this in a browser that supports CSSFontFeatureValuesRule
					// not supported in librewolf 133.0-1 on linux

					// https://developer.mozilla.org/en-US/docs/Web/API/CSSFontFeatureValuesRule
					// https://developer.mozilla.org/en-US/docs/Web/CSS/@font-feature-values
					// https://developer.mozilla.org/en-US/docs/Web/CSS/@font-feature-values/font-display

					console.warn(rule);
					console.warn('unclear how to parse CSSFontFeatureValuesRule, using unformatted');

					append(ruleNode, textNode(rule.cssText));

					// ruleNode.appendChild(textNode('@font-feature-values '));
					// ruleNode.appendChild(rule.fontFamily);
					// ruleNode.appendChild(cssSymbol(' {'));

					// who knows

					// ruleNode.appendChild(cssSymbol('}'));

					break;
				}
				case 'CSSFontPaletteValuesRule': {
					ruleNode.className = 'font-palette-values-rule';

					// TODO test this in a browser that supports CSSFontPaletteValuesRule
					// not supported in librewolf 133.0-1 on linux

					console.warn(rule);
					console.warn('unclear how to parse CSSFontFeatureValuesRule, using unformatted');

					append(ruleNode, textNode(rule.cssText));

					// ruleNode.appendChild(textNode('@font-palette-values '));
					// ruleNode.appendChild(rule.name);
					// ruleNode.appendChild(cssSymbol(' {'));

					// ruleNode.appendChild(parseDeclaration('font-family', rule.fontFamily, indentLevel + 1));
					// ruleNode.appendChild(parseDeclaration('base-palette', rule.basePalette, indentLevel + 1));

					// no idea how this will behave
					// https://developer.mozilla.org/en-US/docs/Web/API/CSSFontPaletteValuesRule
					// https://developer.mozilla.org/en-US/docs/Web/API/CSSFontPaletteValuesRule/overrideColors
					// may need special treatment for formatting
					// ruleNode.appendChild(parseDeclaration('override-colors', rule.overrideColors, indentLevel + 1, true))

					// ruleNode.appendChild(cssSymbol('}'));

					break;
				}
				case 'CSSLayerBlockRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'layer-block-rule';

					append(braceLeadingNode, [atRuleNameNode('@layer '), textNode(rule.name)]);
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, parseRuleCSSRules(rule, indentLevel + 1));
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				case 'CSSLayerBlockRule': {
					ruleNode.className = 'layer-block-rule';

					append(ruleNode, atRuleNameNode('@layer '));

					rule.nameList.forEach((name, i) => {
						append(ruleNode, textNode(name));

						if (i + 1 < this.length) {
							append(ruleNode, cssSymbol(', '));
						}
					});

					append(ruleNode, cssSymbol(';'));

					break;
				}
				case 'CSSPropertyRule': {
					insertExpandCollapseBtn();

					ruleNode.className = 'property-rule';

					append(braceLeadingNode, [atRuleNameNode('@property '), textNode(rule.name)]);
					append(ruleNode, [braceLeadingNode, cssSymbol(' {')]);
					append(braceContentNode, [parseDeclaration('syntax', rule.syntax, indentLevel + 1), parseDeclaration('inherits', '' + rule.inherits, indentLevel + 1), parseDeclaration('initial-value', rule.initialValue, indentLevel + 1, true)]);
					addNewLineSpacing(braceContentNode, indentLevel);
					append(ruleNode, [braceContentNode, cssSymbol('}')]);

					break;
				}
				default: {
					ruleNode.className = 'unexpected-rule';

					// should not need to explicitly handle CSSGroupingRule because other rule types inherit from it
					// should not need to explicitly handle CSSNestedDeclarations because other rules pass a cssStyleDeclaration

					console.warn(rule);
					console.warn('unexpected css rule type, using unformatted');

					append(ruleNode, textNode(rule.cssText));

					break;
				}
			}

			parentElement.appendChild(ruleNode);
		}

		if (cssStyleDeclaration instanceof CSSStyleSheet) {
			const ruleRulesNode = spanNode();

			for (const rule of cssStyleDeclaration.cssRules) {
				parseRule(ruleRulesNode, rule, indentLevel);
			}

			append(style, ruleRulesNode);
		}
		else {
			// previously use a for of before to filter out all style declarations
			// need to know if there is a next declaration for formatting purposes
			// element.style has numbered indexes for styles actually declared on the element

			for (let i = 0; ; ) {
				const prop = cssStyleDeclaration[i];

				if (!prop) {
					break;
				}

				i++;

				const hasNext = !!cssStyleDeclaration[i];

				append(style, parseDeclaration(prop, cssStyleDeclaration.getPropertyValue(prop), indentLevel + 1, !hasNext));
			}
		}

		return style;
	}

	function parseScript(node) {
		// TODO formatting, highlighting

		return spanNode('script', node.textContent.trim());
	}

	function hideCollapsed() {
		let a = '.collapsed';
		let b = a;

		['br', '.spacer', '.spacer'].forEach((c) => {
			b += ' + ' + c;
			a += ', ' + b;
		});

		return a + ' {\n\tdisplay: none;\n}';
	}

	function color(selector, color) {
		return `${selector} {\n\tcolor: ${color};\n}`;
	}

	function getStyle() {
		return `body {
	margin: 1em;
	padding: 0;
	display: block;
	font-family: monospace;
	font-size: 1em;
	line-height: 1.2em;
	tab-size: 2;
	color: #cbcbc7;
	background: #232327;
	word-break: break-word;
}

.spacer {
	margin: 0;
	padding: 0;
	border: 0;
	outline: 0;
	display: inline-block;
}

${hideCollapsed()}

.expand-collapse-button {
	margin: 0;
	padding: 0;
	border: 0;
	width: fit-content;
	cursor: pointer;
	background: inherit;
	color: #88888a;
}

.expand-collapse-button:has(+ .spacer + .tag > .collapsed), .expand-collapse-button:has(+ .spacer + .css-brace-leading + .css-symbol + .css-brace-content.collapsed) {
	rotate: 270deg;
}

${color('.userscript-error', '#ff0000')}

.document-type {
	font-style: italic;
}

${color('.html-symbol', '#7c7c7e')}

${color('.document-type', '#72baf9')}

${color('.comment', '#90EE90')}

${color('.tag-name', '#72baf9')}

${color('.tag-attribute-name', '#fb7be5')}

${color('.tag-attribute-value', '#9b79d4')}

.tag-attribute-value a {
	color: inherit;
	text-decoration: underline;
}

${color('.tag.hidden-tag .html-symbol', '#6e6e6e')}

${color('.tag.hidden-tag .tag-name', '#929294')}

${color('.tag.hidden-tag .tag-attribute-name', '#676768')}

${color('.tag.hidden-tag .tag-attribute-value', '#939394')}

${color('.css-symbol', '#7c7c7e')}

${color('.css-at-rule-name', '#72baf9')}

${color('.css-declaration-property', '#80d36f')}

${color('.css-declaration-value', '#fb7be5')}

.css-color-preview {
	display: inline-block;
	width: 1em;
	height: 1em;
	outline-width: 2px;
	outline-style: solid;
	filter: invert(100%);
}`;
	}

	async function main(outputWindow) {
		const meta1 = el('meta');
		const meta2 = el('meta');
		const title = el('title');
		const style = el('style');
		const output = el('span');

		meta1.setAttribute('charset', 'utf-8');

		meta2.setAttribute('name', 'viewport');
		meta2.setAttribute('content', 'width=device-width, initial-scale=1, minimum-scale=1');

		title.innerHTML = 'Web Inspector - ' + document.title;

		style.innerHTML = getStyle();

		for (const node of document.childNodes) {
			await parseHTML(node, output, 0).then(() => {}, (error) => {
				debugAlert('in main parseHTML.then error');
				throw error;
			});
		}

		if (output.firstElementChild.tagName === 'BR') {
			// remove unnecessary spacing at top
			output.firstElementChild.remove();
		}

		setupExpandCollapseBtns(output);

		outputWindow.document.write('<!DOCTYPE html><html><head></head><body></body></html>');

		append(outputWindow.document.head, meta1);
		append(outputWindow.document.head, meta2);
		append(outputWindow.document.head, title);
		append(outputWindow.document.head, style);
		append(outputWindow.document.body, output);
	}

	async function receiveParseHTMLMessage(event) {
		try {
			// unable to access iframe content, so wait for a message from the top window
			// then pass the output element

			if (event.source !== top) {
				// this check should reduce security issues
				return;
			}

			if (!(event.data && event.data.WEB_INSPECTOR && event.data.WEB_INSPECTOR.parseHTML)) {
				// make sure the instruction exists
				return;
			}

			const indentLevel = parseInt(event.data.WEB_INSPECTOR.parseHTML);

			if (!(isFinite(indentLevel) && indentLevel > 0)) {
				return;
			}

			window.removeEventListener('message', receiveParseHTMLMessage);

			const output = spanNode();

			for (const node of document.childNodes) {
				await parseHTML(node, output, indentLevel).then(() => {}, (error) => {
					debugAlert('in receiveParseHTMLMessage parseHTML error');
					throw error;
				});
			}

			event.source.postMessage({WEB_INSPECTOR: {parseHTMLOutput: output.outerHTML}}, event.origin);
		}
		catch(err) {
			throw err;
		}
	}

	function isDefined(variable) {
		return typeof variable !== 'undefined';
	}

	function extractErrorInfo(error) {
		let str = error.name + ': ' + error.message;

		if (isDefined(error.fileName) && isDefined(error.lineNumber) && isDefined(error.columnNumber)) {
			str += ' in ' + error.fileName + ' ' + error.lineNumber + ':' + error.columnNumber;
		}

		if (isDefined(error.stack)) {
			str += '\n' + error.stack;
		}

		return str;
	}

	function errorDetected(error) {
		return prompt('Error while using Web Inspector:', extractErrorInfo(error));
	}

	if (self !== top) {
		window.addEventListener('message', receiveParseHTMLMessage);

		return;
	}

	window.WEB_INSPECTOR = function() {
		// try to open in a new window
		// if popups are blocked, replace the current webpage with the web inspector

		const outputWindow = open('about:blank') || window;

		main(outputWindow).then(() => {}, (error) => {
			debugAlert('in window.WEB_INSPECTOR main error');
			errorDetected(error);
		});

		outputWindow.onload = function() {
			main(outputWindow).then(() => {}, (error) => {
				debugAlert('in window.WEB_INSPECTOR outputWindow main error');
				errorDetected(error);
			});
		};
	};
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址