Skip to content

Commit eb8bdb7

Browse files
tinchox5claude
andcommitted
fix(pseudo): support counter-set; drop divergent dead counter duplicates
The live deriveCounterCtxForPseudo in pseudo.js parsed counter-reset and counter-increment but not counter-set, so a pseudo with `counter-set` resolved its content with the wrong value. A second, divergent copy in counter.js DID handle counter-set but was never imported (only its tests called it) — exactly the trap that let the bug pass as "tested". counter-set is now applied in the live path (CSS order reset → set → increment), and the dead counter.js duplicates (deriveCounterCtxForPseudo, resolvePseudoContent, unquoteDoubleStrings) plus their tests are removed. Also drops the byte-identical, uncalled pluginsList (getGlobalPlugins remains) and collapses an unreachable `|| __plugins` fallback in getContextPlugins. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6d1ad72 commit eb8bdb7

5 files changed

Lines changed: 45 additions & 153 deletions

File tree

__tests__/module.counter.test.js

Lines changed: 1 addition & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,8 @@
22
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
33
import {
44
hasCounters,
5-
unquoteDoubleStrings,
65
buildCounterContext,
7-
resolveCountersInContent,
8-
deriveCounterCtxForPseudo,
9-
resolvePseudoContent
6+
resolveCountersInContent
107
} from '../src/modules/counter.js'
118

129
beforeEach(() => {
@@ -33,17 +30,6 @@ describe('hasCounters', () => {
3330
})
3431
})
3532

