/** * styles.css 的声明级层叠求值器。 * * jsdom 不加载 styles.css,所以"某个声明最终生效的是哪一条"只能在声明上验; * 但只读"第一条匹配到的规则"会被后面的同权重规则悄悄顶掉(例如 `.game-approval-dialog` * 在 8 千行以后又声明了一次 `width`),所以这里按真实层叠 * (媒体查询是否命中 -> 特异性 -> 源码顺序)算出最终生效的值。 */ import { expect } from 'vitest'; export type StyleRule = { selectors: string[]; declarations: Map; media: string | null; order: number; }; export function parseStyleSheet(css: string): StyleRule[] { const rules: StyleRule[] = []; const source = css.replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ' '), ); const pushDeclarationBlock = ( selectorText: string, body: string, media: string | null, ) => { const selectors = splitSelectorList(selectorText); if (selectors.length === 0) { return; } const declarations = new Map(); for (const chunk of body.split(';')) { const separator = chunk.indexOf(':'); if (separator < 0) { continue; } const property = chunk.slice(0, separator).trim(); const value = chunk .slice(separator + 1) .trim() .replace(/\s+/gu, ' '); if (property) { declarations.set(property, value); } } rules.push({ selectors, declarations, media, order: rules.length }); }; const readBlock = (text: string, start: number) => { let depth = 1; let index = start; while (index < text.length && depth > 0) { if (text[index] === '{') { depth += 1; } else if (text[index] === '}') { depth -= 1; } index += 1; } return { body: text.slice(start, index - 1), end: index }; }; let cursor = 0; let buffer = ''; while (cursor < source.length) { const char = source[cursor]!; if (char === '{') { const prelude = buffer.trim().replace(/\s+/gu, ' '); buffer = ''; const { body, end } = readBlock(source, cursor + 1); cursor = end; if (prelude.startsWith('@media')) { // 媒体查询体内只陈述"顶层选择器 + 声明",这里就够了:查询用的选择器都在顶层。 let inner = 0; let innerBuffer = ''; while (inner < body.length) { if (body[inner] === '{') { const innerSelector = innerBuffer.trim().replace(/\s+/gu, ' '); innerBuffer = ''; const { body: innerBody, end: innerEnd } = readBlock( body, inner + 1, ); pushDeclarationBlock(innerSelector, innerBody, prelude); inner = innerEnd; continue; } innerBuffer += body[inner]; inner += 1; } continue; } if (prelude.startsWith('@')) { continue; } pushDeclarationBlock(prelude, body, null); continue; } if (char === '}') { buffer = ''; cursor += 1; continue; } buffer += char; cursor += 1; } return rules; } export function splitSelectorList(selectorText: string): string[] { const selectors: string[] = []; let depth = 0; let current = ''; for (const char of selectorText) { if (char === '(' || char === '[') { depth += 1; } else if (char === ')' || char === ']') { depth -= 1; } if (char === ',' && depth === 0) { if (current.trim()) { selectors.push(current.trim().replace(/\s+/gu, ' ')); } current = ''; continue; } current += char; } if (current.trim()) { selectors.push(current.trim().replace(/\s+/gu, ' ')); } return selectors; } /** 特异性:只区分 id / 类(含属性与伪类)/ 元素,够排序本文件里的选择器。 */ export function specificity(selector: string): number { let rest = selector; let classLike = 0; // `:has(...)` 的权重按它参数里最高的那条选择器算,这里等价于再加一个类。 const hasParts = rest.match(/:has\([^)]*\)/gu) ?? []; for (const part of hasParts) { classLike += 1; const inner = Math.max( ...splitSelectorList(part.slice(5, -1)).map((one) => specificity(one)), ); classLike += Math.floor(inner / 100) % 100; } rest = rest.replace(/:has\([^)]*\)/gu, ' '); const ids = (rest.match(/#[\w-]+/gu) ?? []).length; classLike += (rest.match(/\.[\w-]+/gu) ?? []).length; classLike += (rest.match(/\[[^\]]*\]/gu) ?? []).length; classLike += (rest.match(/:(?!:)[\w-]+/gu) ?? []).length; const types = (rest.match(/(?:^|[\s>+~])([a-zA-Z][\w-]*)/gu) ?? []).length + (rest.match(/::[\w-]+/gu) ?? []).length; return ids * 10000 + classLike * 100 + types; } export function mediaMatches(media: string, viewportWidth: number): boolean { const conditions = media.match(/\((?:min|max)-width:\s*\d+px\)/gu) ?? []; if (conditions.length === 0) { // 只认识宽度条件:命中了别的媒体特性(例如 prefers-reduced-motion)就说明这条规则 // 的生效与否不是本用例能判定的,直接报错,避免悄悄算错一个几何值。 throw new Error(`测试求值器不认识这个媒体查询:${media}`); } for (const condition of conditions) { const parsed = /\((min|max)-width:\s*(\d+)px\)/u.exec(condition)!; const limit = Number(parsed[2]); if (parsed[1] === 'min' && viewportWidth < limit) { return false; } if (parsed[1] === 'max' && viewportWidth > limit) { return false; } } return true; } /** * 按层叠算出元素最终生效的声明。 * `elementSelectors` 是这个元素在 DOM 上会命中的全部选择器(含媒体查询里那几条)。 */ export function resolveDeclarations( rules: StyleRule[], elementSelectors: readonly string[], viewportWidth: number, ): Map { const winners = new Map(); for (const rule of rules) { const matched = rule.selectors .filter((selector) => elementSelectors.includes(selector)) .map((selector) => specificity(selector)); if (matched.length === 0) { continue; } if (rule.media && !mediaMatches(rule.media, viewportWidth)) { continue; } const rank: [number, number] = [Math.max(...matched), rule.order]; for (const [property, value] of rule.declarations) { const current = winners.get(property); if ( !current || rank[0] > current.rank[0] || (rank[0] === current.rank[0] && rank[1] > current.rank[1]) ) { winners.set(property, { value, rank }); } } } return new Map( Array.from(winners, ([property, winner]) => [property, winner.value]), ); } export function declaration( declarations: Map, property: string, ): string { const value = declarations.get(property); expect(value, `缺少生效声明 ${property}`).toBeDefined(); return value!; }