mirror of
https://github.com/joeytoday/obsidian-mp-publisher.git
synced 2026-07-22 06:53:08 +00:00
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:
parent
30c935e9da
commit
b327cccc07
6 changed files with 2665 additions and 1001 deletions
3027
package-lock.json
generated
3027
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
// 移除定位样式
|
||||
|
|
|
|||
389
src/utils/pseudo-element-renderer.test.ts
Normal file
389
src/utils/pseudo-element-renderer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
|||
// 纯装饰性,填充 防止 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
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'jsdom',
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue