Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/repo/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 31 additions & 31 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ Query contexts default to the factory document when omitted. `closest()`, `first

| Method | Result |
| --- | --- |
| [`byClass(cls, context)`](../src/nwsapi.mts#L3864) | Returns elements with the class name. |
| [`byId(id, context)`](../src/nwsapi.mts#L3862) | Returns elements with the ID. Duplicate IDs are allowed by default. |
| [`byTag(tag, context)`](../src/nwsapi.mts#L3863) | Returns elements with the tag name. Use `*` for all elements. |
| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3870) | Returns the nearest match, starting with the element, or `null`. |
| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3872) | Compiles a selector into a resolver function. This is an advanced API. |
| [`configure(option, clear)`](../src/nwsapi.mts#L3873) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. |
| [`emit(message, proto)`](../src/nwsapi.mts#L3875) | Reports an error using the configured error policy. |
| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3866) | Returns the first matching descendant, or `null`. |
| [`install(all)`](../src/nwsapi.mts#L3881) | Replaces native selector methods. Pass `true` to also replace collection methods. |
| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3867) | Returns whether the element matches. |
| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3888) | Adds a relationship between elements using trusted resolver code. |
| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3913) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. |
| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3935) | Adds a selector pattern and a compiler callback that returns `source` and `status`. |
| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3868) | Returns an array of matching descendants, or an empty array. |
| [`uninstall()`](../src/nwsapi.mts#L3882) | Restores the native methods saved by `install()`. |
| [`byClass(cls, context)`](../src/nwsapi.mts#L3865) | Returns elements with the class name. |
| [`byId(id, context)`](../src/nwsapi.mts#L3863) | Returns elements with the ID. Duplicate IDs are allowed by default. |
| [`byTag(tag, context)`](../src/nwsapi.mts#L3864) | Returns elements with the tag name. Use `*` for all elements. |
| [`closest(selectors, element, callback)`](../src/nwsapi.mts#L3871) | Returns the nearest match, starting with the element, or `null`. |
| [`compile(selector, mode, callback, relative)`](../src/nwsapi.mts#L3873) | Compiles a selector into a resolver function. This is an advanced API. |
| [`configure(option, clear)`](../src/nwsapi.mts#L3874) | Reads or changes options. Pass `true` as the second argument to clear compiled selectors. |
| [`emit(message, proto)`](../src/nwsapi.mts#L3876) | Reports an error using the configured error policy. |
| [`first(selectors, context, callback)`](../src/nwsapi.mts#L3867) | Returns the first matching descendant, or `null`. |
| [`install(all)`](../src/nwsapi.mts#L3882) | Replaces native selector methods. Pass `true` to also replace collection methods. |
| [`match(selectors, element, callback)`](../src/nwsapi.mts#L3868) | Returns whether the element matches. |
| [`registerCombinator(combinator, resolver)`](../src/nwsapi.mts#L3889) | Adds a relationship between elements using trusted resolver code. |
| [`registerOperator(operator, resolver)`](../src/nwsapi.mts#L3914) | Adds an attribute operator using a resolver with `p1`, `p2`, and `p3` fields. |
| [`registerSelector(name, rexp, func)`](../src/nwsapi.mts#L3936) | Adds a selector pattern and a compiler callback that returns `source` and `status`. |
| [`select(selectors, context, callback)`](../src/nwsapi.mts#L3869) | Returns an array of matching descendants, or an empty array. |
| [`uninstall()`](../src/nwsapi.mts#L3883) | Restores the native methods saved by `install()`. |

<details>
<summary>Configuration</summary>
Expand Down Expand Up @@ -60,22 +60,22 @@ These exports support extensions and debugging. Prefer query methods and `config

