@thomasseanfahey/domq 0.1.2 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-node@v4
15
+ with:
16
+ node-version: 20
17
+ - run: npm ci
18
+ - run: npm test
19
+ - run: npm run build:types
@@ -0,0 +1,25 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-node@v4
18
+ with:
19
+ node-version: 20
20
+ registry-url: https://registry.npmjs.org/
21
+ - run: npm ci
22
+ - run: npm run build:types
23
+ - run: npm publish --access public
24
+ env:
25
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026
3
+ Copyright (c) 2026 Thomas Fahey
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
18
  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # domq
1
+ # @thomasseanfahey/domq
2
2
 
3
3
  Query DOM relationships, not selectors.
4
4
 
@@ -0,0 +1,64 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>@thomasseanfahey/domq demo</title>
7
+ <style>
8
+ body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; padding: 16px; }
9
+ .card { border: 1px solid #ccc; padding: 12px; margin: 12px 0; border-radius: 8px; }
10
+ .row { display: flex; gap: 12px; align-items: center; }
11
+ .pill { display: inline-block; padding: 2px 8px; border: 1px solid #aaa; border-radius: 999px; font-size: 12px; }
12
+ button { padding: 8px 12px; }
13
+ pre { background: #111; color: #eee; padding: 12px; border-radius: 8px; overflow: auto; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <h1>@thomasseanfahey/domq demo</h1>
18
+ <p>This page uses an import map to load <code>domq</code> directly from <code>./src</code> (no bundler).</p>
19
+
20
+ <div class="card" data-card="a">
21
+ <div class="row">
22
+ <span class="pill" data-kind="tag" data-x="1">tag</span>
23
+ <span class="pill" data-kind="tag" data-x="2">tag2</span>
24
+ </div>
25
+ <div class="row" style="margin-top: 10px;">
26
+ <button class="cta" data-action="secondary">Secondary</button>
27
+ <button class="cta" data-action="primary">Primary</button>
28
+ </div>
29
+ </div>
30
+
31
+ <pre id="out"></pre>
32
+
33
+ <script type="importmap">
34
+ {
35
+ "imports": {
36
+ "domq": "../src/index.js",
37
+ "domq/shadow": "../src/shadow.js",
38
+ "domq/extra": "../src/extra.js"
39
+ }
40
+ }
41
+ </script>
42
+
43
+ <script type="module">
44
+ import { dq } from "@thomasseanfahey/domq";
45
+
46
+ const out = document.querySelector("#out");
47
+ const start = document.querySelector('[data-kind="tag"]');
48
+
49
+ const q = dq(start)
50
+ .closest("[data-card]")
51
+ .descendants()
52
+ .where(dq.attr("data-action").eq("primary"));
53
+
54
+ const el = q.one();
55
+ el.addEventListener("click", () => alert("Primary clicked"));
56
+
57
+ out.textContent = [
58
+ `Pipeline: ${q.explain()}`,
59
+ `Found: ${el.tagName.toLowerCase()}[data-action=${el.getAttribute("data-action")}]`,
60
+ `Count in card: ${dq(start).closest("[data-card]").descendants().count()}`
61
+ ].join("\n");
62
+ </script>
63
+ </body>
64
+ </html>
package/package.json CHANGED
@@ -1,58 +1,24 @@
1
1
  {
2
2
  "name": "@thomasseanfahey/domq",
3
- "version": "0.1.2",
3
+ "version": "0.1.6",
4
4
  "description": "Query DOM relationships, not selectors. Composable traversal + predicates with deterministic semantics and lazy evaluation.",
5
5
  "type": "module",
6
- "types": "./dist/index.d.ts",
7
6
  "exports": {
8
- ".": {
9
- "types": "./dist/index.d.ts",
10
- "default": "./src/index.js"
11
- },
12
- "./shadow": {
13
- "types": "./dist/shadow.d.ts",
14
- "default": "./src/shadow.js"
15
- },
16
- "./extra": {
17
- "types": "./dist/extra.d.ts",
18
- "default": "./src/extra.js"
19
- }
7
+ ".": "./src/index.js",
8
+ "./shadow": "./src/shadow.js",
9
+ "./extra": "./src/extra.js"
20
10
  },
21
- "files": [
22
- "src",
23
- "dist",
24
- "README.md",
25
- "LICENSE"
26
- ],
27
11
  "sideEffects": false,
28
12
  "engines": {
29
13
  "node": ">=18"
30
14
  },
31
- "keywords": [
32
- "dom",
33
- "dom-traversal",
34
- "dom-query",
35
- "browser",
36
- "browser-extension",
37
- "scraping",
38
- "testing",
39
- "shadow-dom",
40
- "predicate",
41
- "query"
42
- ],
43
15
  "scripts": {
44
16
  "test": "node --test",
45
- "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true });\"",
46
- "build:types": "npm run clean && npx -y tsc -p tsconfig.json",
47
- "prepack": "npm run build:types",
48
- "prepublishOnly": "npm test && npm run build:types",
49
- "pack:check": "npm pack --dry-run"
17
+ "build:types": "npx -y tsc -p tsconfig.json"
50
18
  },
51
19
  "devDependencies": {
52
- "typescript": "^5.6.3"
20
+ "typescript": "^5.6.3",
21
+ "happy-dom": "^15.10.2"
53
22
  },
54
- "license": "MIT",
55
- "publishConfig": {
56
- "access": "public"
57
- }
58
- }
23
+ "license": "MIT"
24
+ }
@@ -0,0 +1,38 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDom } from './helpers/dom.js';
4
+
5
+ test('budget maxNodes triggers DOMQ_BUDGET_NODES', async () => {
6
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
7
+ const { dq } = await import('../src/index.js');
8
+
9
+ const root = document.getElementById('root');
10
+
11
+ assert.throws(() => {
12
+ dq(root).budget({ maxNodes: 3 }).descendants().toArray();
13
+ }, (err) => {
14
+ assert.equal(err.code, 'DOMQ_BUDGET_NODES');
15
+ assert.match(err.message, /maxNodes=3/);
16
+ return true;
17
+ });
18
+
19
+ cleanup();
20
+ });
21
+
22
+ test('compile() builds reusable query functions', async () => {
23
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
24
+ const { dq } = await import('../src/index.js');
25
+
26
+ const getPrimary = dq.compile(q =>
27
+ q.closest('section')
28
+ .descendants()
29
+ .where(dq.attr('data-action').eq('primary'))
30
+ .one()
31
+ );
32
+
33
+ const a2 = document.getElementById('a2');
34
+ const btn = getPrimary(a2);
35
+ assert.equal(btn.id, 'btn');
36
+
37
+ cleanup();
38
+ });
@@ -0,0 +1,44 @@
1
+ import { Window } from 'happy-dom';
2
+
3
+ export function createDom(html = '<!doctype html><html><head></head><body></body></html>') {
4
+ const win = new Window({ url: 'https://example.test/' });
5
+ const doc = win.document;
6
+
7
+ doc.write(html);
8
+ doc.close();
9
+
10
+ const prev = {
11
+ window: globalThis.window,
12
+ document: globalThis.document,
13
+ Node: globalThis.Node,
14
+ Element: globalThis.Element,
15
+ HTMLElement: globalThis.HTMLElement,
16
+ ShadowRoot: globalThis.ShadowRoot,
17
+ HTMLSlotElement: globalThis.HTMLSlotElement,
18
+ getComputedStyle: globalThis.getComputedStyle,
19
+ };
20
+
21
+ globalThis.window = win;
22
+ globalThis.document = doc;
23
+ globalThis.Node = win.Node;
24
+ globalThis.Element = win.Element;
25
+ globalThis.HTMLElement = win.HTMLElement;
26
+ globalThis.ShadowRoot = win.ShadowRoot;
27
+ globalThis.HTMLSlotElement = win.HTMLSlotElement;
28
+ globalThis.getComputedStyle = win.getComputedStyle.bind(win);
29
+
30
+ return {
31
+ window: win,
32
+ document: doc,
33
+ cleanup() {
34
+ globalThis.window = prev.window;
35
+ globalThis.document = prev.document;
36
+ globalThis.Node = prev.Node;
37
+ globalThis.Element = prev.Element;
38
+ globalThis.HTMLElement = prev.HTMLElement;
39
+ globalThis.ShadowRoot = prev.ShadowRoot;
40
+ globalThis.HTMLSlotElement = prev.HTMLSlotElement;
41
+ globalThis.getComputedStyle = prev.getComputedStyle;
42
+ }
43
+ };
44
+ }
@@ -0,0 +1,55 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDom } from './helpers/dom.js';
4
+
5
+ test('extra plugin installs expensive predicates', async () => {
6
+ const { document, cleanup } = createDom(`
7
+ <!doctype html><html><body>
8
+ <div id="v1" style="display:block;opacity:1; width: 100px; height: 20px;">X</div>
9
+ <div id="v2" style="display:none;opacity:1;">Y</div>
10
+ </body></html>
11
+ `);
12
+
13
+ const { dq, extra } = await import('../src/index.js');
14
+ dq.use(extra);
15
+
16
+ const v1 = document.getElementById('v1');
17
+ const v2 = document.getElementById('v2');
18
+
19
+ // happy-dom does not perform layout, so getClientRects() often returns empty.
20
+ // Stub it to emulate "has boxes" for a visible element.
21
+ v1.getClientRects = () => ([{ left: 0, top: 0, right: 100, bottom: 20, width: 100, height: 20 }]);
22
+
23
+ assert.equal(dq(v1).where(dq.visible()).exists(), true);
24
+ assert.equal(dq(v2).where(dq.visible()).exists(), false);
25
+
26
+ assert.equal(dq(v2).where(dq.style('display').eq('none')).exists(), true);
27
+
28
+ cleanup();
29
+ });
30
+
31
+ test('shadow plugin adds composed traversal relations', async () => {
32
+ const { document, cleanup } = createDom(`
33
+ <!doctype html><html><body>
34
+ <div id="host"></div>
35
+ </body></html>
36
+ `);
37
+
38
+ const { dq, shadow } = await import('../src/index.js');
39
+ dq.use(shadow);
40
+
41
+ const host = document.getElementById('host');
42
+ const sr = host.attachShadow({ mode: 'open' });
43
+
44
+ const inside = document.createElement('span');
45
+ inside.id = 'inside';
46
+ sr.appendChild(inside);
47
+
48
+ const found = dq(host).composedDescendants().where(dq.tag('span')).one();
49
+ assert.equal(found.id, 'inside');
50
+
51
+ const anc = dq(inside).composedAncestors().first();
52
+ assert.equal(anc.id, 'host');
53
+
54
+ cleanup();
55
+ });
@@ -0,0 +1,59 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDom } from './helpers/dom.js';
4
+
5
+ test('attr/data/dataset/text/value predicates', async () => {
6
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
7
+ const { dq } = await import('../src/index.js');
8
+
9
+ const a = document.getElementById('a');
10
+ const inp = document.getElementById('inp');
11
+ const a1 = document.getElementById('a1');
12
+
13
+ assert.equal(dq(a).where(dq.attr('data-x').eq('1')).one().id, 'a');
14
+ assert.equal(dq(a).where(dq.attr('missing').exists()).count(), 0);
15
+
16
+ assert.equal(dq(inp).where(dq.data('kebab-case').eq('ok')).one().id, 'inp');
17
+ assert.equal(dq(inp).where(dq.dataset('kebab-case').eq('ok')).one().id, 'inp');
18
+
19
+ assert.equal(dq(a1).where(dq.text().eq('Hello world')).one().id, 'a1');
20
+ assert.equal(dq(a1).where(dq.text().includes('world')).one().id, 'a1');
21
+
22
+ assert.equal(dq(inp).where(dq.value().eq('yes')).one().id, 'inp');
23
+
24
+ cleanup();
25
+ });
26
+
27
+ test('matches/tag/hasClass/role predicates', async () => {
28
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
29
+ const { dq } = await import('../src/index.js');
30
+
31
+ const btn = document.getElementById('btn');
32
+
33
+ assert.equal(dq(btn).where(dq.matches('button.cta')).one().id, 'btn');
34
+ assert.equal(dq(btn).where(dq.tag('button')).one().id, 'btn');
35
+ assert.equal(dq(btn).where(dq.hasClass('primary')).one().id, 'btn');
36
+
37
+ btn.setAttribute('role', 'dialog');
38
+ assert.equal(dq(btn).where(dq.role('dialog')).one().id, 'btn');
39
+
40
+ cleanup();
41
+ });
42
+
43
+ test('and/or/not combinators', async () => {
44
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
45
+ const { dq } = await import('../src/index.js');
46
+
47
+ const s1 = document.getElementById('s1');
48
+
49
+ const q = dq(s1).descendants().where(dq.and(dq.tag('span'), dq.attr('data-kind').eq('leaf')));
50
+ assert.deepEqual(q.map(el => el.id), ['a1', 'a2']);
51
+
52
+ const q2 = dq(s1).descendants().where(dq.or(dq.attr('data-x').eq('1'), dq.attr('data-x').eq('2')));
53
+ assert.deepEqual(q2.map(el => el.id), ['a', 'a2']);
54
+
55
+ const q3 = dq(s1).descendants().where(dq.not(dq.tag('div')));
56
+ assert.ok(q3.map(el => el.id).includes('btn'));
57
+
58
+ cleanup();
59
+ });
@@ -0,0 +1,92 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDom } from './helpers/dom.js';
4
+
5
+ test('ancestors() yields nearest-first and respects maxDepth', async () => {
6
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
7
+ const { dq } = await import('../src/index.js');
8
+
9
+ const a1 = document.getElementById('a1');
10
+
11
+ const ids = dq(a1).ancestors().map(el => el.id);
12
+ assert.deepEqual(ids.slice(0, 3), ['a', 's1', 'root']);
13
+
14
+ const limited = dq(a1).budget({ maxDepth: 2 }).ancestors().map(el => el.id);
15
+ assert.deepEqual(limited, ['a', 's1']);
16
+
17
+ cleanup();
18
+ });
19
+
20
+ test('descendants() yields preorder and respects maxDepth', async () => {
21
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
22
+ const { dq } = await import('../src/index.js');
23
+
24
+ const s1 = document.getElementById('s1');
25
+
26
+ const ids = dq(s1).descendants().map(el => el.id);
27
+ assert.deepEqual(ids, ['a', 'a1', 'a2', 'inp', 'b', 'btn']);
28
+
29
+ const limited = dq(s1).budget({ maxDepth: 1 }).descendants().map(el => el.id);
30
+ assert.deepEqual(limited, ['a', 'b']);
31
+
32
+ cleanup();
33
+ });
34
+
35
+ test('siblings()/followingSiblings()/precedingSiblings() ordering', async () => {
36
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
37
+ const { dq } = await import('../src/index.js');
38
+
39
+ const a = document.getElementById('a');
40
+ const b = document.getElementById('b');
41
+
42
+ assert.deepEqual(dq(a).siblings().map(x => x.id), ['b']);
43
+ assert.deepEqual(dq(a).followingSiblings().map(x => x.id), ['b']);
44
+ assert.deepEqual(dq(b).precedingSiblings().map(x => x.id), ['a']);
45
+
46
+ cleanup();
47
+ });
48
+
49
+ test('closest() and find() work with selectors', async () => {
50
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
51
+ const { dq } = await import('../src/index.js');
52
+
53
+ const btn = document.getElementById('btn');
54
+
55
+ const section = dq(btn).closest('section').one();
56
+ assert.equal(section.id, 's1');
57
+
58
+ const leaves = dq(section).find('[data-kind="leaf"]').map(el => el.id);
59
+ assert.deepEqual(leaves, ['a1', 'a2']);
60
+
61
+ cleanup();
62
+ });
63
+
64
+ test('within() yields closest selector match and can be composed', async () => {
65
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
66
+ const { dq } = await import('../src/index.js');
67
+
68
+ const a2 = document.getElementById('a2');
69
+
70
+ const primaryBtn =
71
+ dq(a2)
72
+ .within('section')
73
+ .descendants()
74
+ .where(dq.attr('data-action').eq('primary'))
75
+ .one();
76
+
77
+ assert.equal(primaryBtn.id, 'btn');
78
+
79
+ cleanup();
80
+ });
81
+
82
+ test('until() walks ancestors until boundary (exclusive)', async () => {
83
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
84
+ const { dq } = await import('../src/index.js');
85
+
86
+ const a1 = document.getElementById('a1');
87
+
88
+ const ids = dq(a1).until('#root').map(el => el.id);
89
+ assert.deepEqual(ids, ['a', 's1']);
90
+
91
+ cleanup();
92
+ });
@@ -0,0 +1,76 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDom } from './helpers/dom.js';
4
+
5
+ test('first/exists/at/take/skip/slice/reverse', async () => {
6
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
7
+ const { dq } = await import('../src/index.js');
8
+
9
+ const s1 = document.getElementById('s1');
10
+
11
+ assert.equal(dq(s1).descendants().first().id, 'a');
12
+ assert.equal(dq(s1).descendants().exists(), true);
13
+
14
+ assert.equal(dq(s1).descendants().at(1).one().id, 'a1');
15
+ assert.equal(dq(s1).descendants().at(-1).one().id, 'btn');
16
+
17
+ assert.deepEqual(dq(s1).descendants().take(2).map(el => el.id), ['a', 'a1']);
18
+ assert.deepEqual(dq(s1).descendants().skip(4).map(el => el.id), ['b', 'btn']);
19
+
20
+ assert.deepEqual(dq(s1).descendants().slice(1, 3).map(el => el.id), ['a1', 'a2']);
21
+ assert.deepEqual(dq(s1).descendants().take(3).reverse().map(el => el.id), ['a2', 'a1', 'a']);
22
+
23
+ cleanup();
24
+ });
25
+
26
+ test('unique() dedupes repeated nodes across relation compositions', async () => {
27
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
28
+ const { dq } = await import('../src/index.js');
29
+
30
+ const s1 = document.getElementById('s1');
31
+
32
+ const repeated = dq(s1).descendants().ancestors().where(dq.tag('section'));
33
+ assert.ok(repeated.count() > 1);
34
+
35
+ const uniq = repeated.unique().map(el => el.id);
36
+ assert.deepEqual(uniq, ['s1']);
37
+
38
+ cleanup();
39
+ });
40
+
41
+ test('one() and maybeOne() throw with context on invalid cardinality', async () => {
42
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
43
+ const { dq } = await import('../src/index.js');
44
+
45
+ const s1 = document.getElementById('s1');
46
+
47
+ assert.throws(() => dq(s1).descendants().where(dq.tag('span')).one(), (err) => {
48
+ assert.equal(err.code, 'DOMQ_ONE');
49
+ assert.match(err.message, /Pipeline:/);
50
+ return true;
51
+ });
52
+
53
+ assert.throws(() => dq(s1).descendants().where(dq.tag('div')).maybeOne(), (err) => {
54
+ assert.equal(err.code, 'DOMQ_MAYBEONE');
55
+ assert.match(err.message, /Pipeline:/);
56
+ return true;
57
+ });
58
+
59
+ cleanup();
60
+ });
61
+
62
+ test('explain() returns a readable pipeline string', async () => {
63
+ const { document, cleanup } = createDom('<!doctype html>\n<html>\n <body>\n <div id="root" data-root="1">\n <section id="s1" class="section">\n <div id="a" class="box" data-x="1">\n <span id="a1" data-kind="leaf"> Hello world </span>\n <span id="a2" data-kind="leaf" data-x="2">Second</span>\n <input id="inp" type="text" value="yes" data-kebab-case="ok" />\n </div>\n <div id="b" class="box">\n <button id="btn" class="cta primary" data-action="primary">Click</button>\n </div>\n </section>\n <section id="s2" class="section">\n <div id="c" class="box">\n <span id="c1" data-kind="leaf">Third</span>\n </div>\n </section>\n </div>\n </body>\n</html>\n');
64
+ const { dq } = await import('../src/index.js');
65
+
66
+ const a1 = document.getElementById('a1');
67
+ const q = dq(a1).ancestors().where(dq.tag('section')).at(0);
68
+
69
+ const s = q.explain();
70
+ assert.match(s, /from\(/);
71
+ assert.match(s, /ancestors/);
72
+ assert.match(s, /where\(tag\("section"\)\)/);
73
+ assert.match(s, /at\(0\)/);
74
+
75
+ cleanup();
76
+ });
@@ -0,0 +1,19 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createDom } from './helpers/dom.js';
4
+
5
+ test('exports exist and plugins are available', async () => {
6
+ const { cleanup } = createDom();
7
+ const { dq, extra, shadow } = await import('../src/index.js');
8
+
9
+ assert.equal(typeof dq, 'function');
10
+ assert.equal(typeof dq.attr, 'function');
11
+ assert.equal(typeof dq.compile, 'function');
12
+ assert.ok(dq.relations);
13
+ assert.equal(typeof dq.relations.ancestors, 'function');
14
+
15
+ assert.equal(typeof extra, 'function');
16
+ assert.equal(typeof shadow, 'function');
17
+
18
+ cleanup();
19
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowJs": true,
7
+ "checkJs": false,
8
+ "declaration": true,
9
+ "emitDeclarationOnly": true,
10
+ "declarationMap": false,
11
+ "outDir": "dist",
12
+ "stripInternal": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src/**/*.js"]
16
+ }
package/dist/core/dq.d.ts DELETED
@@ -1,7 +0,0 @@
1
- export function dq(node: any): Query;
2
- export namespace dq {
3
- function all(iterable: any): Query;
4
- function compile(builder: any): (start: any, options?: any) => any;
5
- function use(plugin: any): void;
6
- }
7
- import { Query } from './query.js';
@@ -1,32 +0,0 @@
1
- export function predicate(fn: any, describe?: string): any;
2
- export function and(...preds: any[]): any;
3
- export function or(...preds: any[]): any;
4
- export function not(pred: any): any;
5
- export function attr(name: any): ComparatorBuilder;
6
- export function data(key: any): ComparatorBuilder;
7
- export function dataset(key: any): ComparatorBuilder;
8
- export function text(): ComparatorBuilder;
9
- export function ownText(): ComparatorBuilder;
10
- export function value(): ComparatorBuilder;
11
- export function tag(tagName: any): any;
12
- export function hasClass(className: any): any;
13
- export function matches(selector: any): any;
14
- export function role(roleName: any): any;
15
- declare class ComparatorBuilder {
16
- constructor(kind: any, getter: any);
17
- _kind: any;
18
- _getter: any;
19
- exists(): any;
20
- eq(value: any): any;
21
- ne(value: any): any;
22
- includes(substr: any): any;
23
- startsWith(prefix: any): any;
24
- endsWith(suffix: any): any;
25
- matches(re: any): any;
26
- truthy(): any;
27
- gt(n: any): any;
28
- gte(n: any): any;
29
- lt(n: any): any;
30
- lte(n: any): any;
31
- }
32
- export {};
@@ -1,44 +0,0 @@
1
- export class Query {
2
- static from(node: any): Query;
3
- static fromAll(iterable: any): Query;
4
- static installBaseRelations(relations: any): void;
5
- constructor(iterableFactory: any, steps: any);
6
- _iterableFactory: any;
7
- _steps: any;
8
- _budget: {};
9
- _debugEnabled: boolean;
10
- _debugLabel: any;
11
- get(relation: any, ...args: any[]): Query;
12
- where(pred: any): Query;
13
- not(pred: any): Query;
14
- unique(): Query;
15
- uniqueBy(keyFn: any): Query;
16
- reverse(): Query;
17
- budget(budget: any): this;
18
- debug(label?: boolean): this;
19
- explain(): any;
20
- first(): any;
21
- exists(): boolean;
22
- one(): any;
23
- maybeOne(): any;
24
- at(n: any): Query;
25
- take(n: any): Query;
26
- skip(n: any): Query;
27
- slice(start: any, end: any): Query;
28
- count(): number;
29
- toArray(): any[];
30
- map(fn: any): any[];
31
- _clone(iterableFactory: any, steps: any): Query;
32
- _evaluate(): {
33
- ctx: {
34
- budget: {};
35
- debugEnabled: boolean;
36
- debugLabel: any;
37
- visitedNodes: number;
38
- startedAt: number;
39
- };
40
- iterable: Generator<any, void, unknown>;
41
- };
42
- _maybeDebug(ctx: any, matchCount: any): void;
43
- }
44
- export function checkBudget(ctx: any): void;
@@ -1,14 +0,0 @@
1
- export const self: any;
2
- export const parent: any;
3
- export const ancestors: any;
4
- export const children: any;
5
- export const descendants: any;
6
- export const siblings: any;
7
- export const followingSiblings: any;
8
- export const precedingSiblings: any;
9
- export const next: any;
10
- export const prev: any;
11
- export const closest: any;
12
- export const find: any;
13
- export const within: any;
14
- export const until: any;
@@ -1,7 +0,0 @@
1
- export function toElement(node: any): any;
2
- export function toIterable(iterable: any): any;
3
- export function describeElement(el: any): string;
4
- export function normaliseText(s: any): string;
5
- export function getOwnText(el: any): string;
6
- export function getValue(el: any): string;
7
- export function safeMatches(el: any, selector: any): any;
package/dist/extra.d.ts DELETED
@@ -1 +0,0 @@
1
- export { extra } from "./plugins/extra.js";
package/dist/index.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export { dq } from "./core/dq.js";
2
- export { shadow } from "./plugins/shadow.js";
3
- export { extra } from "./plugins/extra.js";
@@ -1 +0,0 @@
1
- export function extra(dq: any): void;
@@ -1 +0,0 @@
1
- export function shadow(dq: any, Query: any): void;
package/dist/shadow.d.ts DELETED
@@ -1 +0,0 @@
1
- export { shadow } from "./plugins/shadow.js";