1000 lines
44 KiB
JavaScript
1000 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, 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,
|
|
});
|
|
}
|
|
supplied.set('children', { node: element.children, environment: callerEnvironment });
|
|
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 }
|
|
: 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) {
|
|
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,
|
|
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) {
|
|
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 });
|
|
}
|
|
}
|
|
return environment;
|
|
}
|
|
|
|
export function createStyleWitnesses(sources, { entry = 'App' } = {}) {
|
|
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)) throw new TypeError(`Recursive JSX witness component: ${definition.node.id?.name || definition.file}`);
|
|
const nextStack = [...stack, definition];
|
|
const entryEnvironment = withLocalJsxBindings(definition.node.body, environment, definitionNodes);
|
|
const visit = (node, currentAncestors = ancestors, currentUnknown = ancestorUnknown, currentEnvironment = entryEnvironment) => {
|
|
if (!node || typeof node !== 'object') return;
|
|
if (Array.isArray(node)) {
|
|
for (const child of node) visit(child, currentAncestors, currentUnknown, currentEnvironment);
|
|
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),
|
|
definitionNodes,
|
|
);
|
|
visit(callable.node.body, currentAncestors, currentUnknown, callEnvironment);
|
|
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);
|
|
if (appOrBody) {
|
|
visit(node.arguments[0], [{ classes: ['app', 'client-app', 'is-gateway-app'], prefixes: [], unknown: false }], false, currentEnvironment);
|
|
}
|
|
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);
|
|
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(definition.file).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);
|
|
return;
|
|
}
|
|
if (TRANSPARENT_JSX_COMPONENTS.has(name)) {
|
|
visit(node.children, currentAncestors, currentUnknown, currentEnvironment);
|
|
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, dynamicByFile);
|
|
expand(target, currentAncestors, currentUnknown, targetEnvironment, nextStack);
|
|
return;
|
|
}
|
|
if (node.type === 'JSXFragment') {
|
|
visit(node.children, currentAncestors, currentUnknown, currentEnvironment);
|
|
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);
|
|
}
|
|
};
|
|
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'),
|
|
}));
|
|
return createStyleWitnesses(files);
|
|
}
|
|
|
|
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-muted --client-panel --client-power-top --client-text --client-work-height
|
|
--harbor-connect --harbor-gateway --harbor-word -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
|
|
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-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 (!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,
|
|
};
|
|
}
|