fix: apply code review fixes for PR #63

P1.1: unify counter parsing through resolveContent -> tokenizeContent,
      remove duplicate manual counter parsing in renderPseudoForElement
P1.2: add tokenizeContent for mixed string+counter content
      (e.g. "第" counter(h2) "章")
P2.1: add unique nanoid to tempStyle data-mp-temp attribute to prevent
      concurrent pollution during batch publishing
P2.2: skip CSS-wide keywords (none/inherit/initial/unset) in counter-reset/increment
Info.2: use regex match(/::(before|after)\\s*$/) instead of .includes()
Info.3: preserve CSS comments in removePseudoRulesFromCSS
Info.4: use .remove() instead of parentNode.removeChild() for tempStyle
Tests: add vitest + 45 unit tests for all pure functions

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Duyuxuan 2026-07-04 12:19:33 +08:00
parent 30c935e9da
commit b327cccc07
6 changed files with 2665 additions and 1001 deletions

3027
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,9 @@
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"obsidian",
@ -21,9 +23,11 @@
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.28.1",
"jsdom": "^29.1.1",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
"typescript": "4.7.4",
"vitest": "^1.6.1"
},
"dependencies": {
"highlight.js": "^11.11.1",

View file

@ -2,6 +2,7 @@ import { App, MarkdownRenderer, Component } from 'obsidian';
import { cleanObsidianUIElements } from './utils/html-cleaner';
import { preprocessMathFormula, waitForAsyncRender, convertMathToSVG as mathToSVG } from './utils/math-formula';
import { prerenderPseudoElements } from './utils/pseudo-element-renderer';
import { nanoid } from './utils/nanoid';
import type { ThemeManager } from './themeManager';
export class MPConverter {
@ -424,7 +425,7 @@ export async function markdownToHtml(
let tempStyle: HTMLStyleElement | null = null;
if (themeCSS) {
tempStyle = document.createElement('style');
tempStyle.setAttribute('data-mp-temp', 'prerender');
tempStyle.setAttribute('data-mp-temp', `prerender-${nanoid()}`);
tempStyle.textContent = themeCSS;
document.head.appendChild(tempStyle);
@ -439,8 +440,8 @@ export async function markdownToHtml(
const cleanedCSS = prerenderPseudoElements(tempDiv, themeCSS);
// ★ 从 <head> 移除临时 <style>(伪元素已转为真实 DOM不再需要
if (tempStyle && tempStyle.parentNode) {
tempStyle.parentNode.removeChild(tempStyle);
if (tempStyle) {
tempStyle.remove();
}
// 移除定位样式

View file

@ -0,0 +1,389 @@
/**
*
*/
import { describe, it, expect } from 'vitest';
import {
formatCounterValue,
toRoman,
numToAlpha,
parsePseudoRules,
resolveContent,
parseCounterConfig,
splitCSSBlocks,
removePseudoRulesFromCSS,
} from './pseudo-element-renderer';
// ============================================================
// formatCounterValue
// ============================================================
describe('formatCounterValue', () => {
it('decimal (default)', () => {
expect(formatCounterValue(1, 'decimal')).toBe('1');
expect(formatCounterValue(42, 'decimal')).toBe('42');
});
it('decimal-leading-zero', () => {
expect(formatCounterValue(1, 'decimal-leading-zero')).toBe('01');
expect(formatCounterValue(9, 'decimal-leading-zero')).toBe('09');
expect(formatCounterValue(10, 'decimal-leading-zero')).toBe('10');
expect(formatCounterValue(99, 'decimal-leading-zero')).toBe('99');
});
it('upper-roman', () => {
expect(formatCounterValue(1, 'upper-roman')).toBe('I');
expect(formatCounterValue(4, 'upper-roman')).toBe('IV');
expect(formatCounterValue(9, 'upper-roman')).toBe('IX');
expect(formatCounterValue(42, 'upper-roman')).toBe('XLII');
expect(formatCounterValue(99, 'upper-roman')).toBe('XCIX');
});
it('lower-roman', () => {
expect(formatCounterValue(1, 'lower-roman')).toBe('i');
expect(formatCounterValue(4, 'lower-roman')).toBe('iv');
expect(formatCounterValue(2024, 'lower-roman')).toBe('mmxxiv');
});
it('upper-alpha / upper-latin', () => {
expect(formatCounterValue(1, 'upper-alpha')).toBe('A');
expect(formatCounterValue(26, 'upper-alpha')).toBe('Z');
expect(formatCounterValue(27, 'upper-alpha')).toBe('AA');
expect(formatCounterValue(52, 'upper-alpha')).toBe('AZ');
expect(formatCounterValue(53, 'upper-alpha')).toBe('BA');
});
it('lower-alpha / lower-latin', () => {
expect(formatCounterValue(1, 'lower-alpha')).toBe('a');
expect(formatCounterValue(26, 'lower-alpha')).toBe('z');
expect(formatCounterValue(27, 'lower-alpha')).toBe('aa');
expect(formatCounterValue(28, 'lower-latin')).toBe('ab');
});
it('unknown style falls back to decimal', () => {
expect(formatCounterValue(5, 'unknown-style')).toBe('5');
});
});
// ============================================================
// toRoman
// ============================================================
describe('toRoman', () => {
it('basic conversions', () => {
expect(toRoman(1)).toBe('i');
expect(toRoman(2)).toBe('ii');
expect(toRoman(3)).toBe('iii');
expect(toRoman(4)).toBe('iv');
expect(toRoman(5)).toBe('v');
expect(toRoman(6)).toBe('vi');
expect(toRoman(9)).toBe('ix');
expect(toRoman(10)).toBe('x');
});
it('tens', () => {
expect(toRoman(14)).toBe('xiv');
expect(toRoman(19)).toBe('xix');
expect(toRoman(20)).toBe('xx');
expect(toRoman(40)).toBe('xl');
expect(toRoman(49)).toBe('xlix');
expect(toRoman(50)).toBe('l');
expect(toRoman(90)).toBe('xc');
expect(toRoman(99)).toBe('xcix');
});
it('hundreds and thousands', () => {
expect(toRoman(100)).toBe('c');
expect(toRoman(400)).toBe('cd');
expect(toRoman(500)).toBe('d');
expect(toRoman(900)).toBe('cm');
expect(toRoman(1000)).toBe('m');
expect(toRoman(2024)).toBe('mmxxiv');
expect(toRoman(3999)).toBe('mmmcmxcix');
});
});
// ============================================================
// numToAlpha
// ============================================================
describe('numToAlpha', () => {
it('single letters', () => {
expect(numToAlpha(1)).toBe('a');
expect(numToAlpha(13)).toBe('m');
expect(numToAlpha(26)).toBe('z');
});
it('double letters', () => {
expect(numToAlpha(27)).toBe('aa');
expect(numToAlpha(52)).toBe('az');
expect(numToAlpha(53)).toBe('ba');
expect(numToAlpha(78)).toBe('bz');
expect(numToAlpha(79)).toBe('ca');
});
it('triple letters', () => {
expect(numToAlpha(703)).toBe('aaa');
expect(numToAlpha(728)).toBe('aaz');
});
it('zero or negative returns empty string', () => {
expect(numToAlpha(0)).toBe('');
expect(numToAlpha(-1)).toBe('');
});
});
// ============================================================
// splitCSSBlocks
// ============================================================
describe('splitCSSBlocks', () => {
it('splits simple blocks', () => {
const css = 'h1 { color: red; } h2 { color: blue; }';
const blocks = splitCSSBlocks(css);
expect(blocks).toHaveLength(2);
expect(blocks[0]).toBe('h1 { color: red; }');
expect(blocks[1].trim()).toBe('h2 { color: blue; }');
});
it('handles nested braces in non-standard CSS', () => {
const css = '.a { content: "{" attr(href) "}"; }';
const blocks = splitCSSBlocks(css);
expect(blocks).toHaveLength(1);
});
it('handles empty input', () => {
expect(splitCSSBlocks('')).toHaveLength(0);
});
});
// ============================================================
// parsePseudoRules
// ============================================================
describe('parsePseudoRules', () => {
it('extracts ::before rules', () => {
const css = 'h1::before { content: "→"; color: red; }';
const rules = parsePseudoRules(css);
expect(rules).toHaveLength(1);
expect(rules[0].baseSelector).toBe('h1');
expect(rules[0].pseudoType).toBe('before');
expect(rules[0].properties['content']).toBe('"→"');
expect(rules[0].properties['color']).toBe('red');
});
it('extracts ::after rules', () => {
const css = 'h1::after { content: "←"; }';
const rules = parsePseudoRules(css);
expect(rules).toHaveLength(1);
expect(rules[0].pseudoType).toBe('after');
});
it('rejects pseudo-element in middle of selector (Info.2 fix)', () => {
// ::before appearing in the middle of selector is invalid
const css = '.prefix::before-something { color: red; }';
const rules = parsePseudoRules(css);
// 不应匹配 ".prefix::before-something" 中的 "::before"
expect(rules).toHaveLength(0);
});
it('handles multiple selectors in one block', () => {
const css = 'h1::before, h2::before { content: counter(chapter); }';
const rules = parsePseudoRules(css);
expect(rules).toHaveLength(2);
expect(rules[0].baseSelector).toBe('h1');
expect(rules[1].baseSelector).toBe('h2');
});
it('skips blocks without pseudo-elements', () => {
const css = 'h1 { color: red; } h2::before { content: ">"; }';
const rules = parsePseudoRules(css);
expect(rules).toHaveLength(1);
});
it('strips comments before parsing', () => {
const css = '/* h1::before { old: rule; } */ h2::before { content: ">"; }';
const rules = parsePseudoRules(css);
expect(rules).toHaveLength(1);
expect(rules[0].baseSelector).toBe('h2');
});
it('handles complex selectors', () => {
const css = '.mp-content-section blockquote::before { content: "\\201C"; }';
const rules = parsePseudoRules(css);
expect(rules).toHaveLength(1);
expect(rules[0].baseSelector).toBe('.mp-content-section blockquote');
});
});
// ============================================================
// parseCounterConfig
// ============================================================
describe('parseCounterConfig', () => {
it('parses counter-reset', () => {
const css = 'body { counter-reset: h2 0; }';
const config = parseCounterConfig(css);
expect(config.resets).toHaveLength(1);
expect(config.resets[0].name).toBe('h2');
expect(config.resets[0].value).toBe(0);
});
it('parses counter-increment', () => {
const css = 'h2 { counter-increment: h2 1; }';
const config = parseCounterConfig(css);
expect(config.increments).toHaveLength(1);
expect(config.increments[0].name).toBe('h2');
expect(config.increments[0].value).toBe(1);
});
it('skips counter-reset: none (P2.2 fix)', () => {
const css = 'body { counter-reset: none; }';
const config = parseCounterConfig(css);
expect(config.resets).toHaveLength(0);
});
it('skips counter-increment: none (P2.2 fix)', () => {
const css = 'h2 { counter-increment: none; }';
const config = parseCounterConfig(css);
expect(config.increments).toHaveLength(0);
});
it('skips CSS-wide keywords: inherit, initial, unset (P2.2 fix)', () => {
const css = `
body { counter-reset: inherit; }
div { counter-reset: initial; }
p { counter-reset: unset; }
`;
const config = parseCounterConfig(css);
expect(config.resets).toHaveLength(0);
});
it('skips pseudo-element blocks', () => {
const css = 'h2::before { counter-increment: h2 1; }';
const config = parseCounterConfig(css);
expect(config.increments).toHaveLength(0);
});
it('handles multiple selectors', () => {
const css = 'h2, h3 { counter-increment: heading 1; }';
const config = parseCounterConfig(css);
expect(config.increments).toHaveLength(2);
});
});
// ============================================================
// resolveContent
// ============================================================
describe('resolveContent', () => {
const mockEl = document.createElement('div');
it('returns null for none/normal/empty', () => {
expect(resolveContent('none', mockEl, undefined)).toBeNull();
expect(resolveContent('normal', mockEl, undefined)).toBeNull();
expect(resolveContent('', mockEl, undefined)).toBeNull();
});
it('resolves simple string content', () => {
const result = resolveContent('"→"', mockEl, undefined);
expect(result).toBe('→');
});
it('resolves string with unicode escapes', () => {
const result = resolveContent('"\\201C"', mockEl, undefined);
expect(result).toBe('“');
});
it('resolves counter() with default decimal style', () => {
const counterMap = new Map([['h2', 5]]);
const result = resolveContent('counter(h2)', mockEl, counterMap);
expect(result).toBe('5');
});
it('resolves counter() with explicit style', () => {
const counterMap = new Map([['h2', 1]]);
const result = resolveContent('counter(h2, decimal-leading-zero)', mockEl, counterMap);
expect(result).toBe('01');
});
it('resolves counter() with upper-roman style', () => {
const counterMap = new Map([['chapter', 4]]);
const result = resolveContent('counter(chapter, upper-roman)', mockEl, counterMap);
expect(result).toBe('IV');
});
it('resolves mixed string + counter content (P1.2 fix)', () => {
const counterMap = new Map([['h2', 3]]);
const result = resolveContent('"第" counter(h2) "章"', mockEl, counterMap);
expect(result).toBe('第3章');
});
it('resolves mixed content with leading-zero', () => {
const counterMap = new Map([['h2', 1]]);
const result = resolveContent('"第" counter(h2, decimal-leading-zero) "章"', mockEl, counterMap);
expect(result).toBe('第01章');
});
it('resolves mixed content with unicode escapes', () => {
const counterMap = new Map([['quote', 1]]);
const result = resolveContent('"\\201C" counter(quote) "\\201D"', mockEl, counterMap);
expect(result).toBe('“1”');
});
it('resolves mixed content with multiple counters', () => {
const counterMap = new Map([['chapter', 2], ['section', 1]]);
const result = resolveContent('counter(chapter) "." counter(section)', mockEl, counterMap);
expect(result).toBe('2.1');
});
it('returns null for counter name not in map', () => {
const counterMap = new Map([['other', 5]]);
// Pure counter expression — counter not found, returns empty string via tokenize
const result = resolveContent('counter(missing)', mockEl, counterMap);
// tokenizeContent returns '' (matched token but empty result)
expect(result).toBe('');
});
});
// ============================================================
// removePseudoRulesFromCSS
// ============================================================
describe('removePseudoRulesFromCSS', () => {
it('removes ::before and ::after blocks', () => {
const css = `
h1 { color: red; }
h1::before { content: ">"; }
h1::after { content: "<"; }
p { font-size: 16px; }
`;
const result = removePseudoRulesFromCSS(css);
expect(result).toContain('h1 { color: red; }');
expect(result).toContain('p { font-size: 16px; }');
expect(result).not.toContain('::before');
expect(result).not.toContain('::after');
});
it('preserves non-pseudo-element comments (Info.3 fix)', () => {
const css = `
/* This is a heading */
h1 { color: red; }
/* This is a pseudo — should be removed */
h1::before { content: ">"; }
/* This is a paragraph */
p { font-size: 16px; }
`;
const result = removePseudoRulesFromCSS(css);
expect(result).toContain('/* This is a heading */');
expect(result).toContain('/* This is a paragraph */');
expect(result).not.toContain('::before');
});
it('handles CSS with no pseudo-elements', () => {
const css = 'h1 { color: red; } p { font-size: 16px; }';
const result = removePseudoRulesFromCSS(css);
// 应保留所有规则
expect(result).toContain('h1');
expect(result).toContain('p');
});
});

View file

@ -15,7 +15,7 @@
* h1::after <span class="h1-dot">
* h2::before <span class="h2-num"> 01/02
* h3::after <span class="h3-dot">
* blockquote::before <span class="bq-mark">
* blockquote::before <span class="bq-mark"> "
* .mp-callout::before <span class="callout-mark">
*/
@ -38,7 +38,7 @@ interface CounterConfig {
// CSS 解析
// ============================================================
function parsePseudoRules(css: string): PseudoRule[] {
export function parsePseudoRules(css: string): PseudoRule[] {
const rules: PseudoRule[] = [];
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const blocks = splitCSSBlocks(cssWithoutComments);
@ -57,10 +57,11 @@ function parsePseudoRules(css: string): PseudoRule[] {
const trimmed = sel.trim();
if (!trimmed) continue;
let pseudoType: 'before' | 'after' | null = null;
if (trimmed.includes('::before')) pseudoType = 'before';
else if (trimmed.includes('::after')) pseudoType = 'after';
if (!pseudoType) continue;
// Fix (Info.2): 使用 regex 精确匹配 selector 末尾的 ::before/::after
// 避免匹配到出现在 selector 中间的无效伪元素
const pseudoMatch = trimmed.match(/::(before|after)\s*$/);
if (!pseudoMatch) continue;
const pseudoType = pseudoMatch[1] as 'before' | 'after';
const baseSelector = trimmed
.replace(/::before/g, '')
@ -75,12 +76,15 @@ function parsePseudoRules(css: string): PseudoRule[] {
return rules;
}
function parseCounterConfig(css: string): CounterConfig {
export function parseCounterConfig(css: string): CounterConfig {
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const blocks = splitCSSBlocks(cssWithoutComments);
const resets: CounterConfig['resets'] = [];
const increments: CounterConfig['increments'] = [];
// Fix (P2.2): CSS-wide keywords that should not be parsed as counter names
const CSS_WIDE_KEYWORDS = new Set(['none', 'inherit', 'initial', 'unset']);
for (const block of blocks) {
const braceIndex = block.indexOf('{');
if (braceIndex === -1) continue;
@ -97,12 +101,15 @@ function parseCounterConfig(css: string): CounterConfig {
// counter-reset: "counter-name <value>?"
if (properties['counter-reset']) {
const parts = properties['counter-reset'].trim().split(/\s+/);
const name = parts[0];
const value = parts.length > 1 ? parseInt(parts[1], 10) : 0;
if (name && !isNaN(value)) {
for (const sel of splitSelectors(selectorPart)) {
if (!sel.includes('::before') && !sel.includes('::after')) {
resets.push({ selector: sel.trim(), name, value });
// Fix (P2.2): 跳过 CSS-wide 关键字,防止 "none" 被解析为计数器名
if (!CSS_WIDE_KEYWORDS.has(parts[0].toLowerCase())) {
const name = parts[0];
const value = parts.length > 1 ? parseInt(parts[1], 10) : 0;
if (name && !isNaN(value)) {
for (const sel of splitSelectors(selectorPart)) {
if (!sel.includes('::before') && !sel.includes('::after')) {
resets.push({ selector: sel.trim(), name, value });
}
}
}
}
@ -111,12 +118,15 @@ function parseCounterConfig(css: string): CounterConfig {
// counter-increment: "counter-name <value>?"
if (properties['counter-increment']) {
const parts = properties['counter-increment'].trim().split(/\s+/);
const name = parts[0];
const value = parts.length > 1 ? parseInt(parts[1], 10) : 1;
if (name && !isNaN(value)) {
for (const sel of splitSelectors(selectorPart)) {
if (!sel.includes('::before') && !sel.includes('::after')) {
increments.push({ selector: sel.trim(), name, value });
// Fix (P2.2): 跳过 CSS-wide 关键字
if (!CSS_WIDE_KEYWORDS.has(parts[0].toLowerCase())) {
const name = parts[0];
const value = parts.length > 1 ? parseInt(parts[1], 10) : 1;
if (name && !isNaN(value)) {
for (const sel of splitSelectors(selectorPart)) {
if (!sel.includes('::before') && !sel.includes('::after')) {
increments.push({ selector: sel.trim(), name, value });
}
}
}
}
@ -130,7 +140,7 @@ function parseCounterConfig(css: string): CounterConfig {
// CSS 工具函数
// ============================================================
function splitCSSBlocks(css: string): string[] {
export function splitCSSBlocks(css: string): string[] {
const blocks: string[] = [];
let depth = 0;
let current = '';
@ -178,7 +188,7 @@ function parseProperties(body: string): Record<string, string> {
// 计数器计算
// ============================================================
function formatCounterValue(value: number, style: string): string {
export function formatCounterValue(value: number, style: string): string {
switch (style) {
case 'decimal-leading-zero': return String(value).padStart(2, '0');
case 'upper-roman': return toRoman(value).toUpperCase();
@ -189,7 +199,7 @@ function formatCounterValue(value: number, style: string): string {
}
}
function toRoman(num: number): string {
export function toRoman(num: number): string {
const vals = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
const syms = ['m', 'cm', 'd', 'cd', 'c', 'xc', 'l', 'xl', 'x', 'ix', 'v', 'iv', 'i'];
let result = '';
@ -197,7 +207,7 @@ function toRoman(num: number): string {
return result;
}
function numToAlpha(num: number): string {
export function numToAlpha(num: number): string {
if (num <= 0) return '';
let result = '';
while (num > 0) { num--; result = String.fromCharCode(97 + (num % 26)) + result; num = Math.floor(num / 26); }
@ -248,7 +258,112 @@ function matchesSelectorInContainer(
// 内容解析
// ============================================================
function resolveContent(
/**
* Fix (P1.2): Tokenize content value to support mixed string + counter() expressions.
*
* CSS content :
* content: "第" counter(h2) "章" "第01章"
* content: "\201C" counter(quote) "\201D" ""1"
*
* counter()
* counter()
*/
function tokenizeContent(value: string, counterMap: Map<string, number> | undefined): string | null {
let result = '';
let i = 0;
let matchedAny = false;
while (i < value.length) {
// 跳过 token 之间的空白
while (i < value.length && /\s/.test(value[i])) i++;
if (i >= value.length) break;
// 字符串引号段: "..." 或 '...'
if (value[i] === '"' || value[i] === "'") {
const quote = value[i];
i++; // 跳过开引号
let seg = '';
while (i < value.length && value[i] !== quote) {
if (value[i] === '\\' && i + 1 < value.length) {
i++; // 跳过反斜杠
// Unicode 转义 \XXXX
const hexMatch = value.substring(i).match(/^([0-9A-Fa-f]{4})\s?/);
if (hexMatch) {
seg += String.fromCodePoint(parseInt(hexMatch[1], 16));
i += hexMatch[0].length;
} else if (value[i] === '\\' || value[i] === quote) {
// 简单转义 \\ 或 \"
seg += value[i];
i++;
} else {
// 保留反斜杠 + 字符
seg += '\\' + value[i];
i++;
}
} else {
seg += value[i];
i++;
}
}
if (i < value.length) i++; // 跳过闭引号
result += seg;
matchedAny = true;
continue;
}
// counter() 表达式
if (value.substring(i).startsWith('counter(')) {
i += 8; // 跳过 'counter('
let depth = 0;
let argsStr = '';
while (i < value.length) {
const ch = value[i];
if (ch === '(') { depth++; argsStr += ch; }
else if (ch === ')') {
if (depth === 0) break;
depth--;
argsStr += ch;
} else {
argsStr += ch;
}
i++;
}
if (i < value.length) i++; // 跳过闭括号 ')'
// 解析参数: counter(name, style)
const commaIdx = argsStr.indexOf(',');
const cName = (commaIdx >= 0 ? argsStr.substring(0, commaIdx) : argsStr).trim();
const cStyle = commaIdx >= 0 ? argsStr.substring(commaIdx + 1).trim() : 'decimal';
if (counterMap && cName) {
const val = counterMap.get(cName);
if (val !== undefined) {
result += formatCounterValue(val, cStyle);
}
}
matchedAny = true;
continue;
}
// 无法识别的 token跳过字符
i++;
}
return matchedAny ? result : null;
}
/**
* Fix (P1.1 & P1.2): resolveContent content
*
* :
* 1. : "→" "\201C"
* 2. 纯计数器: counter(h2, decimal-leading-zero)
* 3. : "第" counter(h2) "章"
*
* tokenizeContent renderPseudoForElement
* counter
*/
export function resolveContent(
cssContentValue: string,
_el: HTMLElement,
counterMap: Map<string, number> | undefined,
@ -257,22 +372,9 @@ function resolveContent(
return null;
}
// 字符串字面量: "\201C" → "“"
const stringMatch = cssContentValue.match(/^["'](.*)["']$/s);
if (stringMatch) {
const inner = stringMatch[1];
if (!inner) return '';
return inner.replace(/\\([0-9A-Fa-f]{4})\s?/g, (_, hex) =>
String.fromCodePoint(parseInt(hex, 16))
);
}
// counter() 表达式
const cMatch = cssContentValue.match(/counter\(\s*([a-zA-Z_-]+)\s*(?:,\s*([a-zA-Z_-]+)\s*)?\)/);
if (cMatch && counterMap) {
const val = counterMap.get(cMatch[1]);
if (val !== undefined) return formatCounterValue(val, cMatch[2] || 'decimal');
}
// 统一通过 tokenizeContent 解析,同时覆盖纯字符串、纯 counter() 和混合内容
const tokenized = tokenizeContent(cssContentValue, counterMap);
if (tokenized !== null) return tokenized;
return null;
}
@ -319,6 +421,15 @@ function safeQuerySelectorAll(container: HTMLElement, selector: string): Element
// 核心渲染
// ============================================================
/**
* Fix (P1.1): renderPseudoForElement counter
*
* renderPseudoForElement counter(name,style)
* resolveContent
*
* content resolveContent tokenizeContent
* counter()
*/
function renderPseudoForElement(
el: Element,
rule: PseudoRule,
@ -333,23 +444,11 @@ function renderPseudoForElement(
// 纯装饰性,填充 &nbsp; 防止 XMLSerializer 自闭合导致 DOM 错乱
isEmptyVisual = true;
textContent = ' ';
} else if (cssContent.includes('counter(')) {
// 计数器表达式 → 字符串拆分解析
const parenStart = cssContent.indexOf('(');
const parenEnd = cssContent.lastIndexOf(')');
if (parenStart !== -1 && parenEnd !== -1) {
const args = cssContent.substring(parenStart + 1, parenEnd).split(',').map(s => s.trim());
const cName = args[0];
const cStyle = args[1] || 'decimal';
if (counterMap) {
const cVal = counterMap.get(cName);
if (cVal !== undefined) textContent = formatCounterValue(cVal, cStyle);
}
}
if (!textContent) return;
} else {
// 普通字符串
// Fix (P1.1): 统一通过 resolveContent 解析,不再在此处手动拆括号解析 counter()
textContent = resolveContent(cssContent, htmlEl, counterMap);
// getComputedStyle 兜底(仅在 resolveContent 无法解析时)
if (textContent === null) {
try {
const computed = getComputedStyle(htmlEl,
@ -360,6 +459,7 @@ function renderPseudoForElement(
}
} catch { /* ignore */ }
}
if (!textContent && textContent !== '') return;
}
@ -367,7 +467,7 @@ function renderPseudoForElement(
const span = document.createElement('span');
span.className = generateSpanClass(htmlEl.tagName, rule.pseudoType, el);
// 将 CSS 属性写入 inline style
// 将 CSS 属性写入 inline style(跳过 content 和 counter- 前缀属性)
for (const [prop, value] of Object.entries(rule.properties)) {
if (prop === 'content' || prop.startsWith('counter-')) continue;
try { span.style.setProperty(prop, value); } catch { /* skip */ }
@ -397,9 +497,16 @@ function renderPseudoForElement(
// CSS 清理
// ============================================================
function removePseudoRulesFromCSS(css: string): string {
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const blocks = splitCSSBlocks(cssWithoutComments);
/**
* Fix (Info.3): CSS
*
* blocks
* CSS blocks selector block
*
*/
export function removePseudoRulesFromCSS(css: string): string {
// 直接在原始 CSS 上分块,不预先删除注释
const blocks = splitCSSBlocks(css);
const filtered = blocks.filter(block => {
const braceIndex = block.indexOf('{');
@ -434,6 +541,6 @@ export function prerenderPseudoElements(container: HTMLElement, css: string): st
}
}
// 4. 移除伪元素规则
// 4. 移除伪元素规则(保留注释)
return removePseudoRulesFromCSS(css);
}

8
vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'jsdom',
},
});