From 7ea402f1aef7d40cd1b1c978f05e40518a3e2389 Mon Sep 17 00:00:00 2001 From: jdalton Date: Sun, 6 Sep 2026 00:36:04 -0400 Subject: [PATCH] Preserve quoted attributes after pseudo-classes --- src/nwsapi.mts | 9 +++++-- test/attribute-after-pseudo.test.mts | 37 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 test/attribute-after-pseudo.test.mts diff --git a/src/nwsapi.mts b/src/nwsapi.mts index f1748103..69b9974a 100644 --- a/src/nwsapi.mts +++ b/src/nwsapi.mts @@ -1001,7 +1001,10 @@ '(?:[.#]?' + identifier + ')|' + '(?:' + attributes + ')' + ')+|' + - '(?:' + WSP + '?[>+~][^>+~]' + WSP + '?)|' + + // the combinator is only recognized, not consumed: taking the + // character after it swallows the '[' of a following attribute + // selector, which then cannot be parsed + '(?:' + WSP + '?[>+~](?=[^>+~])' + WSP + '?)|' + '(?:' + WSP + '?,' + WSP + '?)|' + '(?:' + WSP + '?)|' + '(?:\\x29|$)' + @@ -1939,7 +1942,9 @@ if (Config.FORGIVING) { // forgiving pseudos allow to continue even after parse errors if (!(parsed.includes(':is(') || parsed.includes(':where('))) { - emit('\'' + selectors + '\'' + qsInvalid); + // 'selectors' holds the fragments the validator did match, + // which read as a mangled selector once joined by String() + emit('\'' + parsed + '\'' + qsInvalid); return Config.VERBOSITY ? undefined : (type ? none : false); } // The validator cannot read this selector, but it holds a diff --git a/test/attribute-after-pseudo.test.mts b/test/attribute-after-pseudo.test.mts new file mode 100644 index 00000000..2436d15b --- /dev/null +++ b/test/attribute-after-pseudo.test.mts @@ -0,0 +1,37 @@ +import { test, expect } from 'vitest' +import { JSDOM } from 'jsdom' +import { createRequire } from 'node:module' + +const require = createRequire(import.meta.url) +const factory = require('../src/nwsapi.js') + +for (const quote of ["'", '"']) { + for (const combinator of ['+', '~', '>']) { + test(`quoted attributes after pseudos (${quote}, ${combinator})`, t => { + const markup = + combinator === '>' + ? '

' + : '

text

' + const { window } = new JSDOM(markup) + t.onTestFinished(() => window.close()) + const engine = factory(window) + const selector = `[class*=${quote}a${quote} i]:not(:empty) ${combinator} [class*=${quote}b${quote}]` + const target = window.document.getElementById('target') + for (let repeat = 0; repeat < 2; repeat++) { + expect(engine.select(selector, window.document)).toEqual([target]) + expect(engine.first(selector, window.document)).toBe(target) + expect(engine.match(selector, target)).toBe(true) + } + target.className = 'c' + expect(engine.select(selector, window.document)).toEqual([]) + }) + } +} + +test('invalid selectors retain their original text in errors', t => { + const { window } = new JSDOM('

') + t.onTestFinished(() => window.close()) + const engine = factory(window) + const selector = "[class*='a' i]:not(:empty)+[class*='b'] ?" + expect(() => engine.select(selector, window.document)).toThrow(selector) +})