diff --git a/.gitpod.yml b/.gitpod.yml deleted file mode 100644 index fb2b7cf..0000000 --- a/.gitpod.yml +++ /dev/null @@ -1,61 +0,0 @@ -image: nberlette/gitpod-enhanced:latest - -tasks: - - # WARNING: remove this if you share your workspaces! - before: | - cd "$GITPOD_REPO_ROOT" || exit $?; - [ -n "$DOTENV_VAULT" ] && [ ! -e .env.vault ] && - echo "DOTENV_VAULT=$DOTENV_VAULT" > .env.vault; - [ -n "$DOTENV_ME" ] && [ ! -e .env.me ] && - echo "DOTENV_ME=$DOTENV_ME" > .env.me; - if [ -e .env.me ] && [ -e .env.vault ] && [ ! -e .env ]; then - which dotenv-vault &>/dev/null && - dotenv-vault pull || npx -y dotenv-vault@latest pull; - # expose the .env variables to current environment - [ -e .env ] && { set -a; source .env; set +a; } - fi - # make sure we have deno installed - init: | - export DENO_INSTALL_ROOT="$HOME/.deno/bin" - export PATH="$DENO_INSTALL_ROOT:$PATH" - which deno &>/dev/null || brew install deno --quiet --overwrite - gp sync-done listo - - # proceed once we are ready - init: gp sync-await listo - command: deno task dev 2>&1 || deno task - -ports: - - name: "Develop" - port: 8000 - visibility: private - onOpen: open-preview - - name: "Preview" - port: 8080 - visibility: public - onOpen: notify - -github: - prebuilds: - master: true - branches: true - pullRequests: true - pullRequestsFromForks: true - addLabel: true - addBadge: true - addCheck: true - -vscode: - extensions: - - github.copilot-nightly - - GitHub.copilot-labs - - denoland.vscode-deno - - vsls-contrib.gistfs - - github.vscode-codeql - - cschleiden.vscode-github-actions - - editorconfig.editorconfig - - jock.svg - - antfu.iconify - - antfu.unocss - - redhat.vscode-yaml - - jacano.vscode-pnpm - - christian-kohler.path-intellisense diff --git a/README.md b/README.md index 6fe23b7..8c61a28 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # [🦕 DQL](https://deno.land/x/dql) -### _**Web Scraping with Deno  –  DOM + GraphQL**_ +### _**Web Scraping with Deno  –  DOM + GraphQL** (beta)_ @@ -16,7 +16,7 @@ - [x] Modular project structure (as opposed to a mostly single-file design) - [x] Improved types and schema structure -> **Note**: _This is a work-in-progress and there is still a lot to be done._ +> **Warning**: _**This project is currently in **beta**. The pre-1.0 API may experience breaking changes between versions, and you are encouraged to pin your versions.**_ ### 🛝  [**`GraphQL Playground`**](https://dql.deno.dev) diff --git a/deno.json b/deno.json index d0c3cbf..0d7a9fc 100644 --- a/deno.json +++ b/deno.json @@ -1,46 +1,20 @@ { - "importMap": "./import_map.json", "compilerOptions": { - "strict": true, - "experimentalDecorators": true, "lib": [ - "deno.window", - "deno.ns", + "deno.unstable", "dom", "dom.iterable", "dom.asynciterable" ], "types": [ "https://deno.land/x/graphql_deno@v15.0.0/mod.ts", - "https://deno.land/x/deno_dom@v0.1.31-alpha/deno-dom-wasm.ts", - "https://deno.land/x/p_queue@1.0.1/mod.ts", "./lib/types.d.ts" ] }, "fmt": { - "files": { - "exclude": [ - ".devcontainer", - ".git*", - ".vscode", - "*.md", - "LICENSE" - ] - }, - "options": { - "proseWrap": "preserve" - } + "proseWrap": "preserve" }, "lint": { - "files": { - "exclude": [ - ".devcontainer", - ".git*", - ".vscode", - "*.md", - "LICENSE" - ] - }, "rules": { "exclude": [ "no-explicit-any" @@ -48,11 +22,27 @@ } }, "tasks": { - "dev": "deno run -A --unstable --watch=.,./tests,./lib serve.ts", - "serve": "deno run --allow-net --unstable serve.ts", - "test": "deno test -A --jobs 4", - "test:nocheck": "deno test -A --no-check --jobs 4", - "test:unstable": "deno test -A --unstable --jobs 4", - "test:unstable:nocheck": "deno test -A --unstable --no-check --jobs 4" + "dev": "deno run -A --unstable-net --unstable-fs --watch=.,./tests,./lib serve.ts", + "serve": "deno run --allow-net --unstable-net --unstable-fs --unstable-http serve.ts", + "test": "deno test -A --no-check=remote --jobs 4", + "test:coverage": "deno test -A --no-check=remote --parallel --jobs 4 --coverage=.coverage", + "test:nocheck": "deno test -A --no-check --parallel --jobs 4" + }, + "imports": { + "~/": "./", + "@/": "./", + "/": "./", + "~~/": "./", + "@@/": "./", + "./": "./", + "x/": "https://deno.land/x/", + "std/": "https://deno.land/std@0.224.0/", + "lib/": "./lib/", + "esm/": "https://esm.sh/", + "graphql": "https://deno.land/x/graphql_deno@v15.0.0/mod.ts", + "graphql/": "https://deno.land/x/graphql_deno@v15.0.0/", + "pqueue": "npm:p-queue", + "deno_dom": "jsr:@b-fuze/deno-dom@0.1.47", + "sift": "https://deno.land/x/sift@0.6.0/mod.tsx" } } diff --git a/import_map.json b/import_map.json deleted file mode 100644 index 84f1f2c..0000000 --- a/import_map.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "imports": { - "~/": "./", - "@/": "./", - "/": "./", - "~~/": "./", - "@@/": "./", - "./": "./", - "x/": "https://deno.land/x/", - "std/": "https://deno.land/std@0.145.0/", - "lib/": "./lib/", - "esm/": "https://esm.sh/", - "graphql": "https://deno.land/x/graphql_deno@v15.0.0/mod.ts", - "graphql/": "https://deno.land/x/graphql_deno@v15.0.0/", - "pqueue": "https://deno.land/x/p_queue@1.0.1/mod.ts", - "deno-dom": "https://deno.land/x/deno_dom@v0.1.31-alpha/deno-dom-wasm.ts", - "sift": "https://deno.land/x/sift@0.5.0/mod.tsx" - } -} diff --git a/lib/document.ts b/lib/document.ts index dc0aa38..8cbdead 100644 --- a/lib/document.ts +++ b/lib/document.ts @@ -4,7 +4,7 @@ import { type GraphQLObjectTypeConfig, GraphQLString, } from "../deps.ts"; -import { shared } from "./shared.ts"; +import { shared } from "./fields.ts"; import { TNode } from "./node.ts"; import { getAttributeOfElement } from "./helpers.ts"; diff --git a/lib/element.ts b/lib/element.ts index 8b2d38f..479739b 100644 --- a/lib/element.ts +++ b/lib/element.ts @@ -10,7 +10,7 @@ import { getAttributeOfElement, resolveURL } from "./helpers.ts"; import { TNode } from "./node.ts"; import { TDocument } from "./document.ts"; import { selector } from "./selector.ts"; -import { shared } from "./shared.ts"; +import { shared } from "./fields.ts"; export const TElement = new GraphQLObjectType({ name: "Element", diff --git a/lib/fields.ts b/lib/fields.ts new file mode 100644 index 0000000..2981c3d --- /dev/null +++ b/lib/fields.ts @@ -0,0 +1,6 @@ +import { Element, type GraphQLFieldConfigMap } from "../deps.ts"; +import * as $shared from "./fields/_shared.ts"; + +export const shared: GraphQLFieldConfigMap = { + ...($shared as unknown as GraphQLFieldConfigMap), +}; diff --git a/lib/fields/_shared.ts b/lib/fields/_shared.ts new file mode 100644 index 0000000..7d6b5de --- /dev/null +++ b/lib/fields/_shared.ts @@ -0,0 +1,22 @@ +export * from "./attr.ts"; +export * from "./childNodes.ts"; +export * from "./children.ts"; +export * from "./classList.ts"; +export * from "./className.ts"; +export * from "./content.ts"; +export * from "./count.ts"; +export * from "./has.ts"; +export * from "./href.ts"; +export * from "./html.ts"; +export * from "./index.ts"; +export * from "./next.ts"; +export * from "./nextAll.ts"; +export * from "./parent.ts"; +export * from "./previous.ts"; +export * from "./previousAll.ts"; +export * from "./_shared.ts"; +export * from "./siblings.ts"; +export * from "./src.ts"; +export * from "./table.ts"; +export * from "./tag.ts"; +export * from "./text.ts"; diff --git a/lib/fields/attr.ts b/lib/fields/attr.ts new file mode 100644 index 0000000..7f4924b --- /dev/null +++ b/lib/fields/attr.ts @@ -0,0 +1,31 @@ +import { + Element, + getAttributeOfElement, + GraphQLBoolean, + GraphQLNonNull, + GraphQLString, + selector, +} from "./deps.ts"; + +export const attr = { + type: GraphQLString, + description: + "The value of a given attribute from the selected node (`href`, `src`, etc.), if it exists.", + args: { + selector, + name: { + type: new GraphQLNonNull(GraphQLString), + description: "The name of the attribute", + }, + trim: { + type: GraphQLBoolean, + description: + "Trim any leading and trailing whitespace from the value (optional, default: false)", + defaultValue: false, + }, + }, + resolve(element: Element, { selector, name, trim }: TParams) { + element = selector ? element.querySelector(selector)! : element; + return getAttributeOfElement(element, name as string, trim); + }, +}; diff --git a/lib/fields/childNodes.ts b/lib/fields/childNodes.ts new file mode 100644 index 0000000..2a49d41 --- /dev/null +++ b/lib/fields/childNodes.ts @@ -0,0 +1,10 @@ +import { type Element, GraphQLList, TElement } from "./deps.ts"; + +export const childNodes = { + type: new GraphQLList(TElement), + description: + "Child nodes (not elements) of a selected node, including any text nodes.", + resolve(element: Element) { + return Array.from(element.childNodes); + }, +}; diff --git a/lib/fields/children.ts b/lib/fields/children.ts new file mode 100644 index 0000000..70d7fa8 --- /dev/null +++ b/lib/fields/children.ts @@ -0,0 +1,9 @@ +import { type Element, GraphQLList, TElement } from "./deps.ts"; + +export const children = { + type: new GraphQLList(TElement), + description: "Children elements (not nodes) of the selected node.", + resolve(element: Element) { + return Array.from(element.children); + }, +}; diff --git a/lib/fields/classList.ts b/lib/fields/classList.ts new file mode 100644 index 0000000..8c55ef1 --- /dev/null +++ b/lib/fields/classList.ts @@ -0,0 +1,20 @@ +import { + type Element, + type ElementParams, + GraphQLList, + GraphQLString, + selector, +} from "./deps.ts"; + +export const classList = { + type: new GraphQLList(GraphQLString), + description: "An array of CSS classes extracted from the selected node.", + args: { + selector, + }, + resolve(element: Element, { selector }: ElementParams) { + element = selector ? element.querySelector(selector)! : element; + if (element == null) return null; + return [...(element?.classList.values() ?? [])]; + }, +}; diff --git a/lib/fields/className.ts b/lib/fields/className.ts new file mode 100644 index 0000000..ca6e56e --- /dev/null +++ b/lib/fields/className.ts @@ -0,0 +1,29 @@ +import { + type Element, + getAttributeOfElement, + GraphQLBoolean, + GraphQLString, + selector, + type TextParams, +} from "./deps.ts"; + +export const className = { + type: GraphQLString, + description: + "The class attribute of the selected node, if any exists. Formatted as a space-separated list of CSS class names.", + args: { + selector, + trim: { + type: GraphQLBoolean, + description: + "Trim any leading and trailing whitespace from the value (optional, default: false)", + defaultValue: false, + }, + }, + resolve(element: Element, { selector, trim }: TextParams) { + element = selector ? element.querySelector(selector)! : element; + if (element == null) return null; + + return getAttributeOfElement(element, "class", trim); + }, +}; diff --git a/lib/fields/content.ts b/lib/fields/content.ts new file mode 100644 index 0000000..acbaf3f --- /dev/null +++ b/lib/fields/content.ts @@ -0,0 +1,11 @@ +import { ElementParams, GraphQLString, selector } from "./deps.ts"; + +export const content = { + type: GraphQLString, + description: "The innerHTML content of the selected DOM node", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + element = selector ? element.querySelector(selector)! : element; + return element && element.innerHTML; + }, +}; diff --git a/lib/fields/count.ts b/lib/fields/count.ts new file mode 100644 index 0000000..9e055f6 --- /dev/null +++ b/lib/fields/count.ts @@ -0,0 +1,18 @@ +import { + type Element, + type ElementParams, + GraphQLInt, + selector, +} from "./deps.ts"; + +export const count = { + type: GraphQLInt, + description: + "Returns the number of DOM nodes that match the given selector, or 0 if no nodes match.", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + if (!selector) return 0; + + return Array.from(element.querySelectorAll(selector)).length ?? 0; + }, +}; diff --git a/lib/fields/deps.ts b/lib/fields/deps.ts new file mode 100644 index 0000000..29f7543 --- /dev/null +++ b/lib/fields/deps.ts @@ -0,0 +1,73 @@ +/// +/// +/// +/// +/// + +export { + type Element, + GraphQLBoolean, + type GraphQLFieldConfigMap, + GraphQLInt, + GraphQLList, + GraphQLNonNull, + GraphQLString, +} from "../../deps.ts"; + +export { selector } from "../selector.ts"; +export { getAttributeOfElement } from "../helpers.ts"; +export { TElement } from "../element.ts"; +export { TDocument } from "../document.ts"; +export { TNode } from "../node.ts"; +export { State } from "../state.ts"; + +export declare type StateInit = + | Iterable<[string, T]> + | Map + | [string, T][] + | Record; + +export declare interface ElementParams { + selector?: string; +} + +export declare interface PageParams { + url?: string; + source?: string; +} + +export declare interface TextParams extends ElementParams { + trim?: boolean; +} + +export declare interface AttrParams { + name?: string; +} + +export declare interface IndexParams { + parent?: string; +} + +export declare interface AllParams + extends PageParams, IndexParams, ElementParams, TextParams, AttrParams { + attr?: string; +} + +export declare type TParams = Partial; + +export declare interface TContext { + state: import("../state.ts").State; +} + +export declare type Variables = { [key: string]: any }; + +export declare interface QueryOptions { + concurrency?: number; + fetch_options?: RequestInit; + variables?: Variables; + operationName?: string; +} + +export declare interface IOptional { + endpoint?: string; +} diff --git a/lib/fields/has.ts b/lib/fields/has.ts new file mode 100644 index 0000000..f8f9130 --- /dev/null +++ b/lib/fields/has.ts @@ -0,0 +1,15 @@ +import { + type Element, + type ElementParams, + GraphQLBoolean, + selector, +} from "./deps.ts"; +export const has = { + type: GraphQLBoolean, + description: + "Returns true if an element with the given selector exists, otherwise false.", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + return !!element.querySelector(selector!); + }, +}; diff --git a/lib/fields/href.ts b/lib/fields/href.ts new file mode 100644 index 0000000..f71fccd --- /dev/null +++ b/lib/fields/href.ts @@ -0,0 +1,26 @@ +import { + type Element, + getAttributeOfElement, + GraphQLBoolean, + GraphQLString, + selector, + type TextParams, +} from "./deps.ts"; + +export const href = { + type: GraphQLString, + description: "Shorthand for `attr(name: 'href')`", + args: { + selector, + trim: { + type: GraphQLBoolean, + description: + "Trim any leading and trailing whitespace from the value (optional, default: false)", + defaultValue: false, + }, + }, + resolve(element: Element, { selector, trim }: TextParams) { + element = selector ? element.querySelector(selector)! : element; + return getAttributeOfElement(element, "href", trim); + }, +}; diff --git a/lib/fields/html.ts b/lib/fields/html.ts new file mode 100644 index 0000000..dfab318 --- /dev/null +++ b/lib/fields/html.ts @@ -0,0 +1,11 @@ +import { GraphQLString, selector } from "./deps.ts"; + +export const html = { + type: GraphQLString, + description: "The outerHTML content of the selected DOM node", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + element = selector ? element.querySelector(selector)! : element; + return element && element.outerHTML; + }, +}; diff --git a/lib/fields/index.ts b/lib/fields/index.ts new file mode 100644 index 0000000..65fa7ad --- /dev/null +++ b/lib/fields/index.ts @@ -0,0 +1,33 @@ +import { Element, GraphQLInt, selector } from "./deps.ts"; + +export const index = { + type: GraphQLInt, + description: "The node index number of the element (starting from 0).", + args: { parent: selector }, + resolve(element: Element, { parent }: IndexParams, context: TContext) { + if (parent) { + const document = context.state.get("document"); + const nodes = Array.from(document.querySelectorAll(parent) ?? []); + let index = -1; + + for (const node of nodes) { + let elementParent = element.parentNode; + while ( + elementParent && ( + node.compareDocumentPosition(elementParent) != 0 + ) + ) { + if (!elementParent) break; + elementParent = elementParent.parentNode!; + } + if (!elementParent) continue; + if (index > -1) return index; + index = nodes.indexOf(elementParent); + } + return index; + } + + const nodes = Array.from(element.parentElement?.childNodes ?? []); + return nodes.indexOf(element) ?? -1; + }, +}; diff --git a/lib/fields/next.ts b/lib/fields/next.ts new file mode 100644 index 0000000..1988102 --- /dev/null +++ b/lib/fields/next.ts @@ -0,0 +1,10 @@ +import { type Element, TElement } from "./deps.ts"; + +export const next = { + type: TElement, + description: + "Current element's next sibling, including any text nodes. Equivalent to `Node.nextSibling`.", + resolve(element: Element) { + return element.nextSibling; + }, +}; diff --git a/lib/fields/nextAll.ts b/lib/fields/nextAll.ts new file mode 100644 index 0000000..5e4eb99 --- /dev/null +++ b/lib/fields/nextAll.ts @@ -0,0 +1,17 @@ +import { type Element, GraphQLList, TElement } from "./deps.ts"; + +export const nextAll = { + type: new GraphQLList(TElement), + description: "All of the current element's next siblings", + resolve(element: Element) { + const siblings = []; + for ( + let next = element.nextSibling; + next != null; + next = next.nextSibling + ) { + siblings.push(next); + } + return siblings; + }, +}; diff --git a/lib/fields/parent.ts b/lib/fields/parent.ts new file mode 100644 index 0000000..d5bc03b --- /dev/null +++ b/lib/fields/parent.ts @@ -0,0 +1,9 @@ +import { type Element, TElement } from "./deps.ts"; + +export const parent = { + type: TElement, + description: "Parent Element of the selected node.", + resolve(element: Element) { + return element.parentElement; + }, +}; diff --git a/lib/fields/previous.ts b/lib/fields/previous.ts new file mode 100644 index 0000000..db2f0d9 --- /dev/null +++ b/lib/fields/previous.ts @@ -0,0 +1,10 @@ +import { type Element, TElement } from "./deps.ts"; + +export const previous = { + type: TElement, + description: + "Current Element's previous sibling, including any text nodes. Equivalent to `Node.previousSibling`.", + resolve(element: Element) { + return element.previousSibling; + }, +}; diff --git a/lib/fields/previousAll.ts b/lib/fields/previousAll.ts new file mode 100644 index 0000000..06005b9 --- /dev/null +++ b/lib/fields/previousAll.ts @@ -0,0 +1,18 @@ +import { type Element, GraphQLList, TElement } from "./deps.ts"; + +export const previousAll = { + type: new GraphQLList(TElement), + description: "All of the current element's previous siblings", + resolve(element: Element) { + const siblings = []; + for ( + let previous = element.previousSibling; + previous != null; + previous = previous.previousSibling + ) { + siblings.push(previous); + } + siblings.reverse(); + return siblings; + }, +}; diff --git a/lib/fields/query.ts b/lib/fields/query.ts new file mode 100644 index 0000000..a7a6b3c --- /dev/null +++ b/lib/fields/query.ts @@ -0,0 +1,16 @@ +import { + type Element, + type ElementParams, + selector, + TElement, +} from "./deps.ts"; + +export const query = { + type: TElement, + description: + "Equivalent to `Element.querySelector`. The selectors of any nested queries will be scoped to the resulting element.", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + return element.querySelector(selector!); + }, +}; diff --git a/lib/fields/queryAll.ts b/lib/fields/queryAll.ts new file mode 100644 index 0000000..a54d352 --- /dev/null +++ b/lib/fields/queryAll.ts @@ -0,0 +1,17 @@ +import { + type Element, + type ElementParams, + GraphQLList, + selector, + TElement, +} from "./deps.ts"; + +export const queryAll = { + type: new GraphQLList(TElement), + description: + "Equivalent to `Element.querySelectorAll`. The selectors of any nested queries will be scoped to the resulting elements.", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + return Array.from(element.querySelectorAll(selector!)); + }, +}; diff --git a/lib/fields/siblings.ts b/lib/fields/siblings.ts new file mode 100644 index 0000000..6875958 --- /dev/null +++ b/lib/fields/siblings.ts @@ -0,0 +1,13 @@ +import { type Element, GraphQLList, TElement } from "./deps.ts"; + +export const siblings = { + type: new GraphQLList(TElement), + description: + "All elements at the same level in the tree as the current element, as well as the element itself. Equivalent to `Element.parentElement.children`.", + resolve(element: Element) { + const parent = element.parentElement; + if (parent == null) return [element]; + + return Array.from(parent.children); + }, +}; diff --git a/lib/fields/src.ts b/lib/fields/src.ts new file mode 100644 index 0000000..8542f6d --- /dev/null +++ b/lib/fields/src.ts @@ -0,0 +1,28 @@ +import { + type Element, + getAttributeOfElement, + GraphQLBoolean, + GraphQLString, + selector, + type TextParams, +} from "./deps.ts"; + +export const src = { + type: GraphQLString, + description: "Shorthand for `attr(name: 'src')`", + args: { + selector, + trim: { + type: GraphQLBoolean, + description: + "Trim any leading and trailing whitespace from the value (optional, default: false)", + defaultValue: false, + }, + }, + resolve(element: Element, { selector, trim }: TextParams) { + element = selector ? element.querySelector(selector)! : element; + if (element == null) return null; + + return getAttributeOfElement(element, "src", trim); + }, +}; diff --git a/lib/fields/table.ts b/lib/fields/table.ts new file mode 100644 index 0000000..84482b7 --- /dev/null +++ b/lib/fields/table.ts @@ -0,0 +1,37 @@ +import { + type Element, + GraphQLBoolean, + GraphQLList, + GraphQLString, + selector, + type TextParams, +} from "./deps.ts"; + +export const table = { + type: new GraphQLList(new GraphQLList(GraphQLString)), + description: + "Returns a two-dimensional array representing an HTML table element's contents. The first level is a list of rows (``), and each row is an array of cell (``) contents.", + args: { + selector, + trim: { + type: GraphQLBoolean, + description: + "Trim any leading and trailing whitespace from the values (optional, default: false)", + defaultValue: false, + }, + }, + resolve(element: Element, { selector, trim }: TextParams) { + element = selector ? element.querySelector(selector)! : element; + + const result = element && Array.from( + element.querySelectorAll("tr"), + (row) => + Array.from( + (row as Element).querySelectorAll("td"), + (td) => (trim ? td.textContent.trim() : td.textContent), + ), + ); + + return result.filter(Boolean).filter((row) => row.length > 0); + }, +}; diff --git a/lib/fields/tag.ts b/lib/fields/tag.ts new file mode 100644 index 0000000..bedb406 --- /dev/null +++ b/lib/fields/tag.ts @@ -0,0 +1,16 @@ +import { + type Element, + type ElementParams, + GraphQLString, + selector, +} from "./deps.ts"; + +export const tag = { + type: GraphQLString, + description: "The HTML tag name of the selected DOM node", + args: { selector }, + resolve(element: Element, { selector }: ElementParams) { + element = selector ? element.querySelector(selector)! : element; + return element?.tagName ?? null; + }, +}; diff --git a/lib/fields/text.ts b/lib/fields/text.ts new file mode 100644 index 0000000..c44c34e --- /dev/null +++ b/lib/fields/text.ts @@ -0,0 +1,26 @@ +import { + type Element, + GraphQLBoolean, + GraphQLString, + selector, + type TextParams, +} from "./deps.ts"; + +export const text = { + type: GraphQLString, + description: "The text content of the selected DOM node", + args: { + selector, + trim: { + type: GraphQLBoolean, + description: + "Trim any leading and trailing whitespace from the value (optional, default: false)", + defaultValue: false, + }, + }, + resolve(element: Element, { selector, trim }: TextParams) { + element = selector ? element.querySelector(selector)! : element; + const result = element && element.textContent; + return (trim) ? (result ?? "").trim() : result; + }, +}; diff --git a/lib/node.ts b/lib/node.ts index 5aa3011..0d7a3f9 100644 --- a/lib/node.ts +++ b/lib/node.ts @@ -3,7 +3,7 @@ import { GraphQLInterfaceType, type GraphQLInterfaceTypeConfig, } from "../deps.ts"; -import { shared } from "./shared.ts"; +import { shared } from "./fields.ts"; export const TNode = new GraphQLInterfaceType({ name: "Node", diff --git a/lib/shared.ts b/lib/shared.ts deleted file mode 100644 index 6f9e180..0000000 --- a/lib/shared.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { - Element, - GraphQLBoolean, - type GraphQLFieldConfigMap, - GraphQLInt, - GraphQLList, - GraphQLNonNull, - GraphQLString, -} from "../deps.ts"; -import { selector } from "./selector.ts"; -import { getAttributeOfElement } from "./helpers.ts"; -import { TElement } from "./element.ts"; - -export const shared: GraphQLFieldConfigMap = { - index: { - type: GraphQLInt, - description: "The node index number of the element (starting from 0).", - args: { parent: selector }, - resolve(element: Element, { parent }: IndexParams, context: TContext) { - if (parent) { - const document = context.state.get("document"); - const nodes = Array.from(document.querySelectorAll(parent) ?? []); - let index = -1; - - for (const node of nodes) { - let elementParent = element.parentNode; - while ( - elementParent && node.compareDocumentPosition(elementParent) != 0 - ) { - if (!elementParent) break; - elementParent = elementParent.parentNode!; - } - if (!elementParent) continue; - if (index != -1) return index; - index = nodes.indexOf(elementParent); - } - return index; - } - - const nodes = Array.from(element.parentElement?.childNodes ?? []); - return nodes.indexOf(element) ?? -1; - }, - }, - content: { - type: GraphQLString, - description: "The innerHTML content of the selected DOM node", - args: { selector }, - resolve(element, { selector }: ElementParams) { - element = selector ? element.querySelector(selector)! : element; - return element && element.innerHTML; - }, - }, - html: { - type: GraphQLString, - description: "The outerHTML content of the selected DOM node", - args: { selector }, - resolve(element: Element, { selector }: ElementParams) { - element = selector ? element.querySelector(selector)! : element; - - return element && element.outerHTML; - }, - }, - text: { - type: GraphQLString, - description: "The text content of the selected DOM node", - args: { - selector, - trim: { - type: GraphQLBoolean, - description: - "Trim any leading and trailing whitespace from the value (optional, default: false)", - defaultValue: false, - }, - }, - resolve(element: Element, { selector, trim }: TextParams) { - element = selector ? element.querySelector(selector)! : element; - const result = element && element.textContent; - return (trim) ? (result ?? "").trim() : result; - }, - }, - table: { - type: new GraphQLList(new GraphQLList(GraphQLString)), - description: - "Returns a two-dimensional array representing an HTML table element's contents. The first level is a list of rows (``), and each row is an array of cell (``) contents.", - args: { - selector, - trim: { - type: GraphQLBoolean, - description: - "Trim any leading and trailing whitespace from the values (optional, default: false)", - defaultValue: false, - }, - }, - resolve(element: Element, { selector, trim }: TextParams) { - element = selector ? element.querySelector(selector)! : element; - - const result = element && Array.from( - element.querySelectorAll("tr"), - (row) => - Array.from( - (row as Element).querySelectorAll("td"), - (td) => (trim ? td.textContent.trim() : td.textContent), - ), - ); - - return result.filter(Boolean).filter((row) => row.length > 0); - }, - }, - tag: { - type: GraphQLString, - description: "The HTML tag name of the selected DOM node", - args: { selector }, - resolve(element: Element, { selector }: ElementParams) { - element = selector ? element.querySelector(selector)! : element; - return element?.tagName ?? null; - }, - }, - attr: { - type: GraphQLString, - description: - "The value of a given attribute from the selected node (`href`, `src`, etc.), if it exists.", - args: { - selector, - name: { - type: new GraphQLNonNull(GraphQLString), - description: "The name of the attribute", - }, - trim: { - type: GraphQLBoolean, - description: - "Trim any leading and trailing whitespace from the value (optional, default: false)", - defaultValue: false, - }, - }, - resolve(element: Element, { selector, name, trim }: TParams) { - element = selector ? element.querySelector(selector)! : element; - return getAttributeOfElement(element, name as string, trim); - }, - }, - href: { - type: GraphQLString, - description: "Shorthand for `attr(name: 'href')`", - args: { - selector, - trim: { - type: GraphQLBoolean, - description: - "Trim any leading and trailing whitespace from the value (optional, default: false)", - defaultValue: false, - }, - }, - resolve(element: Element, { selector, trim }: TextParams) { - element = selector ? element.querySelector(selector)! : element; - return getAttributeOfElement(element, "href", trim); - }, - }, - src: { - type: GraphQLString, - description: "Shorthand for `attr(name: 'src')`", - args: { - selector, - trim: { - type: GraphQLBoolean, - description: - "Trim any leading and trailing whitespace from the value (optional, default: false)", - defaultValue: false, - }, - }, - resolve(element: Element, { selector, trim }: TextParams) { - element = selector ? element.querySelector(selector)! : element; - if (element == null) return null; - - return getAttributeOfElement(element, "src", trim); - }, - }, - class: { - type: GraphQLString, - description: - "The class attribute of the selected node, if any exists. Formatted as a space-separated list of CSS class names.", - args: { - selector, - trim: { - type: GraphQLBoolean, - description: - "Trim any leading and trailing whitespace from the value (optional, default: false)", - defaultValue: false, - }, - }, - resolve(element: Element, { selector, trim }: TextParams) { - element = selector ? element.querySelector(selector)! : element; - if (element == null) return null; - - return getAttributeOfElement(element, "class", trim); - }, - }, - classList: { - type: new GraphQLList(GraphQLString), - description: "An array of CSS classes extracted from the selected node.", - args: { - selector, - }, - resolve(element: Element, { selector }: ElementParams) { - element = selector ? element.querySelector(selector)! : element; - if (element == null) return null; - return [...(element?.classList.values() ?? [])]; - }, - }, - has: { - type: GraphQLBoolean, - description: - "Returns true if an element with the given selector exists, otherwise false.", - args: { selector }, - resolve(element: Element, { selector }: ElementParams) { - return !!element.querySelector(selector!); - }, - }, - count: { - type: GraphQLInt, - description: - "Returns the number of DOM nodes that match the given selector, or 0 if no nodes match.", - args: { selector }, - resolve(element: Element, { selector }: ElementParams) { - if (!selector) return 0; - - return Array.from(element.querySelectorAll(selector)).length ?? 0; - }, - }, - query: { - type: TElement, - description: - "Equivalent to `Element.querySelector`. The selectors of any nested queries will be scoped to the resulting element.", - args: { selector }, - resolve(element: Element, { selector }: ElementParams) { - return element.querySelector(selector!); - }, - }, - queryAll: { - type: new GraphQLList(TElement), - description: - "Equivalent to `Element.querySelectorAll`. The selectors of any nested queries will be scoped to the resulting elements.", - args: { selector }, - resolve(element: Element, { selector }: ElementParams) { - return Array.from(element.querySelectorAll(selector!)); - }, - }, - children: { - type: new GraphQLList(TElement), - description: "Children elements (not nodes) of the selected node.", - resolve(element: Element) { - return Array.from(element.children); - }, - }, - childNodes: { - type: new GraphQLList(TElement), - description: - "Child nodes (not elements) of a selected node, including any text nodes.", - resolve(element: Element) { - return Array.from(element.childNodes); - }, - }, - parent: { - type: TElement, - description: "Parent Element of the selected node.", - resolve(element: Element) { - return element.parentElement; - }, - }, - siblings: { - type: new GraphQLList(TElement), - description: - "All elements at the same level in the tree as the current element, as well as the element itself. Equivalent to `Element.parentElement.children`.", - resolve(element: Element) { - const parent = element.parentElement; - if (parent == null) return [element]; - - return Array.from(parent.children); - }, - }, - next: { - type: TElement, - description: - "Current element's next sibling, including any text nodes. Equivalent to `Node.nextSibling`.", - resolve(element: Element) { - return element.nextSibling; - }, - }, - nextAll: { - type: new GraphQLList(TElement), - description: "All of the current element's next siblings", - resolve(element: Element) { - const siblings = []; - for ( - let next = element.nextSibling; - next != null; - next = next.nextSibling - ) { - siblings.push(next); - } - return siblings; - }, - }, - previous: { - type: TElement, - description: - "Current Element's previous sibling, including any text nodes. Equivalent to `Node.previousSibling`.", - resolve(element: Element) { - return element.previousSibling; - }, - }, - previousAll: { - type: new GraphQLList(TElement), - description: "All of the current element's previous siblings", - resolve(element: Element) { - const siblings = []; - for ( - let previous = element.previousSibling; - previous != null; - previous = previous.previousSibling - ) { - siblings.push(previous); - } - siblings.reverse(); - return siblings; - }, - }, -};