Files
harbor-net/test/web/style-source.js
T
dokril 1ae23d848b
Build and Deploy Gateway / build-and-push (push) Successful in 1m22s
Build and Deploy Gateway / deploy (push) Successful in 16s
Migrate Harbor state and traffic history to SQLite
2026-09-10 19:21:21 +03:00

1016 lines
44 KiB
JavaScript

import fs from 'node:fs';
import crypto from 'node:crypto';
import path from 'node:path';
import { parse as parseJavaScript } from '@babel/parser';
import { compare, selectorSpecificity } from '@csstools/selector-specificity';
import postcss from 'postcss';
import selectorParser from 'postcss-selector-parser';
export const STYLE_IMPORT_PATTERN = /^@import ['"](\.\/[^'"]+\.css)['"];$/gm;
export function styleLeafPaths(root) {
const stylesRoot = path.join(root, 'src/web/styles');
const index = fs.readFileSync(path.join(stylesRoot, 'index.css'), 'utf8');
return Array.from(index.matchAll(STYLE_IMPORT_PATTERN), ([, relativePath]) => (
path.resolve(stylesRoot, relativePath)
));
}
export function readStyleSource(root) {
return styleLeafPaths(root).map((file) => fs.readFileSync(file, 'utf8')).join('');
}
function sourceFiles(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const target = path.join(directory, entry.name);
if (entry.isDirectory()) return sourceFiles(target);
return /\.[jt]sx?$/.test(entry.name) ? [target] : [];
});
}
function classTokens(value) {
return value.split(/\s+/).filter((token) => /^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/.test(token));
}
function mergeClassInfo(...values) {
return {
classes: [...new Set(values.flatMap((value) => value.classes))].sort(),
prefixes: [...new Set(values.flatMap((value) => value.prefixes))].sort(),
unknown: values.some((value) => value.unknown),
};
}
function staticStringValues(node, environment, seen = new Set()) {
if (!node) return [''];
if (Array.isArray(node)) return null;
if (node.type === 'StringLiteral') return [node.value];
if (node.type === 'NumericLiteral') return [String(node.value)];
if (node.type === 'NullLiteral' || node.type === 'BooleanLiteral') return [''];
if (['TSAsExpression', 'TSSatisfiesExpression', 'TypeCastExpression', 'ParenthesizedExpression'].includes(node.type)) {
return staticStringValues(node.expression, environment, seen);
}
if (node.type === 'Identifier') {
if (node.name === 'undefined') return [''];
const binding = environment.get(node.name);
if (!binding || seen.has(binding)) return null;
if (binding.values) return binding.values;
return staticStringValues(binding.node, binding.environment, new Set([...seen, binding]));
}
if (node.type === 'MemberExpression' && !node.computed
&& node.object?.type === 'Identifier' && node.property?.type === 'Identifier') {
return environment.get(`${node.object.name}.${node.property.name}`)?.values || null;
}
if (node.type === 'CallExpression' && node.arguments.length === 0
&& node.callee?.type === 'MemberExpression' && !node.callee.computed
&& node.callee.property?.name === 'toLowerCase') {
const values = staticStringValues(node.callee.object, environment, seen);
return values?.map((value) => value.toLowerCase()) || null;
}
if (node.type === 'ConditionalExpression') {
const consequent = staticStringValues(node.consequent, environment, seen);
const alternate = staticStringValues(node.alternate, environment, seen);
return consequent && alternate ? [...new Set([...consequent, ...alternate])] : null;
}
if (node.type === 'LogicalExpression' && node.operator === '&&') {
const right = staticStringValues(node.right, environment, seen);
return right ? [...new Set(['', ...right])] : null;
}
if (node.type === 'BinaryExpression' && node.operator === '+') {
const left = staticStringValues(node.left, environment, seen);
const right = staticStringValues(node.right, environment, seen);
if (!left || !right || left.length * right.length > 64) return null;
return left.flatMap((leftValue) => right.map((rightValue) => leftValue + rightValue));
}
if (node.type === 'TemplateLiteral') {
let values = [node.quasis[0].value.cooked || node.quasis[0].value.raw];
for (let index = 0; index < node.expressions.length; index += 1) {
const expressions = staticStringValues(node.expressions[index], environment, seen);
if (!expressions || values.length * expressions.length > 64) return null;
const suffix = node.quasis[index + 1].value.cooked || node.quasis[index + 1].value.raw;
values = values.flatMap((value) => expressions.map((expression) => value + expression + suffix));
}
return values;
}
return null;
}
function classExpressionInfo(node, environment = new Map()) {
if (!node) return { classes: [], prefixes: [], unknown: false };
if (node.type === 'JSXExpressionContainer') return classExpressionInfo(node.expression, environment);
const staticValues = staticStringValues(node, environment);
if (staticValues) {
return {
classes: [...new Set(staticValues.flatMap(classTokens))],
prefixes: [],
unknown: false,
};
}
if (node.type === 'TemplateLiteral') {
const classes = node.quasis.flatMap((quasi) => classTokens(quasi.value.cooked || quasi.value.raw));
const prefixes = [];
for (let index = 0; index < node.expressions.length; index += 1) {
const before = node.quasis[index].value.cooked || node.quasis[index].value.raw;
const prefix = /(?:^|\s)((?:app|client|harbor|has|is)-[a-z0-9-]*)$/.exec(before)?.[1];
const expression = classExpressionInfo(node.expressions[index], environment);
if (prefix) prefixes.push(prefix);
classes.push(...expression.classes);
prefixes.push(...expression.prefixes);
}
return { classes, prefixes, unknown: true };
}
return { classes: [], prefixes: [], unknown: true };
}
function jsxName(node) {
if (node.type === 'JSXIdentifier') return node.name;
if (node.type === 'JSXMemberExpression') return `${jsxName(node.object)}.${jsxName(node.property)}`;
return null;
}
function elementClassInfo(openingElement, environment, imperativeClasses = []) {
let info = { classes: [], prefixes: [], unknown: false };
for (const attribute of openingElement.attributes) {
if (attribute.type === 'JSXSpreadAttribute') {
info.unknown = true;
continue;
}
if (attribute.name?.name !== 'className') continue;
info = mergeClassInfo(info, classExpressionInfo(attribute.value, environment));
}
info.classes.push(...imperativeClasses);
info.classes = [...new Set(info.classes)].sort();
return info;
}
function parseWitnessFile(file, source) {
try {
return parseJavaScript(source, {
plugins: [/\.tsx?$/.test(file) ? 'typescript' : 'jsx', /\.[jt]sx$/.test(file) ? 'jsx' : null].filter(Boolean),
sourceFilename: file,
sourceType: 'module',
});
} catch (error) {
throw new TypeError(`Unable to parse JSX witness source: ${file}`, { cause: error });
}
}
function componentDefinitions(files) {
const definitions = new Map();
const definitionNodes = new Set();
const register = (name, node, file) => {
if (!name || !/^[A-Z]/.test(name)) return;
if (definitions.has(name)) throw new TypeError(`Ambiguous JSX component witness owner: ${name}`);
definitions.set(name, { file, node });
definitionNodes.add(node);
};
const collect = (node, file) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) collect(child, file);
return;
}
if (node.type === 'FunctionDeclaration') register(node.id?.name, node, file);
if (node.type === 'VariableDeclarator'
&& ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.init?.type)) {
register(node.id?.name, node.init, file);
}
for (const [key, child] of Object.entries(node)) {
if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue;
collect(child, file);
}
};
for (const file of files) collect(file.parsed.program, file.file);
return { definitions, definitionNodes };
}
function imperativeClassesByTarget(file, source) {
const mutations = [...source.matchAll(/\.classList\.(?:add|remove|toggle|replace)\(([^)]*)\)/g)];
if (!mutations.length) return new Map();
const classes = mutations.flatMap(([, args]) => [...args.matchAll(/['"]([^'"]+)['"]/g)].map((match) => match[1])).sort();
const diagnosticsFile = file.endsWith('/features/diagnostics/ConnectivityDiagnosticsPanel.tsx');
if (!diagnosticsFile || mutations.length !== 3
|| JSON.stringify(classes) !== JSON.stringify(['is-moving', 'is-moving', 'is-visible', 'is-visible'])) {
throw new TypeError(`Unsupported imperative class mutation in JSX witness source: ${file}`);
}
return new Map([['client-diagnostics-active-marker', ['is-moving', 'is-visible']]]);
}
function literalUnion(source, pattern, label) {
const match = pattern.exec(source);
if (!match) throw new TypeError(`Missing dynamic class domain: ${label}`);
return [...match[1].matchAll(/'([^']+)'/g)].map((value) => value[1]);
}
function dynamicClassBindings(files) {
const bySuffix = (suffix) => files.find(({ file }) => file.endsWith(suffix))?.source;
const bindings = new Map();
const add = (suffix, name, values) => {
if (!values.length) throw new TypeError(`Empty dynamic class domain: ${name}`);
if (!bindings.has(suffix)) bindings.set(suffix, new Map());
bindings.get(suffix).set(name, { values: [...new Set(values)].sort() });
};
const diagnosticsSuffix = '/features/diagnostics/ConnectivityDiagnosticsPanel.tsx';
const diagnostics = bySuffix(diagnosticsSuffix);
if (diagnostics) {
const diagnosticStatuses = [...diagnostics.matchAll(/\['(is-[a-z-]+)'/g)].map((match) => match[1]);
add(diagnosticsSuffix, 'className', diagnosticStatuses);
}
const devicesSuffix = '/features/devices/DevicesPanel.tsx';
const devices = bySuffix(devicesSuffix);
const deviceContract = bySuffix('/features/devices/deviceSnapshot.ts');
if (devices || deviceContract) {
if (!devices || !deviceContract) throw new TypeError('Incomplete dynamic class domain: devices');
add(devicesSuffix, 'device.status', literalUnion(deviceContract, /type DeviceStatus = ([^;]+);/, 'DeviceStatus'));
add(devicesSuffix, 'displayPolicy', literalUnion(deviceContract, /export type DevicePolicy = ([^;]+);/, 'DevicePolicy'));
}
const routingSuffix = '/features/routing/RoutingFeature.tsx';
const routing = bySuffix(routingSuffix);
if (routing) {
const routingStatusBody = /function localRuleStatus[\s\S]+?\n}\n\nfunction routingSaveState/.exec(routing)?.[0];
if (!routingStatusBody) throw new TypeError('Missing dynamic class domain: localRuleStatus');
add(routingSuffix, 'status', [...routingStatusBody.matchAll(/return \['([a-z-]+)'/g)].map((match) => match[1]));
}
const connectionSuffix = '/features/connection/ConnectionPanel.tsx';
const connection = bySuffix(connectionSuffix);
if (connection) {
const clockUnits = [...connection.matchAll(/\['(hours|minutes|seconds)', duration\./g)].map((match) => match[1]);
const directNames = [...connection.matchAll(/<DurationPart name="([a-z-]+)"/g)].map((match) => match[1]);
add(connectionSuffix, 'name', [
...directNames,
...clockUnits.flatMap((unit) => [`${unit}-value`, `${unit}-label`]),
]);
}
const pageSuffix = '/components/ClientOverviewPage.tsx';
const page = bySuffix(pageSuffix);
if (page) {
const product = /const product = isGateway \? '([^']+)' : '([^']+)';/.exec(page);
if (!product) throw new TypeError('Missing dynamic class domain: product');
add(pageSuffix, 'product', product.slice(1));
}
return new Map(files.map(({ file }) => {
const entry = [...bindings].find(([suffix]) => file.endsWith(suffix));
return [file, entry?.[1] || new Map()];
}));
}
function bindComponentProps(definition, element, callerEnvironment, callerFile, dynamicBindings) {
const supplied = new Map();
for (const attribute of element.openingElement.attributes) {
if (attribute.type === 'JSXSpreadAttribute') {
throw new TypeError(`Unsupported spread props in JSX witness call: ${jsxName(element.openingElement.name)}`);
}
const name = attribute.name?.name;
if (!name) continue;
const node = attribute.value?.type === 'JSXExpressionContainer'
? attribute.value.expression
: attribute.value || { type: 'BooleanLiteral', value: true };
supplied.set(name, node.type === 'Identifier' && callerEnvironment.get(node.name)
? callerEnvironment.get(node.name)
: {
callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(node.type) && producesJsx(node),
node,
environment: callerEnvironment,
file: callerFile,
});
}
supplied.set('children', { node: element.children, environment: callerEnvironment, file: callerFile });
const environment = new Map(dynamicBindings.get(definition.file));
const parameter = definition.node.params?.[0];
if (parameter?.type === 'ObjectPattern') {
for (const property of parameter.properties) {
if (property.type === 'RestElement') continue;
const sourceName = property.key?.name;
const target = property.value?.type === 'AssignmentPattern' ? property.value.left : property.value;
const localName = target?.name;
const fallback = property.value?.type === 'AssignmentPattern'
? { node: property.value.right, environment: callerEnvironment, file: definition.file }
: null;
if (sourceName && localName && (supplied.get(sourceName) || fallback)) {
environment.set(localName, supplied.get(sourceName) || fallback);
}
}
}
return environment;
}
function producesJsx(node) {
if (!node || typeof node !== 'object') return false;
if (Array.isArray(node)) return node.some(producesJsx);
if (node.type === 'JSXElement' || node.type === 'JSXFragment') return true;
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'createPortal') return true;
return Object.entries(node).some(([key, child]) => (
!['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key) && producesJsx(child)
));
}
function withLocalJsxBindings(node, environment, definitionNodes, file) {
const scoped = new Map(environment);
const collect = (value) => {
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
for (const child of value) collect(child);
return;
}
if (definitionNodes.has(value)) return;
if (value.type === 'VariableDeclarator' && value.id?.type === 'Identifier' && producesJsx(value.init)) {
scoped.set(value.id.name, {
callable: ['ArrowFunctionExpression', 'FunctionExpression'].includes(value.init?.type),
declaration: value,
environment: scoped,
file,
node: value.init,
});
return;
}
for (const [key, child] of Object.entries(value)) {
if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue;
collect(child);
}
};
collect(node);
return scoped;
}
function bindCallParameters(callable, argumentsList, callerEnvironment, callerFile) {
const environment = new Map(callable.environment);
for (let index = 0; index < callable.node.params.length; index += 1) {
const parameter = callable.node.params[index];
if (parameter?.type === 'Identifier' && argumentsList[index]) {
environment.set(parameter.name, { node: argumentsList[index], environment: callerEnvironment, file: callerFile });
}
}
return environment;
}
export function createStyleWitnesses(sources, { entry = 'App', recursionLimits = {} } = {}) {
const files = sources.map(({ file, source }) => ({ file, source, parsed: parseWitnessFile(file, source) }));
const { definitions, definitionNodes } = componentDefinitions(files);
const entryDefinition = definitions.get(entry);
if (!entryDefinition) throw new TypeError(`Missing JSX witness entry component: ${entry}`);
const imperativeByFile = new Map(files.map((file) => [
file.file,
imperativeClassesByTarget(file.file, file.source),
]));
const dynamicByFile = dynamicClassBindings(files);
const witnesses = [];
const expand = (definition, ancestors, ancestorUnknown, environment, stack) => {
if (stack.includes(definition)) {
const limit = recursionLimits[definition.node.id?.name];
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`Recursive JSX witness component: ${definition.node.id?.name || definition.file}`);
if (stack.filter((item) => item === definition).length >= limit) return;
}
const nextStack = [...stack, definition];
const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes, definition.file);
const visit = (
node,
currentAncestors = ancestors,
currentUnknown = ancestorUnknown,
currentEnvironment = entryEnvironment,
currentFile = definition.file,
) => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
for (const child of node) visit(child, currentAncestors, currentUnknown, currentEnvironment, currentFile);
return;
}
if (definitionNodes.has(node)) return;
if (node.type === 'VariableDeclarator' && node.id?.type === 'Identifier') {
const binding = currentEnvironment.get(node.id.name);
if (binding?.declaration === node) return;
}
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier') {
const callable = currentEnvironment.get(node.callee.name);
if (callable?.callable) {
const callEnvironment = withLocalJsxBindings(
callable.node.body,
bindCallParameters(callable, node.arguments, currentEnvironment, currentFile),
definitionNodes,
callable.file || currentFile,
);
visit(callable.node.body, currentAncestors, currentUnknown, callEnvironment, callable.file || currentFile);
return;
}
}
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'createPortal') {
const target = node.arguments[1];
const documentBody = target?.type === 'MemberExpression' && !target.computed
&& target.object?.name === 'document' && target.property?.name === 'body';
const appOrBody = target?.type === 'LogicalExpression' && target.operator === '||'
&& target.left?.type === 'CallExpression'
&& target.left.callee?.type === 'MemberExpression'
&& target.left.callee.object?.name === 'document'
&& target.left.callee.property?.name === 'querySelector'
&& target.left.arguments?.[0]?.value === '.app.client-app'
&& target.right?.type === 'MemberExpression'
&& target.right.object?.name === 'document'
&& target.right.property?.name === 'body';
if (!documentBody && !appOrBody) {
throw new TypeError(`Unsupported JSX portal target in ${definition.file}`);
}
visit(node.arguments[0], [], false, currentEnvironment, currentFile);
if (appOrBody) {
visit(node.arguments[0], [{ classes: ['app', 'client-app', 'is-gateway-app'], prefixes: [], unknown: false }], false, currentEnvironment, currentFile);
}
return;
}
if (node.type === 'JSXExpressionContainer' && node.expression?.type === 'Identifier') {
const binding = currentEnvironment.get(node.expression.name);
if (binding) {
visit(binding.node, currentAncestors, currentUnknown, binding.environment, binding.file || currentFile);
return;
}
}
if (node.type === 'JSXElement') {
const name = jsxName(node.openingElement.name);
const intrinsic = Boolean(name && /^[a-z]/.test(name));
if (intrinsic) {
const initialInfo = elementClassInfo(node.openingElement, currentEnvironment);
const additions = [...imperativeByFile.get(currentFile).entries()]
.filter(([target]) => initialInfo.classes.includes(target))
.flatMap(([, classes]) => classes);
const info = elementClassInfo(node.openingElement, currentEnvironment, additions);
witnesses.push({
ancestorClasses: [...new Set(currentAncestors.flatMap((ancestor) => ancestor.classes))].sort(),
ancestorPrefixes: [...new Set(currentAncestors.flatMap((ancestor) => ancestor.prefixes))].sort(),
ancestorUnknown: currentUnknown || currentAncestors.some((ancestor) => ancestor.unknown),
classes: [...new Set(info.classes)].sort(),
prefixes: [...new Set(info.prefixes)].sort(),
tag: name,
unknown: info.unknown,
});
visit(node.children, [...currentAncestors, info], currentUnknown, currentEnvironment, currentFile);
return;
}
if (TRANSPARENT_JSX_COMPONENTS.has(name)) {
visit(node.children, currentAncestors, currentUnknown, currentEnvironment, currentFile);
return;
}
const target = definitions.get(name);
if (!target) throw new TypeError(`Unresolved JSX witness component: ${name} in ${definition.file}`);
const targetEnvironment = bindComponentProps(target, node, currentEnvironment, currentFile, dynamicByFile);
expand(target, currentAncestors, currentUnknown, targetEnvironment, nextStack);
return;
}
if (node.type === 'JSXFragment') {
visit(node.children, currentAncestors, currentUnknown, currentEnvironment, currentFile);
return;
}
for (const [key, child] of Object.entries(node)) {
if (['loc', 'start', 'end', 'extra', 'comments', 'tokens', 'errors'].includes(key)) continue;
if (child && typeof child === 'object') visit(child, currentAncestors, currentUnknown, currentEnvironment, currentFile);
}
};
visit(definition.node.body, ancestors, ancestorUnknown, entryEnvironment);
};
expand(entryDefinition, [], false, new Map(dynamicByFile.get(entryDefinition.file)), []);
return sortLedger(witnesses);
}
export function readStyleWitnesses(root) {
const files = sourceFiles(path.join(root, 'src/web')).map((file) => ({
file,
source: fs.readFileSync(file, 'utf8'),
}));
// History has exactly service/domain/hostname/IP levels, not arbitrary JSX recursion.
return createStyleWitnesses(files, { recursionLimits: { HistoryRows: 4, HistoryBranch: 3 } });
}
const OBSERVED_PROPERTIES = new Set(`
--client-accent --client-accent-soft --client-bg --client-border --client-control
--client-delete-strike-y --client-device-chart-height --client-device-copy-color --client-rule-drag-y
--client-muted --client-panel --client-power-top --client-text --client-work-height
--harbor-connect --harbor-gateway --harbor-word -webkit-appearance -webkit-backdrop-filter
-webkit-text-fill-color align-content align-items align-self animation
animation-duration animation-name animation-timing-function appearance backdrop-filter
background background-color border border-bottom border-bottom-color border-collapse
border-radius border-top bottom box-shadow box-sizing caret-color clip-path color
color-scheme column-gap content cursor display fill filter flex flex-basis flex-direction
flex-wrap font font-family font-size font-style font-variant-numeric font-weight gap
grid-area grid-column grid-row grid-template-columns grid-template-rows height inset
isolation justify-content justify-items justify-self left letter-spacing line-height
margin margin-bottom margin-inline margin-left margin-right margin-top max-height max-width
min-height min-width mix-blend-mode opacity order outline outline-offset overflow
overflow-wrap overflow-x overflow-y overscroll-behavior padding padding-block
padding-bottom padding-inline padding-left padding-right padding-top place-content
place-items pointer-events position right row-gap scrollbar-width stroke stroke-dasharray
stroke-dashoffset stroke-linecap stroke-linejoin stroke-width table-layout text-align
text-decoration text-overflow text-shadow text-transform text-underline-offset top
touch-action transform transform-box transform-origin transition transition-delay user-select
vector-effect vertical-align visibility white-space width will-change z-index
`.trim().split(/\s+/));
const PROPERTY_FAMILIES = new Map([
[['animation', 'animation-duration', 'animation-name', 'animation-timing-function'], 'animation'],
[['background', 'background-color'], 'background'],
[['border', 'border-bottom', 'border-bottom-color', 'border-top'], 'border-edge'],
[['column-gap', 'gap', 'row-gap'], 'gap'],
[['flex', 'flex-basis'], 'flex-size'],
[['font', 'font-family', 'font-size', 'font-style', 'font-variant-numeric', 'font-weight', 'line-height'], 'font'],
[['grid-area', 'grid-column', 'grid-row'], 'grid-placement'],
[['inset', 'bottom', 'left', 'right', 'top'], 'inset'],
[['margin', 'margin-bottom', 'margin-inline', 'margin-left', 'margin-right', 'margin-top'], 'margin'],
[['overflow', 'overflow-x', 'overflow-y'], 'overflow'],
[['padding', 'padding-block', 'padding-bottom', 'padding-inline', 'padding-left', 'padding-right', 'padding-top'], 'padding'],
[['place-content', 'align-content', 'justify-content'], 'place-content'],
[['place-items', 'align-items', 'justify-items'], 'place-items'],
[['transition', 'transition-delay'], 'transition'],
].flatMap(([properties, family]) => properties.map((property) => [property, family])));
const SUPPORTED_SELECTOR_NODES = new Set(['attribute', 'class', 'combinator', 'id', 'pseudo', 'selector', 'tag', 'universal']);
const SUPPORTED_COMBINATORS = new Set([' ', '+', '>']);
const SUPPORTED_PSEUDOS = new Set([
':-webkit-autofill', '::-webkit-inner-spin-button', '::-webkit-outer-spin-button', '::-webkit-scrollbar',
'::after', '::before', '::marker', '::placeholder',
'::view-transition-group', '::view-transition-new', '::view-transition-old', ':active', ':disabled',
':first-child', ':focus', ':focus-visible', ':focus-within', ':has', ':hover', ':last-child', ':not',
':nth-child', ':root',
]);
const TRANSPARENT_JSX_COMPONENTS = new Set(['React.Fragment']);
const hash = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex');
function propertyFamily(property) {
if (property.startsWith('--')) return property;
if (!OBSERVED_PROPERTIES.has(property)) {
throw new TypeError(`Unsupported CSS property in cascade proof: ${property}`);
}
return PROPERTY_FAMILIES.get(property) || property;
}
function atRuleContext(node) {
const context = [];
for (let parent = node.parent; parent && parent.type !== 'root'; parent = parent.parent) {
if (parent.type !== 'atrule') continue;
if (parent.name !== 'media' && parent.name !== 'keyframes' && parent.name !== '-webkit-keyframes') {
throw new TypeError(`Unsupported CSS at-rule in cascade proof: @${parent.name}`);
}
context.unshift(`@${parent.name} ${parent.params}`);
}
return context;
}
function keyframeAncestor(node) {
for (let parent = node.parent; parent && parent.type !== 'root'; parent = parent.parent) {
if (parent.type === 'atrule' && (parent.name === 'keyframes' || parent.name === '-webkit-keyframes')) {
return parent;
}
}
return null;
}
function splitVarArguments(value) {
let depth = 0;
let quote = null;
let escaped = false;
for (let index = 0; index < value.length; index += 1) {
const char = value[index];
const next = value[index + 1];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '/' && next === '*') {
const end = value.indexOf('*/', index + 2);
if (end < 0) throw new TypeError('Unterminated CSS value comment');
index = end + 1;
continue;
}
if (char === '(') depth += 1;
else if (char === ')') depth -= 1;
else if (char === ',' && depth === 0) return [value.slice(0, index), value.slice(index + 1)];
}
return [value, null];
}
export function variableReferences(value) {
const references = [];
for (let index = 0; index < value.length;) {
const match = /\bvar\s*\(/gy;
match.lastIndex = index;
const found = match.exec(value);
if (!found) {
index += 1;
continue;
}
const contentStart = match.lastIndex;
let depth = 1;
let quote = null;
let escaped = false;
let cursor = contentStart;
for (; cursor < value.length && depth > 0; cursor += 1) {
const char = value[cursor];
const next = value[cursor + 1];
if (quote) {
if (escaped) escaped = false;
else if (char === '\\') escaped = true;
else if (char === quote) quote = null;
continue;
}
if (char === '"' || char === "'") {
quote = char;
continue;
}
if (char === '/' && next === '*') {
const end = value.indexOf('*/', cursor + 2);
if (end < 0) throw new TypeError('Unterminated CSS value comment');
cursor = end + 1;
continue;
}
if (char === '(') depth += 1;
else if (char === ')') depth -= 1;
}
if (depth !== 0) throw new TypeError(`Unterminated var() reference: ${value}`);
const rawArguments = value.slice(contentStart, cursor - 1);
const [rawName, rawFallback] = splitVarArguments(rawArguments);
const name = rawName.trim();
if (!/^--[a-zA-Z0-9_-]+$/.test(name)) throw new TypeError(`Invalid var() name: ${name}`);
const fallback = rawFallback === null ? null : rawFallback.trim();
references.push({ name, fallback });
if (fallback) references.push(...variableReferences(fallback));
index = cursor;
}
return references;
}
function simpleConstraints(nodes) {
const constraints = { attributes: {}, classes: [], ids: [], negatives: [], tags: [] };
for (const node of nodes) {
if (node.type === 'id') constraints.ids.push(node.value);
else if (node.type === 'class') constraints.classes.push(node.value);
else if (node.type === 'tag' && node.value !== '*') constraints.tags.push(node.value.toLowerCase());
else if (node.type === 'attribute' && node.operator === '=' && node.value !== undefined) {
constraints.attributes[node.attribute] = String(node.value);
} else if (node.type === 'pseudo' && node.value === ':not') {
for (const selector of node.nodes || []) {
if (selector.nodes.some((part) => part.type === 'combinator')) continue;
if (selector.nodes.length !== 1) continue;
const negative = simpleConstraints(selector.nodes);
constraints.negatives.push(...negative.ids.map((value) => `#${value}`));
constraints.negatives.push(...negative.classes.map((value) => `.${value}`));
constraints.negatives.push(...negative.tags);
}
}
}
for (const key of ['classes', 'ids', 'negatives', 'tags']) constraints[key].sort();
return constraints;
}
export function analyzeSelectorList(selector) {
let root;
try {
root = selectorParser().astSync(selector);
} catch (error) {
throw new TypeError(`Unsupported CSS selector grammar: ${selector}`, { cause: error });
}
if (!root.nodes.length) throw new TypeError(`Empty CSS selector list: ${selector}`);
root.walk((node) => {
if (!SUPPORTED_SELECTOR_NODES.has(node.type)) {
throw new TypeError(`Unsupported CSS selector node ${node.type}: ${selector}`);
}
if (node.type === 'combinator' && !SUPPORTED_COMBINATORS.has(node.value)) {
throw new TypeError(`Unsupported CSS combinator ${node.value}: ${selector}`);
}
if (node.type === 'pseudo' && !SUPPORTED_PSEUDOS.has(node.value)) {
throw new TypeError(`Unsupported CSS pseudo ${node.value}: ${selector}`);
}
if (node.type === 'attribute' && node.operator !== '=') {
throw new TypeError(`Unsupported CSS attribute selector: ${selector}`);
}
});
return root.nodes.map((selectorNode) => {
const compounds = [[]];
for (const node of selectorNode.nodes) {
if (node.type === 'combinator') compounds.push([]);
else compounds.at(-1).push(node);
}
const specificity = selectorSpecificity(selectorNode);
if (![specificity.a, specificity.b, specificity.c].every(Number.isSafeInteger)) {
throw new TypeError(`Unsupported selector specificity: ${selectorNode}`);
}
return {
ancestorClasses: compounds.length > 1
? simpleConstraints(compounds.slice(0, -1).flat()).classes
: [],
selector: selectorNode.toString(),
specificity,
target: simpleConstraints(compounds.at(-1)),
witnessEligible: !selectorNode.nodes.some((node) => node.type === 'combinator' && node.value === '+'),
};
});
}
function witnessHasClass(witness, className, ancestor = false) {
const classes = ancestor ? witness.ancestorClasses : witness.classes;
const prefixes = ancestor ? witness.ancestorPrefixes : witness.prefixes;
const unknown = ancestor ? witness.ancestorUnknown : witness.unknown;
return unknown || classes.includes(className) || prefixes.some((prefix) => className.startsWith(prefix));
}
function witnessMatches(witness, requirement) {
if (requirement.target.tags.length && !requirement.target.tags.includes(witness.tag)) return false;
if (!requirement.target.classes.every((className) => witnessHasClass(witness, className))) return false;
return requirement.ancestorClasses.every((className) => witnessHasClass(witness, className, true));
}
export function selectorsWithoutWitness(source, witnesses) {
const unmatched = [];
const root = postcss.parse(source, { from: undefined });
root.walkRules((rule) => {
if (keyframeAncestor(rule)) return;
for (const requirement of analyzeSelectorList(rule.selector)) {
if ([':root', 'html', 'body', '#root'].includes(requirement.selector.trim())) continue;
const hasStructuralTarget = requirement.target.classes.length
|| requirement.target.tags.length
|| requirement.ancestorClasses.length;
if (requirement.witnessEligible && hasStructuralTarget
&& !witnesses.some((witness) => witnessMatches(witness, requirement))) {
unmatched.push(requirement.selector);
}
}
});
return [...new Set(unmatched)].sort();
}
function constraintsConflict(left, right, witnesses) {
if (left.target.ids.length && right.target.ids.length
&& !left.target.ids.some((id) => right.target.ids.includes(id))) return true;
if (left.target.tags.length && right.target.tags.length
&& !left.target.tags.some((tag) => right.target.tags.includes(tag))) return true;
for (const [name, value] of Object.entries(left.target.attributes)) {
if (right.target.attributes[name] !== undefined && right.target.attributes[name] !== value) return true;
}
const leftPositive = [...left.target.ids.map((value) => `#${value}`), ...left.target.classes.map((value) => `.${value}`), ...left.target.tags];
const rightPositive = [...right.target.ids.map((value) => `#${value}`), ...right.target.classes.map((value) => `.${value}`), ...right.target.tags];
if (leftPositive.some((value) => right.target.negatives.includes(value))
|| rightPositive.some((value) => left.target.negatives.includes(value))) return true;
if (!witnesses || !left.witnessEligible || !right.witnessEligible) return false;
const hasStaticRequirement = left.target.classes.length || right.target.classes.length
|| left.target.tags.length || right.target.tags.length
|| left.ancestorClasses.length || right.ancestorClasses.length;
return Boolean(hasStaticRequirement && !witnesses.some((witness) => (
witnessMatches(witness, left) && witnessMatches(witness, right)
)));
}
function mediaRequirements(context) {
const requirements = {};
for (const item of context) {
if (!item.startsWith('@media ')) continue;
const params = item.slice('@media '.length);
for (const clause of params.split(/\s+and\s+/)) {
const match = /^\(([a-z-]+):\s*([a-z0-9.]+(?:px)?)\)$/.exec(clause.trim());
if (!match) throw new TypeError(`Unsupported media grammar: ${params}`);
const [, feature, value] = match;
if (feature === 'min-width' || feature === 'max-width') requirements[feature] = Number.parseFloat(value);
else if (requirements[feature] !== undefined && requirements[feature] !== value) requirements[feature] = null;
else requirements[feature] = value;
}
}
return requirements;
}
function mediaCompatible(left, right) {
const a = mediaRequirements(left);
const b = mediaRequirements(right);
for (const feature of new Set([...Object.keys(a), ...Object.keys(b)])) {
if (a[feature] === null || b[feature] === null) return false;
if (!['min-width', 'max-width'].includes(feature)
&& a[feature] !== undefined && b[feature] !== undefined && a[feature] !== b[feature]) return false;
}
const min = Math.max(a['min-width'] ?? -Infinity, b['min-width'] ?? -Infinity);
const max = Math.min(a['max-width'] ?? Infinity, b['max-width'] ?? Infinity);
return min <= max;
}
function semanticDeclaration(entry) {
return {
context: entry.context,
declarationIndex: entry.declarationIndex,
important: entry.important,
property: entry.property,
ruleOccurrence: entry.ruleOccurrence,
selector: entry.selector,
value: entry.value,
};
}
function declarationAtom(entry) {
return {
context: entry.context,
important: entry.important,
property: entry.property,
selector: entry.selector,
value: entry.value,
};
}
function sortLedger(entries) {
return [...entries].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
}
export function createStyleLedger(source, { witnesses = null } = {}) {
const root = postcss.parse(source, { from: undefined });
root.walkAtRules((atRule) => {
if (!['media', 'keyframes', '-webkit-keyframes'].includes(atRule.name)) {
throw new TypeError(`Unsupported CSS at-rule in cascade proof: @${atRule.name}`);
}
if (atRule.name === 'media') mediaRequirements([`@media ${atRule.params}`]);
});
const declarations = [];
const selectors = [];
const cascadeDeclarations = [];
const ruleDeclarationSequences = [];
const occurrences = new Map();
let ruleOrdinal = 0;
root.walkRules((rule) => {
const context = atRuleContext(rule);
const inKeyframes = Boolean(keyframeAncestor(rule));
const occurrenceKey = JSON.stringify([context, rule.selector]);
const ruleOccurrence = occurrences.get(occurrenceKey) || 0;
occurrences.set(occurrenceKey, ruleOccurrence + 1);
const analyzed = inKeyframes ? [] : analyzeSelectorList(rule.selector);
if (!inKeyframes) {
selectors.push({
context,
occurrence: ruleOccurrence,
ordinal: ruleOrdinal,
selector: rule.selector,
selectors: analyzed.map((entry) => entry.selector),
});
}
let declarationIndex = 0;
const ruleDeclarations = [];
for (const node of rule.nodes || []) {
if (node.type !== 'decl') continue;
const family = propertyFamily(node.prop);
const entry = {
context,
declarationIndex,
family,
important: Boolean(node.important),
ordinal: declarations.length,
property: node.prop,
ruleOccurrence,
ruleOrdinal,
selector: rule.selector,
value: node.value,
};
declarations.push(entry);
ruleDeclarations.push({ important: Boolean(node.important), property: node.prop, value: node.value });
if (!inKeyframes) {
for (const analyzedSelector of analyzed) {
cascadeDeclarations.push({ ...entry, ...analyzedSelector });
}
}
declarationIndex += 1;
}
ruleDeclarationSequences.push({ context, declarations: ruleDeclarations, selector: rule.selector });
ruleOrdinal += 1;
});
const keyframes = [];
const keyframeOccurrences = new Map();
root.walkAtRules((atRule) => {
if (atRule.name !== 'keyframes' && atRule.name !== '-webkit-keyframes') return;
const occurrence = keyframeOccurrences.get(atRule.params) || 0;
keyframeOccurrences.set(atRule.params, occurrence + 1);
keyframes.push({
context: atRuleContext(atRule),
name: atRule.params,
occurrence,
vendor: atRule.name,
frames: (atRule.nodes || []).filter((node) => node.type === 'rule').map((frame) => ({
selector: frame.selector,
declarations: (frame.nodes || []).filter((node) => node.type === 'decl').map((node) => ({
important: Boolean(node.important),
property: node.prop,
value: node.value,
})),
})),
});
});
const customProperties = declarations.filter((entry) => entry.property.startsWith('--'));
const variableReferenceLedger = declarations.flatMap((entry) => variableReferences(entry.value).map((reference, referenceIndex) => ({
...semanticDeclaration(entry),
referenceIndex,
...reference,
})));
const grouped = new Map();
for (const entry of cascadeDeclarations) {
const key = JSON.stringify([entry.family, entry.important, entry.specificity]);
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(entry);
}
const cascadeEdges = [];
for (const entries of grouped.values()) {
for (let rightIndex = 1; rightIndex < entries.length; rightIndex += 1) {
const right = entries[rightIndex];
for (let leftIndex = 0; leftIndex < rightIndex; leftIndex += 1) {
const left = entries[leftIndex];
if (compare(left.specificity, right.specificity) !== 0) continue;
if (!mediaCompatible(left.context, right.context)) continue;
if (constraintsConflict(left, right, witnesses)) continue;
cascadeEdges.push({
context: [left.context, right.context],
family: left.family,
important: left.important,
loser: declarationAtom(left),
specificity: left.specificity,
winner: declarationAtom(right),
});
}
}
}
const normalizedRuleDeclarationSequences = [];
const appClientDeclarations = [];
for (const rule of ruleDeclarationSequences) {
if (rule.context.length === 0 && rule.selector === '.app.client-app') appClientDeclarations.push(...rule.declarations);
else normalizedRuleDeclarationSequences.push(rule);
}
if (appClientDeclarations.length) {
normalizedRuleDeclarationSequences.push({
context: [],
declarations: appClientDeclarations,
selector: '.app.client-app',
});
}
let appClientSelectorSeen = false;
const selectorLists = sortLedger(selectors.flatMap(({ context, selector, selectors: expanded }) => {
if (context.length === 0 && selector === '.app.client-app') {
if (appClientSelectorSeen) return [];
appClientSelectorSeen = true;
}
return [{ context, selector, selectors: expanded }];
}));
const duplicateSelectors = [];
const rulesBySelector = new Map();
for (const rule of normalizedRuleDeclarationSequences) {
const key = JSON.stringify([rule.context, rule.selector]);
if (!rulesBySelector.has(key)) rulesBySelector.set(key, []);
rulesBySelector.get(key).push(rule.declarations);
}
for (const [key, sequences] of rulesBySelector) {
if (sequences.length > 1) duplicateSelectors.push({ key: JSON.parse(key), sequences });
}
const duplicateKeyframes = [];
const keyframesByName = new Map();
for (const keyframe of keyframes) {
const key = JSON.stringify([keyframe.context, keyframe.vendor, keyframe.name]);
if (!keyframesByName.has(key)) keyframesByName.set(key, []);
keyframesByName.get(key).push(keyframe.frames);
}
for (const [key, sequences] of keyframesByName) {
if (sequences.length > 1) duplicateKeyframes.push({ key: JSON.parse(key), sequences });
}
const ledgers = {
cascadeEdges: sortLedger(cascadeEdges),
customProperties: sortLedger(customProperties.map(declarationAtom)),
declarations: sortLedger(declarations.map(declarationAtom)),
duplicateKeyframes: sortLedger(duplicateKeyframes),
duplicateSelectors: sortLedger(duplicateSelectors),
keyframes: sortLedger(keyframes.map(({ occurrence: _occurrence, ...keyframe }) => keyframe)),
ruleDeclarationSequences: sortLedger(normalizedRuleDeclarationSequences),
selectors: selectorLists,
variableReferences: sortLedger(variableReferenceLedger.map((entry) => ({
context: entry.context,
fallback: entry.fallback,
important: entry.important,
name: entry.name,
property: entry.property,
selector: entry.selector,
value: entry.value,
}))),
};
if (witnesses) ledgers.witnesses = sortLedger(witnesses);
return {
counts: {
cascadeEdges: cascadeEdges.length,
customProperties: customProperties.length,
declarations: declarations.length,
important: declarations.filter((entry) => entry.important).length,
keyframes: keyframes.length,
media: root.nodes.reduce((count, node) => count + (node.type === 'atrule' && node.name === 'media' ? 1 : 0), 0),
rules: ruleOrdinal,
variableReferences: variableReferenceLedger.length,
},
hashes: Object.fromEntries(Object.entries(ledgers).map(([name, ledger]) => [name, hash(ledger)])),
ledgers,
};
}