| Member | Purpose |
| --- | --- |
| [`CFG`](../src/nwsapi.mts#L3850) | Contains the compiler syntax settings. |
| [`Config`](../src/nwsapi.mts#L3876) | Contains the active options. Use `configure()` to change them. |
| [`M_BODY`](../src/nwsapi.mts#L3853) | Contains the matching resolver body template. |
| [`M_TEST`](../src/nwsapi.mts#L3857) | Contains the matching resolver test template. |
| [`matchLambdas`](../src/nwsapi.mts#L3842) | Caches compiled matching functions, not DOM results. |
| [`matchResolvers`](../src/nwsapi.mts#L3845) | Caches matching plans, not DOM results. |
| [`N_BODY`](../src/nwsapi.mts#L3854) | Exposes the matching resolver body template. |
| [`N_TEST`](../src/nwsapi.mts#L3858) | Contains the alternate resolver test template. |
| [`Operators`](../src/nwsapi.mts#L3884) | Contains registered attribute operators. |
| [`S_BODY`](../src/nwsapi.mts#L3852) | Contains the selection resolver body template. |
| [`S_TEST`](../src/nwsapi.mts#L3856) | Contains the selection resolver test template. |
| [`selectLambdas`](../src/nwsapi.mts#L3843) | Caches compiled selection functions, not DOM results. |
| [`Selectors`](../src/nwsapi.mts#L3885) | Contains registered selector extensions. |
| [`selectResolvers`](../src/nwsapi.mts#L3846) | Caches selection plans, not DOM results. |
| [`Snapshot`](../src/nwsapi.mts#L3877) | Contains the document state and helpers used by compiled selectors. |
| [`Version`](../src/nwsapi.mts#L3879) | Contains the engine version string. |
| [`CFG`](../src/nwsapi.mts#L3851) | Contains the compiler syntax settings. |
| [`Config`](../src/nwsapi.mts#L3877) | Contains the active options. Use `configure()` to change them. |
| [`M_BODY`](../src/nwsapi.mts#L3854) | Contains the matching resolver body template. |
| [`M_TEST`](../src/nwsapi.mts#L3858) | Contains the matching resolver test template. |
| [`matchLambdas`](../src/nwsapi.mts#L3843) | Caches compiled matching functions, not DOM results. |
| [`matchResolvers`](../src/nwsapi.mts#L3846) | Caches matching plans, not DOM results. |
| [`N_BODY`](../src/nwsapi.mts#L3855) | Exposes the matching resolver body template. |
| [`N_TEST`](../src/nwsapi.mts#L3859) | Contains the alternate resolver test template. |
| [`Operators`](../src/nwsapi.mts#L3885) | Contains registered attribute operators. |
| [`S_BODY`](../src/nwsapi.mts#L3853) | Contains the selection resolver body template. |
| [`S_TEST`](../src/nwsapi.mts#L3857) | Contains the selection resolver test template. |
| [`selectLambdas`](../src/nwsapi.mts#L3844) | Caches compiled selection functions, not DOM results. |
| [`Selectors`](../src/nwsapi.mts#L3886) | Contains registered selector extensions. |
| [`selectResolvers`](../src/nwsapi.mts#L3847) | Caches selection plans, not DOM results. |
| [`Snapshot`](../src/nwsapi.mts#L3878) | Contains the document state and helpers used by compiled selectors. |
| [`Version`](../src/nwsapi.mts#L3880) | Contains the engine version string. |

</details>

Expand Down
15 changes: 0 additions & 15 deletions docs/legacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,6 @@ accessors when needed. Their reference assertions pass without expected-failure
markers. Relative `:has()` queries also use legacy parent traversal for sibling
arguments. Other changes from #167 remain outside this extraction.

### Combining the extracted PRs

Keep these integration changes when their prerequisites land:

- With #194, retain one `classOf` implementation and route its fallback through
`attrOf(e, 'class')`. The modern read table should call the `classOf` helper;
the legacy table should call `legacyClassOf`. Expose those helpers under
distinct Snapshot keys so one does not overwrite the other. The legacy
pseudo-class rewrite must select `legacyClassOf` too. Use `read.id('e')`
for the ID comparison so legacy forms retain their attribute fallback.

These are integration recipes, not claims that the prerequisites are included
in this standalone branch. Validate the combined tree with both the original
legacy assertions and each prerequisite's focused tests.

## Validation

Use Node.js 26 and pnpm ≥ 12.3.4.
Expand Down
31 changes: 16 additions & 15 deletions src/nwsapi.mts
Original file line number Diff line number Diff line change
Expand Up @@ -1290,7 +1290,7 @@
return v + '.id'
},
cls: function (v) {
return v + '.getAttribute("class")'
return 's.classOf(' + v + ')'
},
up: function (v) {
return v + '.parentElement'
Expand All @@ -1316,7 +1316,7 @@
return helper('hId', 'idOf') + '(' + v + ')'
},
cls: function (v) {
return helper('hCls', 'classOf') + '(' + v + ')'
return helper('hCls', 'legacyClassOf') + '(' + v + ')'
},
up: function (v) {
return helper('hUp', 'upOf') + '(' + v + ')'
Expand All @@ -1337,7 +1337,7 @@
helpReads = function (code) {
var reads = {
localName: ['hTag', 'tagOf'],
className: ['hCls', 'classOf'],
className: ['hCls', 'legacyClassOf'],
id: ['hId', 'idOf'],
parentElement: ['hUp', 'upOf'],
nextElementSibling: ['hNext', 'nextOf'],
Expand Down Expand Up @@ -2278,18 +2278,17 @@
// id resolver
case '#':
match = selector.match(Patterns.id)
match[1] = escapeIdentifier(match[1]).replace(
REX.RegExpChar,
'\\$&',
// an exact comparison, which is what the selector asks for.
// escapeIdentifier turns the CSS escapes into JavaScript ones, so
// only the quote is escaped after it.
expr = escapeIdentifier(match[1]).replace(
/\\.|\x22/g,
function (part) {
return part == '"' ? '\\"' : part
},
)
source =
'if((/^' +
match[1] +
'$/.test(' +
read.attr('e', 'id') +
'))){' +
source +
'}'
'if((' + read.id('e') + '=="' + expr + '")){' + source + '}'
break

// class name resolver
Expand Down Expand Up @@ -3753,11 +3752,12 @@
selectResolvers = createCache(),
// passed to resolvers
Snapshot: {
classOf: typeof classOf
attrOf: typeof legacyAttrOf
hasAttrOf: typeof legacyHasAttrOf
tagOf: typeof legacyTagOf
idOf: typeof legacyIdOf
classOf: typeof legacyClassOf
legacyClassOf: typeof legacyClassOf
upOf: typeof legacyUpOf
nextOf: typeof legacyNextOf
prevOf: typeof legacyPrevOf
Expand Down Expand Up @@ -3802,7 +3802,7 @@
hasAttrOf: legacyHasAttrOf,
tagOf: legacyTagOf,
idOf: legacyIdOf,
classOf: legacyClassOf,
legacyClassOf: legacyClassOf,
upOf: legacyUpOf,
nextOf: legacyNextOf,
prevOf: legacyPrevOf,
Expand All @@ -3828,6 +3828,7 @@
isDisabled: isDisabled,
isModal: isModal,
isFullscreen: isFullscreen,
classOf: classOf,
isPictureInPicture: isPictureInPicture,
isPopoverOpen: isPopoverOpen,
isFocusable: isFocusable,
Expand Down
30 changes: 30 additions & 0 deletions test/repo/e2e/upstream/fixtures/property-reads.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<meta charset="utf-8">
<title>Reflected class and ID reads</title>
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<body>
<script>
test(() => {
const engine = NW.Dom;
const resolver = engine.compile('.item', false);
for (const className of ['item', { baseVal: 'item' }]) {
assert_true(resolver({ className, getAttribute() { throw Error('Unnecessary attribute read'); } }, undefined, undefined, false));
}
assert_true(resolver({ getAttribute(name) { assert_equals(name, 'class'); return 'item'; } }, undefined, undefined, false));
for (const namespace of ['http://www.w3.org/1999/xhtml', 'http://www.w3.org/2000/svg']) {
const target = document.createElementNS(namespace, namespace.endsWith('svg') ? 'g' : 'p');
target.setAttribute('class', 'item');
assert_true(engine.match('.item', target));
target.setAttribute('class', 'other');
assert_false(engine.match('.item', target));
for (const id of ['a.b', 'a$b', 'a"b', 'a\\b', '🦄']) {
target.id = id;
const selector = '#' + CSS.escape(id);
assert_true(engine.match(selector, target), selector);
target.id += 'suffix';
assert_false(engine.match(selector, target), selector);
}
}
}, 'Property reads preserve HTML and SVG classes, attribute fallbacks, and escaped IDs');
</script>
5 changes: 5 additions & 0 deletions test/repo/e2e/upstream/manifest.mts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export const manifest: Array<{
install?: boolean
legacyMap?: boolean
}> = [
{
path: '/_repo/test/repo/e2e/upstream/fixtures/property-reads.html',
note: 'local WPT regression: reflected classes, SVG base values, attribute fallbacks, and escaped IDs',
install: false,
},
{
path: '/_repo/test/repo/e2e/upstream/fixtures/attribute-equality.html',
note: 'local WPT regression: exact attribute values, escapes, case rules, and custom operators',
Expand Down
53 changes: 53 additions & 0 deletions test/repo/unit/property-reads.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { JSDOM } from 'jsdom'
import { expect, test } from 'vitest'
import factory from '../../../src/nwsapi.js'

test('class matching uses HTML and SVG reflections before reading attributes', t => {
const { window } = new JSDOM('')
t.onTestFinished(() => window.close())
const engine = factory(window)
const resolver = engine.compile('.item', false)!
for (const className of ['item', { baseVal: 'item' }]) {
const target = {
className,
getAttribute() {
throw Error('Unnecessary attribute read')
},
}
expect(resolver(target, undefined, undefined, false)).toBe(true)
}
let reads = 0
const target = {
getAttribute(name: string) {
expect(name).toBe('class')
reads++
return 'item'
},
}
expect(resolver(target, undefined, undefined, false)).toBe(true)
expect(reads).toBe(1)
})

test('ID comparisons preserve escaped punctuation without attribute calls', t => {
const { window } = new JSDOM('')
t.onTestFinished(() => window.close())
const engine = factory(window)
for (const [id, selector] of [
['a.b', '#a\\.b'],
['a$b', '#a\\$b'],
['a"b', '#a\\22 b'],
['a"b', '#a\\"b'],
['a\\b', '#a\\5c b'],
]) {
const target = {
id,
getAttribute() {
throw Error('Unnecessary attribute read')
},
}
const resolver = engine.compile(selector, false)!
expect(resolver(target, undefined, undefined, false)).toBe(true)
target.id += 'suffix'
expect(resolver(target, undefined, undefined, false)).toBe(false)
}
})