diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f9d7b75 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# Use Unix line endings in all text files. +* text=auto eol=lf + +# Exclude testdata from linguist +tests/fixtures/** linguist-vendored +.devcontainer/** linguist-vendored +.vscode/** linguist-vendored +.github/** linguist-vendored +npm/** linguist-vendored +src/wasm/** linguist-vendored +scripts/** linguist-vendored +# crates/** linguist-vendored diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..02d3af3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,315 @@ +name: CI + Publish +on: + push: + branches: + - main + tags: + - "*" + pull_request: + branches: + - main + workflow_dispatch: + inputs: + deno-version: + type: string + description: "Deno version" + default: "2.x" + deno-test: + type: choice + description: "Run deno test?" + options: + - true + - false + - docs + default: "true" + deno-fmt: + type: boolean + description: "Run deno fmt?" + default: true + deno-lint: + type: boolean + description: "Run deno lint?" + default: true + cargo-test: + type: choice + description: "Run cargo test?" + options: + - true + - false + - docs + default: "true" + cargo-fmt: + type: boolean + description: "Run cargo fmt?" + default: true + publish-jsr: + type: boolean + description: "Publish to JSR?" + default: true + publish-npm: + type: boolean + description: "Publish to NPM?" + default: false + publish-gpr: + type: boolean + description: "Publish to GitHub Package Registry?" + default: false +jobs: + ci: + name: Quality Control + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + continue-on-error: true + strategy: + matrix: + os: [ubuntu-latest] + node: [22.x] + outputs: + artifact-digest: ${{ steps.artifact.outputs.artifact-digest }} + artifact-id: ${{ steps.artifact.outputs.artifact-id }} + artifact-url: ${{ steps.artifact.outputs.artifact-url }} + artifact-exists: ${{ steps.artifact.outcome == 'success' }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: ${{ github.event.inputs.deno-version || 'canary' }} + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: ${{ matrix.node }} + + - name: Setup Rust + uses: dsherret/rust-toolchain-file@v1 + + - if: runner.os == 'Linux' + id: cargo-fmt + continue-on-error: true + run: cargo fmt --all -- --check + + - id: build + name: Build WebAssembly Bindings + run: deno task build + env: + WASM_OPT_LEVEL: 4 + WASM_OPT_BULK_MEMORY: 1 + WASM_OPT_EXTRA_ARGS: "--all-features" + + - if: | + runner.os == 'Linux' && ( + github.event_name != 'workflow_dispatch' || + github.event.inputs.deno-fmt == 'true' + ) && steps.build.outcome == 'success' + id: deno-fmt + name: deno fmt + run: deno task fmt:check + + - if: | + runner.os == 'Linux' && ( + github.event_name != 'workflow_dispatch' || + github.event.inputs.deno-lint == 'true' + ) && steps.build.outcome == 'success' + id: deno-lint + name: deno lint + run: deno task lint + + - if: runner.os == 'Linux' && steps.build.outcome == 'success' + id: artifact + name: upload build artifact + uses: actions/upload-artifact@v4 + with: + name: dawm-${{ github.sha }} + path: | + ./LICENSE + ./README.md + ./*.json + ./npm + ./src + ./target + retention-days: 90 + + - if: runner.os == 'Linux' + id: cache + name: Cache + uses: actions/cache@v4 + with: + key: deno-${{ github.event.inputs.deno-version || 'canary' }}_dawm-${{ github.ref_name }}_${{ github.sha }} + restore-keys: | + deno-${{ github.event.inputs.deno-version || 'canary' }}_dawm-${{ github.ref_name }}_ + deno-${{ github.event.inputs.deno-version || 'canary' }}_dawm- + path: | + /home/runner/.cache + /home/runner/.cargo + /home/runner/.deno + ./.coverage + ./target + ./npm + ./src + + publish: + name: Publish + needs: ci + runs-on: ubuntu-latest + timeout-minutes: 15 + if: | + ( + github.event_name == 'workflow_dispatch' && ( + github.event.inputs.publish-jsr == 'true' || + github.event.inputs.publish-npm == 'true' || + github.event.inputs.publish-gpr == 'true' + ) + ) || ( + needs.ci.result == 'success' && + github.event_name == 'push' && + startsWith(github.ref, 'refs/tags/') + ) + concurrency: + group: publish-${{ github.ref_name }} + cancel-in-progress: true + permissions: + contents: write + id-token: write + packages: write + env: + PUBLISH_GPR: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.publish-gpr == 'true') || (startsWith(github.ref, 'refs/tags/') && github.event.inputs.publish-gpr != 'false') }} + PUBLISH_JSR: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.publish-jsr == 'true') || (startsWith(github.ref, 'refs/tags/') && github.event.inputs.publish-jsr != 'false') }} + PUBLISH_NPM: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.publish-npm == 'true') || (startsWith(github.ref, 'refs/tags/') && github.event.inputs.publish-npm != 'false') }} + GPR_PACKAGE: ${{ vars.GPR_PACKAGE_NAME }} + JSR_PACKAGE: ${{ vars.JSR_PACKAGE_NAME }} + NPM_PACKAGE: ${{ vars.NPM_PACKAGE_NAME }} + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + submodules: recursive + + - name: Setup Rust + uses: dsherret/rust-toolchain-file@v1 + + - if: needs.ci.outputs.artifact-exists != 'true' + id: cache + name: Restore Cache + uses: actions/cache/restore@v4 + with: + key: | + deno-${{ github.event.inputs.deno-version || 'canary' }}_dawm-${{ github.ref_name }}_${{ github.sha }} + deno-${{ github.event.inputs.deno-version || 'canary' }}_dawm-${{ github.ref_name }}_ + deno-${{ github.event.inputs.deno-version || 'canary' }}_dawm- + path: | + /home/runner/.cache + /home/runner/.cargo + /home/runner/.deno + ./target + ./.coverage + ./lib + ./crates + ./src + + - if: needs.ci.outputs.artifact-exists == 'true' + id: artifact + name: Download Artifact + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: dawm-${{ github.sha }} + + - name: Setup Deno + uses: denoland/setup-deno@v2 + with: + deno-version: ${{ github.event.inputs.deno-version || 'canary' }} + + - if: steps.artifact.outcome != 'success' + id: build + name: Build WebAssembly Bindings + env: + WASM_OPT_LEVEL: 4 + WASM_OPT_BULK_MEMORY: 1 + WASM_OPT_EXTRA_ARGS: "--all-features" + run: deno task build + + - id: publish-jsr + if: env.PUBLISH_JSR == 'true' + name: Publish to JSR + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: deno task publish + + - if: env.PUBLISH_GPR == 'true' + name: Setup Node.js for GPR + uses: actions/setup-node@v5 + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + node-version: 22.x + registry-url: "https://npm.pkg.github.com/" + scope: "@nberlette" + + - if: env.PUBLISH_GPR == 'true' + name: Build GPR Package + env: + PACKAGE_NAME: ${{ env.GPR_PACKAGE }} + NPM_REGISTRY_URL: "https://npm.pkg.github.com/" + NPM_DIST_TAG: ${{ vars.NPM_DIST_TAG || 'latest' }} + run: deno task build:npm + + - if: env.PUBLISH_GPR == 'true' + id: publish-gpr + name: Publish to GitHub Package Registry + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + working-directory: ./npm/dist + run: npm publish --access public --registry https://npm.pkg.github.com/ + + - if: env.PUBLISH_NPM == 'true' + name: Setup Node.js + uses: actions/setup-node@v5 + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + with: + node-version: 22.x + cache: "npm" + registry-url: "https://registry.npmjs.org/" + + - if: env.PUBLISH_NPM == 'true' + name: Build NPM Package + env: + PACKAGE_NAME: ${{ env.NPM_PACKAGE }} + NPM_REGISTRY_URL: "https://registry.npmjs.org/" + NPM_DIST_TAG: ${{ vars.NPM_DIST_TAG || 'latest' }} + run: deno task build:npm + + - if: env.PUBLISH_NPM == 'true' + id: publish-npm + name: Publish to NPM + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + working-directory: ./npm/dist + run: npm publish --access public --registry https://registry.npmjs.org/ + + - id: release + if: startsWith(github.ref, 'refs/tags/') + name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PRERELEASE="" + if [[ "${GITHUB_REF_NAME}" == *"-"* ]]; then + PRERELEASE="true" + fi + title=$(date +"%Y-%m-%d") + assets=() + if [[ "${PUBLISH_NPM}" == "true" || "${PUBLISH_GPR}" == "true" ]] + then + assets+=(./npm/dist/*.tgz) + fi + gh release create "${GITHUB_REF_NAME}" \ + --title "${GITHUB_REF_NAME} - ${title}" \ + --generate-notes --draft ${PRERELEASE:+--prerelease} \ + "${assets[@]}" diff --git a/README.md b/README.md index 77f4cb8..9bae186 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,11 @@ for use in any ES2015+ JavaScript runtime with [WebAssembly] support. ## Introduction -**`dawm`** — _like **DOM**, but with **a** lil' **WebAssembly**_ — is a portable -toolkit for parsing, traversing, manipulating, and serializing HTML/SVG/XML code -in (usually) headless JavaScript environments. It features a hybrid codebase -combining a high-performance parser written in [Rust] with high-level -[Document Object Model (DOM)][DOM] standard APIs implemented in strict, -well-documented TypeScript. +**`dawm`** is a headless DOM toolkit for parsing, traversing, manipulating, and +serializing HTML/SVG/XML documents in server-side / serverless / edge +environments. Its hybrid codebase couples a high-speed DOM parser written in +[Rust] with TypeScript implementations of many of the standard +[Document Object Model (DOM)][DOM] APIs. The overall developer experience with `dawm` is uncannily familiar for anyone with frontend development experience, making it adoptable by a vast majority of @@ -61,12 +60,15 @@ const options = { contextElement: null, // default context element name (for fragments) } satisfies ParseOptions; +// The `parseHTML` function is also available via standard DOM APIs: +// - `DOMParser.parseFromString(string, "text/html")` +// - `Document.parseHTML(string, options)` const doc = parseHTML( "