36-
describe('unquoteDoubleStrings', () => {
37-
it('removes double quotes from strings', () => {
38-
expect(unquoteDoubleStrings('"hello"')).toBe('hello')
39-
expect(unquoteDoubleStrings('before "mid" after')).toBe('before mid after')
40-
})
41-
it('handles null/empty', () => {
42-
expect(unquoteDoubleStrings(null)).toBe('')
43-
expect(unquoteDoubleStrings('')).toBe('')
44-
})
45-
})
46-
4733
describe('buildCounterContext', () => {
4834
it('returns get and getStack for a node', () => {
4935
const root = document.createElement('div')
@@ -124,30 +110,6 @@ describe('resolveCountersInContent', () => {
124110
})
125111
})
126112

127-
describe('deriveCounterCtxForPseudo', () => {
128-
it('applies pseudo counter-reset/increment', () => {
129-
const span = document.createElement('span')
130-
span.style.counterReset = 'item 0'
131-
document.body.appendChild(span)
132-
const baseCtx = buildCounterContext(span)
133-
const pseudoStyle = {
134-
counterReset: 'item 5',
135-
counterIncrement: 'item'
136-
}
137-
const derived = deriveCounterCtxForPseudo(span, pseudoStyle, baseCtx)
138-
expect(derived.get(span, 'item')).toBe(6)
139-
})
140-
})
141-
142-
describe('resolvePseudoContent', () => {
143-
it('returns empty for none/normal', () => {
144-
const span = document.createElement('span')
145-
document.body.appendChild(span)
146-
const ctx = buildCounterContext(span)
147-
expect(resolvePseudoContent(span, '::before', ctx)).toBe('')
148-
})
149-
})
150-
151113
describe('formatCounter – negative values (NEW-6)', () => {
152114
it('resolves negative decimal counter as negative string', () => {
153115
// Manually craft a ctx that returns -3 for name 'x'

__tests__/module.pseudo.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,4 +369,36 @@ describe('inlinePseudoElements', () => {
369369
expect(before).toBeTruthy()
370370
expect(before.textContent).toBe('1)')
371371
})
372+
373+
// #19: a pseudo's own counter-set must override the counter value before resolving content.
374+
it('applies counter-set on a pseudo element', async () => {
375+
const ol = document.createElement('ol')
376+
ol.className = 'cset-ol'
377+
const li = document.createElement('li')
378+
li.className = 'cset-li'
379+
li.textContent = 'x'
380+
ol.appendChild(li)
381+
document.body.appendChild(ol)
382+
383+
const style = document.createElement('style')
384+
style.textContent = `
385+
.cset-ol { counter-reset: item; list-style: none; }
386+
.cset-li::before {
387+
counter-set: item 41;
388+
content: counter(item);
389+
}
390+
`
391+
document.head.appendChild(style)
392+
393+
const cloneOl = ol.cloneNode(true)
394+
const cloneLi = cloneOl.firstElementChild
395+
await inlinePseudoElements(li, cloneLi, sessionCache, {})
396+
397+
const before = cloneLi.querySelector('[data-snapdom-pseudo="::before"]')
398+
expect(before).toBeTruthy()
399+
expect(before.textContent).toBe('41')
400+
401+
ol.remove()
402+
style.remove()
403+
})
372404
})

src/core/plugins.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ export function registerPlugins(...defs) {
6363
* @returns {readonly any[]}
6464
*/
6565
function getContextPlugins(context) {
66-
const arr = context && Array.isArray(context.plugins) ? context.plugins : __plugins
67-
return arr || __plugins
66+
return context && Array.isArray(context.plugins) ? context.plugins : __plugins
6867
}
6968

7069
/**
@@ -106,12 +105,6 @@ export async function runAll(name, context, payload) {
106105
return outs
107106
}
108107

109-
/**
110-
* Return a shallow copy of currently registered global plugins.
111-
* @returns {any[]}
112-
*/
113-
export function pluginsList() { return __plugins.slice() }
114-
115108
/** Clear all globally registered plugins (mostly for tests). */
116109
export function clearPlugins() { __plugins.length = 0 }
117110

src/modules/counter.js

Lines changed: 2 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@ export function hasCounters(input) {
1515
return /\bcounter\s*\(|\bcounters\s*\(/.test(input || '')
1616
}
1717

18-
/** Replace every CSS string token "..." with its raw content (keeps single quotes). */
19-
export function unquoteDoubleStrings(s) {
20-
return (s || '').replace(/"([^"]*)"/g, '$1')
21-
}
22-
2318
/**
2419
* a, b, ..., z, aa, ab, ...
2520
* @param {number} n
@@ -255,9 +250,8 @@ export function buildCounterContext(root) {
255250
/**
256251
* Resolves counter()/counters() calls inside a content string for a specific node,
257252
* returning the content with counter() expanded but quoted-string tokens preserved.
258-
* The caller is responsible for joining tokens (see pseudo.js collapseCssContent)
259-
* or stripping quotes (see resolvePseudoContent below) — keeping quotes here lets
260-
* collapseCssContent tokenize properly so source whitespace between adjacent
253+
* The caller (pseudo.js) is responsible for joining tokens and stripping quotes —
254+
* keeping quotes here lets its tokenizer work so source whitespace between adjacent
261255
* tokens (e.g. `counter(x) ")"`) doesn't leak into the rendered text.
262256
*
263257
* @param {string} raw
@@ -289,101 +283,3 @@ export function resolveCountersInContent(raw, node, ctx) {
289283
return '- '
290284
}
291285
}
292-
293-
/**
294-
* Create a derived counter context that applies a pseudo's counter-reset /
295-
* counter-increment *for this node only*, before resolving content.
296-
* Works with ::before / ::after (and any pseudo with content).
297-
*
298-
* @param {Element} node
299-
* @param {CSSStyleDeclaration|null} pseudoStyle getComputedStyle(node, '::before' | '::after')
300-
* @param {{get(node: Element, name: string): number, getStack(node: Element, name: string): number[]}} baseCtx
301-
*/
302-
export function deriveCounterCtxForPseudo(node, pseudoStyle, baseCtx) {
303-
const modStacks = new Map()
304-
305-
/** Parse "a 1, b -2" -> [{name:'a', num:1}, {name:'b', num:-2}] */
306-
function parseListDecl(value) {
307-
const out = []
308-
if (!value || value === 'none') return out
309-
for (const part of String(value).split(',')) {
310-
const toks = part.trim().split(/\s+/)
311-
const name = toks[0]
312-
const num = Number.isFinite(Number(toks[1])) ? Number(toks[1]) : undefined
313-
if (name) out.push({ name, num })
314-
}
315-
return out
316-
}
317-
318-
const resets = parseListDecl(pseudoStyle?.counterReset)
319-
const sets = parseListDecl(pseudoStyle?.counterSet)
320-
const incs = parseListDecl(pseudoStyle?.counterIncrement)
321-
322-
function getStackDerived(name) {
323-
if (modStacks.has(name)) return modStacks.get(name).slice()
324-
325-
// base stack at this node from the element context
326-
let stack = baseCtx.getStack(node, name)
327-
stack = stack.length ? stack.slice() : []
328-
329-
// counter-reset (push if exists, replace if not)
330-
const r = resets.find(x => x.name === name)
331-
if (r) {
332-
const val = Number.isFinite(r.num) ? r.num : 0
333-
if (stack.length) {
334-
stack = stack.slice()
335-
stack.push(val)
336-
} else {
337-
stack = [val]
338-
}
339-
}
340-
341-
// counter-set (set top value without creating a new scope)
342-
const s = sets.find(x => x.name === name)
343-
if (s) {
344-
const val = Number.isFinite(s.num) ? s.num : 0
345-
if (stack.length === 0) stack = [0]
346-
stack[stack.length - 1] = val
347-
}
348-
349-
// counter-increment (on top; create top=0 if missing)
350-
const inc = incs.find(x => x.name === name)
351-
if (inc) {
352-
const by = Number.isFinite(inc.num) ? inc.num : 1
353-
if (stack.length === 0) stack = [0]
354-
stack[stack.length - 1] += by
355-
}
356-
357-
modStacks.set(name, stack.slice())
358-
return stack
359-
}
360-
361-
return {
362-
get(_node, name) {
363-
const s = getStackDerived(name)
364-
return s.length ? s[s.length - 1] : 0
365-
},
366-
getStack(_node, name) {
367-
return getStackDerived(name)
368-
}
369-
}
370-
}
371-
372-
/**
373-
* Convenience helper: resolve the final text to render for a pseudo's `content`,
374-
* correctly applying the pseudo's own counter-reset/increment before evaluation.
375-
*
376-
* @param {Element} node
377-
* @param {'::before'|'::after'} pseudo
378-
* @param {{get(node: Element, name: string): number, getStack(node: Element, name: string): number[]}} baseCtx
379-
* @returns {string} resolved content (without surrounding double quotes)
380-
*/
381-
export function resolvePseudoContent(node, pseudo, baseCtx) {
382-
let ps
383-
try { ps = getComputedStyle(node, pseudo) } catch {}
384-
const raw = ps?.content
385-
if (!raw || raw === 'none' || raw === 'normal') return ''
386-
const derived = deriveCounterCtxForPseudo(node, ps, baseCtx)
387-
let out = resolveCountersInContent(raw, node, derived)
388-
return unquoteDoubleStrings(out)
389-
}

src/modules/pseudo.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,7 @@ function deriveCounterCtxForPseudo(node, pseudoStyle, baseCtx) {
299299
}
300300

301301
const resets = parseListDecl(pseudoStyle?.counterReset)
302+
const sets = parseListDecl(pseudoStyle?.counterSet)
302303
const incs = parseListDecl(pseudoStyle?.counterIncrement)
303304

304305
function getStackDerived(name) {
@@ -313,6 +314,14 @@ function deriveCounterCtxForPseudo(node, pseudoStyle, baseCtx) {
313314
stack = stack.length ? [...stack, val] : [val]
314315
}
315316

317+
// counter-set: fija el valor del top sin crear scope (orden CSS: reset → set → increment)
318+
const s = sets.find(x => x.name === name)
319+
if (s) {
320+
const val = Number.isFinite(s.num) ? s.num : 0
321+
if (stack.length === 0) stack = [0]
322+
stack[stack.length - 1] = val
323+
}
324+
316325
// increment: sobre el top, crear top=0 si no existe
317326
const inc = incs.find(x => x.name === name)
318327
if (inc) {

0 commit comments

Comments
 (0)