Hello, world!

", options, ); -const h1 = doc.firstElementChild?.firstChild; +const h1 = doc.body.firstElementChild; assert.strictEqual(h1?.tagName, "H1"); assert.strictEqual(h1.textContent, "Hello, world!"); assert.strictEqual(h1.parentNode, doc.body); diff --git a/deno.json b/deno.json index 79739da..085cdd1 100644 --- a/deno.json +++ b/deno.json @@ -38,7 +38,7 @@ ] }, "build": { - "command": "npm pack ./npm/dist --pack-destination ./npm/dist", + "command": "npm pack ./npm/dist --pack-destination ./npm", "description": "Build all distributables and package for release", "dependencies": [ "build:npm" @@ -106,18 +106,7 @@ "prepublish:npm" ] }, - "prepublish": { - "dependencies": [ - "prepublish:npm", - "prepublish:jsr" - ] - }, - "publish": { - "dependencies": [ - "publish:npm", - "publish:jsr" - ] - }, + "publish": "deno task publish:npm && deno task publish:jsr", "bump": { "command": "deno run -Aq ./scripts/bump_version.ts", "description": "Bump versions in deno.json/package.json/Cargo.toml" @@ -127,11 +116,7 @@ "description": "Bump version, commit changes, push to origin" }, "relish": { - "dependencies": [ - "release", - "publish:npm", - "publish:jsr" - ], + "command": "deno task release && deno task publish", "description": "Create a GitHub Release and Publish to JSR + NPM" }, "burp": { diff --git a/src/collections.ts b/src/collections.ts index c3ab062..47b3de2 100644 --- a/src/collections.ts +++ b/src/collections.ts @@ -1047,10 +1047,11 @@ export class DOMTokenList { replace(oldToken: string, newToken: string): boolean { const tokens = this.#updateTokens(); - const index = tokens.indexOf(oldToken); + const index = indexOf(tokens, oldToken); if (index === -1) return false; tokens[index] = newToken; - this.#updateAttribute(); + this.#tokens = tokens.filter((t, i, a) => indexOf(a, t) === i); + this.#updateAttribute(this.#tokens.join(" ")); return true; } @@ -1282,7 +1283,7 @@ export class NamedNodeMap { namedItem(name: string): Attr | null { for (const attr of this) { - if (attr.name === name) return attr; + if (attr.name.toLowerCase() === name.toLowerCase()) return attr; } return null; } @@ -1303,12 +1304,11 @@ export class NamedNodeMap { setNamedItem(attr: Attr): Attr | null { const existing = this.getNamedItem(attr.name); if (existing) { - const index = indexOf(this, existing); - if (index !== -1) { - getListStorage(this).splice(index, 1, attr); - } else { - return null; + let index = 0; + for (; index < this.length; index++) { + if (this.item(index) === existing) break; } + getListStorage(this).splice(index, 1, attr); } else { getListStorage(this).append(attr); } @@ -1318,12 +1318,11 @@ export class NamedNodeMap { setNamedItemNS(attr: Attr): Attr | null { const existing = this.getNamedItemNS(attr.namespaceURI, attr.localName); if (existing) { - const index = indexOf(this, existing); - if (index !== -1) { - getListStorage(this).splice(index, 1, attr); - } else { - return null; + let index = 0; + for (; index < this.length; index++) { + if (this.item(index) === existing) break; } + getListStorage(this).splice(index, 1, attr); } else { getListStorage(this).append(attr); } @@ -1333,8 +1332,11 @@ export class NamedNodeMap { removeNamedItem(name: string): Attr { const existing = this.getNamedItem(name); if (existing) { - const index = indexOf(this, existing); - if (index !== -1) getListStorage(this).splice(index, 1); + let index = 0; + for (; index < this.length; index++) { + if (this.item(index) === existing) break; + } + getListStorage(this).splice(index, 1); return existing; } else { throw new DOMException( @@ -1347,8 +1349,11 @@ export class NamedNodeMap { removeNamedItemNS(namespace: string | null, localName: string): Attr { const existing = this.getNamedItemNS(namespace, localName); if (existing) { - const index = indexOf(this, existing); - if (index !== -1) getListStorage(this).splice(index, 1); + let index = 0; + for (; index < this.length; index++) { + if (this.item(index) === existing) break; + } + getListStorage(this).splice(index, 1); return existing; } else { throw new DOMException(