The Rust engine: one binary replaces every script and the JS detector, fully open (#714)

* Add oracle harness: verb goldens and function-level vectors

Records stdout/stderr/exit/files for every impeccable verb over a fixed
corpus and replays them against an alternate implementation. Adds a loader
hook that captures per-function call vectors from the pure engine modules.

Prepared with AI assistance (Claude Code).

* Oracle: hook, hook-before-edit, hook-admin cases and goldens

Prepared with AI assistance (Claude Code).

* Add docs/CLI-CONTRACT.md: observable behavior of every impeccable verb

Prepared with AI assistance (Claude Code).

* Oracle: context/doctor/pin/surface-brief/critique/palette/embed/signals/csp/seed/genimg/question cases and goldens

Prepared with AI assistance (Claude Code).

* Oracle: live-mode cases and goldens (roots, inject, wrap, insert, accept, session, manual edits, daemon)

Prepared with AI assistance (Claude Code).

* Oracle: mask the binary path before HOME; export launcher env to the binary

Prepared with AI assistance (Claude Code).

* detect: set process.exitCode instead of exiting after the final write

process.exit() right after a large piped stdout write truncated JSON output
at the pipe buffer boundary; found by the oracle harness. Re-record the six
directory-scan goldens that had captured the truncation.

Prepared with AI assistance (Claude Code).

* Oracle: normalize the hook-admin command in both runtimes' forms and audit chars

Prepared with AI assistance (Claude Code).

* Skill text: invoke the impeccable launcher instead of node scripts

Every `node {{scripts_path}}/<name>.mjs` becomes `{{scripts_path}}/impeccable <verb>`
(context-signals -> signals, hook-admin -> hooks). Setup step 1 drops Node, points
Windows shells without sh at impeccable.cmd, and says the launcher runs a
self-contained binary. allowed-tools follows.

Prepared with AI assistance (Claude Code).

* Scripts dir: replace the Node scripts with the impeccable launcher

skill/scripts keeps command-metadata.json and the page JS; every .mjs entry
point, lib/, and live/ are gone (the binary owns those verbs). Adds the POSIX
launcher, impeccable.cmd, VERSION (copied from the new root ENGINE_VERSION),
scripts/fetch-engine.mjs (bun run fetch:engine) to pull the pinned binary
into skill/scripts/bin/<os>-<arch>/, and gitignores that bin dir.

Prepared with AI assistance (Claude Code).

* Build: ship the launcher instead of bundling the JS engine

readSourceFiles no longer copies cli/engine into the skill; the scripts
payload is the launcher (executable bit preserved through dist, plugin/, and
universal.zip), impeccable.cmd, VERSION (synced from ENGINE_VERSION on every
build), the page JS, and command-metadata.json. Hook manifests call
`<scripts>/impeccable hook` behind an existence guard (Codex adds a
commandWindows sibling calling impeccable.cmd; Cursor runs hook-before-edit;
GitHub keeps the git rev-parse form; Grok mirrors Claude); the Node probe and
systemMessage notice are gone. build:release fetches the pinned engine for
every target (lenient) and stages bin/<os-arch>/ into the dist skill copies
after root harness dirs and plugin/ were synced, so git-delivered trees stay
launcher-only. The detection-rule count check reads the vendored
extension/detector/antipatterns.json and is skipped when absent.
build:browser is a stub; the codex prefix rewrite leaves
`{{scripts_path}}/impeccable` alone.

Prepared with AI assistance (Claude Code).

* CLI: turn the impeccable npm package into a platform-binary shim

cli/engine, cli/lib, and cli/bin/commands are gone; their behavior lives in
the engine binary. cli/bin/cli.js now resolves the binary from IMPECCABLE_BIN,
the @impeccable/cli-<os>-<arch> optional dependency (templates under
cli/platform-packages/, published by the engine release), the
~/.impeccable/bin/<version>/ cache, or a checksum-verified download, and
execs it. package.json drops the engine dependencies and the library
exports; puppeteer moves to devDependencies for the icon scripts.
README.npm.md describes the shim.

Prepared with AI assistance (Claude Code).

* Tests: gate behavior on the oracle and the engine binary

Unit tests of the deleted Node scripts and the JS detector are removed;
their behavior is pinned by tests/oracle goldens (frozen JS behavior plus
reviewed deltas) and the engine's own tests. tests/oracle.test.mjs replays
the corpus against the binary (IMPECCABLE_BIN or skill/scripts/bin/<target>/,
via tests/lib/engine-bin.mjs) and skips cleanly without one; the framework
fixture sweep drives live-inject, live-wrap, and detect-csp through the
binary the same way. record.mjs learns --bin. The function-level vectors
under tests/oracle/vectors/calls are committed as the frozen snapshot they
can no longer be regenerated from. Suites: core trimmed to build and
transformer tests, oracle added to the default run, detector/live reduced to
packaging and reference checks, the live-e2e helper tests move to the opt-in
live-e2e lane pending its retarget, cli-remote-e2e is an empty placeholder.

Prepared with AI assistance (Claude Code).

* Docs: describe the launcher, the engine pin, and the oracle gate

CLAUDE.md gains an Engine binary section (launcher lookup order, ENGINE_VERSION,
untracked binaries, how tests get one, the oracle as behavior gate, what stays
JavaScript) and drops the Node-script and JS-detector descriptions; the CLI
and detection-rule sections point at the shim and the engine repo. README.md
states the skill needs no runtime and lists the launcher-based hook commands;
AGENTS.md follows. CLI-CONTRACT.md's intro notes the scripts it quotes are
the recorded source, not the tree.

Prepared with AI assistance (Claude Code).

* Tests: tighten the hook command guard assertion

Prepared with AI assistance (Claude Code).

* Oracle: re-golden 46 cases for the engine's own command names; record them in DELTAS.md

Prepared with AI assistance (Claude Code).

* Build: ship launcher-only release zips by default

IMPECCABLE_BUNDLE_ENGINE=1 opts in to staging the engine binaries into the
dist skill copies. Bundling every target into every provider copy put
dist/universal.zip near 340 MB, past the 25 MB Cloudflare Pages file cap
that impeccable install downloads through.

Prepared with AI assistance (Claude Code).

* Tests: drive the live-e2e orchestrator through the engine binary

The session, fake-agent loop, steer test, and manual-edit probe spawn
<binary> <verb> (live-server, live, live-inject, live-wrap, live-insert,
live-accept, live-poll, live-complete) resolved by tests/lib/engine-bin.mjs
instead of node skill/scripts/live-*.mjs; the completion typing the agent
imported from the deleted live/completion.mjs is a small local helper. The
live-e2e helper unit tests move back into the default live suite (the steer
loop skips without a binary).

Prepared with AI assistance (Claude Code).

* Tests: run new-work-e2e through the engine's serve-question and generate-image verbs

Prepared with AI assistance (Claude Code).

* Tests: point the skill-behavior harness at the launcher and engine binary

The bash tool exports IMPECCABLE_BIN so the staged skill's launcher runs
without a download; scenarios assert on 'impeccable context' instead of
context.mjs and skip without a binary.

Prepared with AI assistance (Claude Code).

* Tests: note what plugin-e2e validates before and after the generated-output sync

Prepared with AI assistance (Claude Code).

* Oracle: record the engine's 'wasm-unsafe-eval' CSP meta patch as a reviewed delta

Prepared with AI assistance (Claude Code).

* Rebase reconciliation: fold main's post-freeze work into the swapped tree

The rebase onto origin/main brought changes whose JS engine halves left the
tree with the swap. This commit reconciles what survives:

- Suite map: register main's comp-fidelity unit tests (build-phase,
  comp-diff, font-match, hero-checks) in the core suite and
  live-browser-ignores in the live suite.
- Payload guard: the skill scripts payload now allowlists the comp-fidelity
  build pipeline (comp-spec/comp-diff/build-phase/font-match and their libs),
  the one Node toolchain that has not moved into the engine.
- Drop skill/scripts/live/project-ignores.mjs, lib/live-path-globs.mjs, and
  their test: they import hook-lib/live-inject/impeccable-paths, which the
  swap deleted, and their consumer (the JS live server) is the engine now.
- skill text: the comp pipeline's calls to engine verbs (generate-image,
  embed-prompt) use the launcher spelling.
- Oracle: re-record 17 detect goldens over the fixture set main changed
  (oklch #592, color-mix #578, 1D grid #615, the two comp-fidelity rules)
  and record the gap in DELTAS.md; those JS rule changes are not yet ported
  to the engine, and the goldens pin its current behavior.

bun run test (oracle included) and bun run build are green on this tree.

AI-assisted change: implemented with Claude Code.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Launcher: engine-probe PATH validation, working .cmd download path; CI: drop stale path, add oracle job

Byte-identical copies of the engine repo's launchers (engine main
af7572c): the retired 3.x npm CLI on PATH or in ~/.impeccable/bin is
rejected by the engine-probe handshake instead of hijacking every verb;
impeccable.cmd's download path is rewritten as straight-line goto flow
(the parenthesized blocks expanded %url%/%cached% at parse time, making
it dead code) with certutil sha256 verification and a windows-arm64 ->
x64 asset fallback; the final error points at the release download
instead of npm i -g (npm still serves the 3.x CLI).

ci.yml: the generated-output check no longer diffs the deleted
cli/engine/detect-antipatterns-browser.js, and a new oracle job fetches
the pinned engine (bun run fetch:engine) and replays tests/oracle/
against it. The job is continue-on-error with a loud warning until the
first engine release exists; flipping it to required is a release-time
toggle, documented in the workflow.

Verified here: sh -n on both launcher copies, bun run build green, full
oracle replay against the rebuilt engine binary green (770 pass, 0
fail), and a launcher behavior test proving a fake 3.x CLI on PATH is
skipped while the download + checksum chain completes against a local
file server.

Prepared with AI assistance (Claude Code).

* Oracle: restore detector goldens to post-fix behavior after the engine ports

The Aug 17-31 detector fixes (oklch parsing, color-mix nested hex, 1D grid
pass, comment stripping, root-relative linked stylesheets, URL userinfo
redaction, inert ignore-value refusal) and the comp-fidelity rules
organic-clip-path / buried-raster are ported to the engine. Re-records the
gap-pinning detect goldens from the fixed binary (glow.html included: its
.photo-opaque-grad column now carries the buried-raster finding it was
written for), replays the frozen checkHtmlPatterns call vectors through the
last JS engine state in history (db1462b9^; args untouched, 14 of 101
results moved), and rewrites the DELTAS gap section into the landed-ports
note. Each re-recorded json fixture golden byte-matches that JS state's
output; oracle: 770 pass, 0 fail.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Oracle: pin the Aug 17-31 verb fixes ported to the Rust engine

New cases: hook-session-grok-edit-then-stop (Grok Build camelCase envelope,
end_turn/shutdown/stopHookActive Stop handling, 35ae0733 + bfe634e2 +
3c442af7, #646), hook-session-codex-stop-decision (Codex Stop emits
decision/block, c9e7cd8a, #603), and doctor-order-boot-and-deep (boot and
deep findings keep their established artifact order, 80997663).

Re-recorded goldens whose old bytes froze pre-fix behavior, with a
DELTAS.md entry naming each upstream hash: the Stop finding-cache sync
(3c442af7), the Edit|Write manifests without the retired MultiEdit matcher
(7d5c60d2), and the failWithRollback field order (1f2c3f9d).

Prepared with AI assistance (Claude Code).

* Oracle: drop a duplicated DELTAS section

The verb-fix section landed twice when two porting sessions staged the
same file; keep one copy.

Prepared with AI assistance (Claude Code).

* Oracle: pin the hooks ignore-value inert-entry refusal

Three hadmin-ignore-value-inert-* cases record the engine's port of
be87f5eb (#662) to hooks ignore-value: an exact value for a rule whose
findings can never extract one is refused with the wildcard-plus-file
route (and no config write), while the wildcard scoped form for the same
rule is accepted. Goldens recorded from the engine binary and verified
byte-for-byte against the ea360025 hook-admin.mjs on the same sequences.
No existing golden changes, so no DELTAS entry is owed.

Prepared with AI assistance (Claude Code).

* Launcher: fail closed on a missing download checksum (engine triage C1)

Byte-identical sync of the engine repo's launchers: a freshly downloaded
engine binary now runs only after verifying against its .sha256 sidecar.
A sidecar that cannot be fetched, or a machine with no sha256 tool,
refuses the download instead of exec'ing an unverified binary; the
wget-only path fetches the sidecar too. Binaries already on PATH or in
the cache that pass engine-probe are unaffected.

Prepared with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Enforce engine-before-skill release order (triage D4)

The launcher, npm shim, and `impeccable install` all resolve the engine
binary for the pinned ENGINE_VERSION, so a skill/CLI release or a rust-swap
merge published ahead of the engine release + platform packages dead-ends
every install path. Add a mechanical guard:

- scripts/check-engine-release.mjs: verifies all five dist binaries +
  .sha256 and the five @impeccable/cli-<os>-<arch> npm platform packages
  exist for the pinned ENGINE_VERSION; names missing assets, exits non-zero.
  Honors IMPECCABLE_DOWNLOAD_BASE.
- release.mjs: hard-fails release:skill and release:cli when assets are
  missing; extension is exempt (vendored WASM detector, no engine exec).
- CI engine-release-ready job: runs the check, continue-on-error with a
  loud ::warning until the first engine release exists (flip to false then).
- CLAUDE.md Releases: documents the enforced ordering.

Prepared with AI assistance (Claude Code).

* Oracle: re-record the Sep-1 verb fixes ported to the Rust engine

Five fixes landed on main in JS between the swap branch and its rebase and were
ported to the engine; the goldens they touch are re-recorded from the fixed
binary, each engine output first diffed byte-for-byte against the upstream JS on
the same inputs. DELTAS.md documents every case with its upstream hash.

- critique-* (usage/unknown/latest-existing/write-then-read/write-monorepo-child):
  the #660 critique close path (identity + fingerprint freshness, ~NNNN
  collision suffix, closed flag, close verb, latest --json). Upstream 5211bdf4.
- detect-* (new overused-font fixture cases, dir/scope/no-advisory sweeps):
  the #678 overused-font primary-face change (a system stack keeps its system
  face, so a Roboto fallback no longer flags). Upstream 2cfd6076.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Tests: fix pre-existing release-guard staging on the swap branch

release.test.mjs was already red on the swap branch: release.mjs imports
check-engine-release.mjs and fetch-engine.mjs (the D4 engine release-order
guard), which the temp work tree never staged, so every dry run failed to
resolve the module instead of exercising the guard. Stage both modules and set
IMPECCABLE_SKIP_ENGINE_CHECK=1 so the guard does not probe the network; this
suite predates the guard and only covers the version/changelog/artifact checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* oracle: pin E8 stale-hook-manifest detector fallback (context)

Cover the v3-to-launcher upgrade fix (triage E8) recorded from the engine
binary and hand-reviewed:

- context-stale-hook-manifest: a .claude/settings.local.json naming the retired
  `node .../hook.mjs` script under the claude-code provider emits
  MANUAL_DETECTOR_REQUIRED, because the stale marker no longer counts as an
  active hook (its script is gone after the update).
- context-launcher-hook-active: the same manifest in the launcher form still
  suppresses MANUAL_DETECTOR_REQUIRED, confirming the launcher marker is
  recognized as active.

The only difference between the two goldens is the MANUAL_DETECTOR_REQUIRED
block. No existing golden moved: every other context case runs under the source
provider, whose hook-manifest list is empty, so none of them scan a manifest.
Also null IMPECCABLE_PROVIDER_ID in the case BASE_ENV so a recording machine's
value cannot leak. DELTAS.md records the intentional divergence from JS parity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaJv2c4oN8wS7Ttq4XRqyx

* Tests: stop two harness hangs from wedging a whole run

Two suites could hang forever and never print a tally, because the one
mechanism that could interrupt the wedged work was missing on both paths.

Hang 1 (bun run test / build-phase.test.mjs): the test's run() helper
spawned every child with spawnSync and no timeout. spawnSync blocks the
test worker's thread, so node's --test-timeout (an event-loop timer)
cannot interrupt a child that wedges (a fork/exec blocked on OS resources
under concurrency, a gate's comp-diff grandchild, or a stray browser
launch). Bound every child with spawnSync timeout + killSignal SIGKILL so
a wedge becomes a fast, named failure the next test survives.

Hang 2 (bun run test:skill-behavior): runTurn called generateText with no
client-side deadline, so a stalled provider stream kept the fetch (and the
whole node process) alive past the per-test timeout, producing no tally.
Attach a real AbortSignal (default 840s, under the 900s per-test cap):
on expiry the fetch aborts, the turn throws, and the scenario
fails-and-continues. The unref'd timer is cleared on completion.

Runner backstops: run-tests.mjs now spawns each command as a detached
process-group leader and enforces a per-suite wall-clock cap that SIGKILLs
the entire group (workers, grandchildren, browsers) on expiry, with
SIGINT/SIGTERM forwarded so Ctrl-C still reaps the tree. The core node
batch gets a finite --test-timeout (180s); skill-behavior gets a 60min
group cap. Env overrides: IMPECCABLE_TEST_WALL_CLOCK_MS,
IMPECCABLE_SKILL_BEHAVIOR_TURN_TIMEOUT_MS,
IMPECCABLE_BUILD_PHASE_RUN_TIMEOUT_MS.

Proof: bun run test green twice (~60s); scoped claude-sonnet-5
skill-behavior sweep terminates with a tally (20 tests, ~32min) where the
840s abort caught a wedged redesign turn and the sweep continued instead
of hanging.

Prepared with AI assistance (Claude Code).

* launcher: export skill-dir env before the IMPECCABLE_BIN exec (sync engine fix)

Prepared with AI assistance (Claude Code).

* Node-free swap: comp-fidelity verbs move to the engine

The four comp-fidelity scripts (comp-spec, comp-diff, font-match, build-phase)
and their six libs are ported into the impeccable-engine binary. This removes
the last Node .mjs from the skill: `git ls-files skill/scripts | grep '\.mjs$'`
now returns nothing.

- reference/new-work.md, reference/visualize.md, and the asset-producer /
  finish-reviewer agents now invoke `{{scripts_path}}/impeccable <verb>` instead
  of `node <script>.mjs`.
- Deleted the ten ported .mjs and the four JS unit tests that imported them
  (their behavior is now covered by the engine's Rust tests and the oracle);
  removed those files from scripts/test-suites.mjs.
- Added oracle cases (comp-*, font-match-*, build-phase-*) over a comp-basic
  workspace, recorded from the engine binary; the deterministic outputs are
  byte-identical to the JS the scripts left behind.
- docs/CLI-CONTRACT.md documents the four verbs, the CDP font rendering, and
  the runtime-resolved (never-committed) font-index catalog.

The font-index catalog JSON stays shipped in the skill (data/font-index.json);
the engine resolves it at run time and never vendors it.

Prepared with AI assistance (Claude Code).

* reorg: public plumbing for the in-repo Rust workspace and the two-release flow

The engine binaries move from the impeccable-dist channel to this repo's own
GitHub Releases (tag engine-v<ENGINE_VERSION>), and the closed detector the
engine links arrives as detector-v<DETECTOR_VERSION> releases on the same
repo. This commit wires the public side for that; the crates themselves land
in the next commit.

- Launcher (sh + cmd), npm shim, fetch-engine and check-engine-release now
  download from github.com/pbakaus/impeccable/releases/download/engine-v<X>/.
- release.mjs gains `engine`: verifies ENGINE_VERSION against the platform
  package pins and the detector release, tags, pushes; release-engine.yml
  builds the five targets and publishes. check-detector-release.mjs is the
  matching release-order guard (with tests).
- Root Cargo.toml (workspace, lto = false with the reason), rust-toolchain.toml
  (exact pin), DETECTOR_VERSION, /target ignored.
- CI: rust + rust-windows jobs and an oracle job that replays the goldens
  against a source build, warn-only until the first detector release exists;
  ci-test-plan exposes a `rust` output.
- docs/ENGINE.md (the crate map and the closed-detector mechanism) and the
  CLAUDE.md engine, release-order and rules sections.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* reorg C: the open Rust runtime joins this repo as one Cargo workspace

The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.

The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.

What changed versus the engine repo copy:

- Every crate manifest moves from `license-file.workspace` to
  `license.workspace` (this workspace declares Apache-2.0), and the workspace
  gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
  a sibling `launcher/` dir, and `engine_binary` downloads from
  `github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
  of the retired dist repo. No oracle golden carried the old URL, so no
  re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
  `../impeccable-second` or a hardcoded home directory now resolve the root as
  `CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
  override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
  glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
  archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
  live mode serves that. `scripts/check-detector-release.mjs` now requires it
  and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
  `modern-screenshot.umd.js` directly rather than through vendored copies, so
  the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
  plain `cargo build --release -p impeccable` is enough to run `bun run test`.

Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: bring RUNTIME-ENV and PORTING-GUIDE over with the runtime

They describe the binary's environment contract and the parity method every
crate here was ported with; both belong next to the crates now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* core/build.rs: refuse a detector archive built by another rustc, in plain words

The archive links only against the exact rustc that built it; a mismatch
used to surface as pages of undefined std symbols from the linker. The
detector repo now writes rustc-version.txt next to the archive (and ships it
with the release); when it is present, build.rs compares it with its own
compiler and names both versions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* build:extension: ship the wasm-core extension shell and vendor its detector from the detector release

`bun run build:extension` was broken on this branch: it still imported the
deleted JS engine (cli/engine/registry/antipatterns.mjs,
scripts/lib/browser-detector-bundle.js).

The shipped shell now matches the new design. The content script only
snapshots the DOM; an extension-owned offscreen document runs the
WebAssembly rule core over that snapshot, so the scanned page's CSP no
longer matters. That replaces the old approach of injecting a JS rules
bundle into the page. New files: extension/offscreen/offscreen.html, plus
the "offscreen" permission and a 'wasm-unsafe-eval' extension_pages CSP in
the manifest.

The manifest version stays at 1.3.3. The shell's own manifest carried
2.0.0; feature branches never bump versions, so the bump is a release step.

The five generated detector pieces (core.js, core_bg.wasm, snapshot.js,
overlay.js, antipatterns.json) are vendored at build time into the
gitignored extension/detector/ by the new scripts/lib/detector-bundle.mjs,
which resolves them the same three ways crates/core/build.rs resolves the
native archive: IMPECCABLE_DETECTOR_LIB/extension-detector/, the
~/.impeccable/detector/<DETECTOR_VERSION>/ cache, then a checksum-verified
download of detector-browser-bundle.zip from the detector release.
antipatterns.json is no longer regenerated here.

The zip packaging is unchanged. The Firefox variant still builds so
`web-ext lint` keeps covering the shared shell, but it cannot scan: Gecko
has no chrome.offscreen API. The build prints a one-line warning saying so.

Also here: a referenced-path check that fails the build when the manifest
or the service worker points at a file that is not in extension/, a
resolver unit test wired into the core suite, and the detector rule count
in the READMEs synced to the 61 the vendored registry carries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* oracle: replay byte-for-byte on Linux too

The corpus was recorded on macOS and eight cases failed on ubuntu CI for
reasons that were all environment, not behavior:

- stageWorkspace returns the realpath of the staged dir. macOS's tmpdir is a
  symlink and two goldens (context-dir-override, live-accept-source-locked)
  had recorded that artifact; both re-recorded, reviewed in DELTAS.md. The
  source-locked case now actually exercises the lock it is named for.
- context-lowercase-product-name declares platforms: ['darwin', 'win32'];
  run.mjs skips such cases elsewhere and says so in the summary.
- The hook-project workspace's empty provider skill folders (.claude,
  .cursor) are now tracked with .gitkeep; git cannot track empty
  directories, so a fresh checkout had none and hooks on found nothing to
  repair.
- crates/live's read_dir_raw sorts entries by name: the goldens hold the
  order macOS returned, Linux returns hash order, and the source-candidate
  lists in live-commit output depended on it.

macOS: 795 pass, 0 fail. The Svelte accept cases additionally need the
public repo's node_modules on the machine that runs them (CI now installs
them).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* oracle: mask <HOME> only at path boundaries (a short home like /root ate 'roots.json')

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* oracle: track live-html's dist/generated.html (the root dist/ ignore hid it from CI checkouts)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* release-engine: darwin-x64 builds on macos-14 (macos-13 is retired)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Open the detector: the rule crates join the workspace, the C-ABI goes away

The detector is open source. The rules it ships were already public in this
repo's git history and in every npm tarball of the JS engine, so a closed
binary bought nothing it could keep; the moat is the service (the catalog,
the labs, the review pipeline), not the check functions. Keeping them behind
a prebuilt archive cost a C-ABI, an exact toolchain pin, a build-time
download, a second release to order ahead of every engine release, and a
serde layer that had to serve two encodings.

Deleted
- crates/core/src/ffi.rs, crates/core/build.rs, crates/core/tests/boundary.rs
  and the shim modules under src/checks and src/browser.
- crates/foundation/src/boundary.rs and the postcard dependency.
- DETECTOR_VERSION, scripts/check-detector-release.mjs and its test, the
  check:detector-release script, the detector gate and
  IMPECCABLE_SKIP_DETECTOR_CHECK in scripts/release.mjs.
- scripts/lib/detector-bundle.mjs and tests/detector-bundle.test.mjs (the
  vendoring path for the closed browser bundle).
- scripts/build-browser-detector.js and the build:browser script (a stub
  since the JS engine left the tree).
- xtask's detector-archive subcommand and its public-repo lookup.

Came back
- crates/core is now the rule logic itself: every check_* / scan_*, the
  browser adapters, the visual-contrast decisions. It re-exports foundation
  as before, so no consumer changed. Its vectors dispatcher is the union of
  both id tables again, and tests/vectors.rs replays the frozen vectors
  straight through it.
- crates/wasm and crates/xtask join the workspace. cargo xtask bundle builds
  the in-page bundle from browser-bundle/ plus the wasm core, writes
  dist/, refreshes the tracked crates/live/assets/detect-antipatterns-
  browser.js, and writes extension/detector/. bun run build:extension runs
  it instead of downloading.
- crates/live/assets/detect-antipatterns-browser.js is tracked again; live
  mode embeds it and serves it as /detect.js.
- Serde is back to plain derives: no is_human_readable branch in
  js::json_number, derived Serialize for Rgba and BrowserFinding with their
  skip_serializing_if attributes.
- profile.release has lto = "fat" again; rust-toolchain.toml is plain
  stable plus the wasm32 target. The rust, rust-windows and oracle CI jobs
  lose continue-on-error and can be required.

Verified
- cargo build --workspace --all-targets: clean, no warnings.
- cargo test --workspace: 346 pass, 0 fail (the 8 boundary tests are gone
  with the boundary).
- cargo build -p impeccable-wasm --target wasm32-unknown-unknown --release: ok.
- cargo xtask bundle && cargo xtask bundle --check: reproducible; the
  regenerated bundle is committed (it differs from the archived one, which
  was built with a pinned rustc and lto = false).
- cargo build --release -p impeccable: no linker warnings, 12.5 MB (the
  same source at lto = false is 13.1 MB).
- oracle: 795 pass, 0 fail, 0 accepted deltas, 0 missing goldens.
- bun run build, bun run build:extension, web-ext lint (0 errors,
  8 warnings), bun run test: 363 + 80 + 1 + 1 + 133 + 180 + 4 pass, 0 fail.
- impeccable detect --no-config --json tests/fixtures/antipatterns: 128.7 ms
  median of 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* core: doc comments drop the open/closed split

The rule crate and the foundation crate are both Apache-2.0 in one
workspace now, so "open", "closed" and "crosses the boundary" no longer
describe anything. Comments only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Rule packs: downstream crates add rules on all three engines; wasm detect surface

A crate that depends on this workspace can now add rules without forking
it. `impeccable_core::rule_pack::RulePack` (object-safe, Send + Sync +
Debug) carries a pack's registry rows plus three hooks that default to
empty: `check_text` for the text engine, `check_element_dom` and
`check_page_dom` for the browser driver. `impeccable_html::StaticRulePack`
adds `check_document` for the static engine, where the document model
belongs to the html crate and detect cannot name it.

The registry keeps ANTIPATTERNS as the built-in list; `registry::extend`
appends a pack's rows and every lookup consults them after the built-ins,
so a pack can never shadow a built-in id (extend panics on a collision and
is idempotent per slice). `all_antipatterns()` is the built-ins followed by
the registered rows.

Hook order, chosen so built-in output cannot move:

- detect_text: after every matcher, analyzer and the dedupe, before inline
  ignores, so `impeccable-disable` waives pack rules like built-in ones.
- detect_html_source: after the element rules, the design-system merge and
  the page passes, again before inline ignores. One pack pass per HTML
  file: the document hook when set, otherwise the text hook over the raw
  source, so a pack implementing both never reports twice.
- collect_browser_findings: the element hook at the end of the per-element
  loop through the same disabled-rules filter and group, the page hook
  after every built-in page pass with the same el-or-body attribution.

A pack travels on TextOptions / ScanOptions, DetectHtmlOptions
(static_rule_pack plus rule_pack), StaticHtmlEngine, and BrowserConfig
(serde-skipped: a pack is a Rust value, not JSON from the page). The
shipped binary installs none.

`crates/wasm --features detect` exposes the two file engines as JSON
exports for hosts that cannot exec the binary: `detect_text_json` and
`detect_html_source_json`, options `{ inlineIgnores?, designSystem? }`,
returning the findings array `detect --json` prints. `antipatterns_json`
now includes a pack's rows. `set_rule_pack` and `set_static_rule_pack` are
Rust-only, for a crate that links this one as an rlib.

Tests: registry extension and collision in foundation, one test pack per
engine (crates/core, crates/detect, crates/html tests) proving each hook
fires, that the built-in findings are unchanged, and that the waivers and
the disabled-rules list cover pack rules, plus the wasm export shapes.
Workspace tests 346 to 361, oracle 795/0 unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: the cutover checklist under the open design

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* bundle: the page JS and the bundler become a library crate downstream packs can reuse

The in-page bundle, the extension pieces, the registry JSON and the
wasm-pack call were reachable only through `cargo xtask bundle`, which read
`browser-bundle/*.js` from the repo root. A downstream crate that links
impeccable-core + impeccable-wasm with its own rule pack had to copy the
page JS to produce a detector bundle for its module.

They move to `impeccable-bundle` (crates/bundle), which embeds every
`browser-bundle/*.js` with `include_str!` and exposes `in_page_bundle`,
`extension_pieces`, `registry_json`, `check_capture_contract` and
`wasm_pack_build`. Nothing writes files or exits the process; the caller
places the bytes. `registry_json` now reads `all_antipatterns()`, so an
installed pack's rows land in `antipatterns.json` too (no built-in change).

xtask becomes the workspace's caller and writes the same files to the same
places; `cargo xtask bundle` is byte-identical, tracked live asset included.
`IMPECCABLE_BUNDLE_SKIP_WASM_PACK` is the skip switch's new name, the old
`IMPECCABLE_XTASK_SKIP_WASM_PACK` still works.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* The immediate tier moves to the registry, and reaches wasm

The design hook's immediate-tier list is the set of rule ids worth fixing
at the edit site, and a downstream reviewer wants the same set to decide
how loudly a finding is reported. `impeccable-hook` is native-only, so the
list moves to `impeccable_core::registry` (the hook re-exports it) and the
`detect` feature gains `immediate_tier_rules_json()`.

The export is behind `detect`, which the in-page bundle does not build, so
the tracked browser asset is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: Pristine tracks the engine by revision pin, not npm

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* docs: the cutover checklist is maintainer-side, not part of the tree

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix flat type hierarchy false positives (#702)

Upstream sha 84728e9ce4.

The rule now reads rendered semantic roles and the dominant size per role
instead of the raw set of font sizes on the page, and it fires only when
every adjacent role step is under 1.25x.

- crates/core checks::rules gains TYPE_HIERARCHY_SELECTOR / MIN_ROLES /
  MIN_STEP_RATIO, typeHierarchyRole, dominantTypeRoleSize and
  checkFlatTypeHierarchySamples, the shared half of checks.mjs.
- crates/core browser::page_checks gets checkFlatTypeHierarchyFromDoc over
  the Dom trait, with the overlay skip selector checkTypography passes.
- crates/html page.rs gets the same walk over StaticDocument.
- crates/detect drops the source-only analyzer: flat-type-hierarchy leaves
  REGEX_ANALYZERS, the text-content analyzers shift to index 1, and
  analyzer_rule_id loses its first row.
- crates/html cascade defaults gain contentVisibility, and crates/foundation
  registry carries the reworded description.

Goldens re-recorded (the binary now matches origin/main's JS engine on every
one of these fixtures, verified by scanning the shared corpus with both):
glow, icon-tile-stack, layout, modern-color-borders, motion,
named-color-borders, numbered-section-markers, oklch-neon-text,
typography-should-flag, json and text.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix detector URL scans and advisory handling (#709)

Upstream sha fa44839f72.

Advisory handling. `severity` becomes the canonical registry field: the
`advisory` bool leaves `Antipattern`, `advisory_rule_ids` filters on
`severity == "advisory"`, and `derive_advisory_flag` stamps the finding's
`advisory: true` from the effective severity, so a per-finding promotion or
demotion carries the flag. The html and browser engines call it after their
severity override; the detect CLI and the hook accept either spelling; the
driver's serializer and the wasm registry exports derive it the same way.
em-dash-overuse moves from `advisory: true` to `severity: "advisory"`.

URL scans. `expand_joined_url_targets` splits an argv value that is entirely
whitespace-separated URLs and leaves paths with spaces alone. The browser
driver reads the readable linked-stylesheet corpus into the HTML pattern
corpora and resolves a finding's selector with `selector_nodes_for_live_dom`
/ `pseudo_element_host_selector`, so an unresolvable selector drops the
finding instead of keeping it page-level. The CSSOM walk itself is page JS:
`browser-bundle/15-snapshot.js` gains `__snapLinkedStylesheetText` (grouping
rules flattened, container-query probes, effective keyframes) and puts it in
the snapshot as `linkedCss`; `10-probe.js` exposes the same for the in-page
route, and the Dom trait carries `linked_stylesheet_text`.

Also `enclosing_css_selector` blanks comments before hunting the previous
declaration delimiter, and `check_typography` reports the uniquely most-used
family instead of every family over a 15% share.

Verified: `impeccable detect --no-config --json tests/fixtures/antipatterns`
is now byte-identical to `node cli/bin/cli.js` on an origin/main worktree
over the shared corpus (432 findings). The two changed lines in
tests/oracle/vectors/calls/rules.checks/checkHtmlPatterns.jsonl were
re-recorded by running origin/main's `checkHtmlPatterns` over the frozen
args; only the comment-polluted selector changed. Goldens re-recorded for
the advisory partition (config-*, fixture gemini/gpt-tells,
numbered-section-labels, scoped-ignore, shape-assembled-illustration,
color, em-dash-entities) and the help text, each cross-checked against the
JS on origin/main.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: stop gray-on-color false positives on Tailwind opacity and JSX (#707)

Upstream sha 32b270f4e8.

`find_solid_chromatic_bg` replaces the bare `bg-<hue>-<n>` match in both
engines: a `bg-blue-500/10` tint is a wash, not a solid fill. The `regex`
crate has no lookahead, so the maximal digit run plus the word boundary is
matched as before and the byte after it is tested for `/`.

The text engine gains the JS-source scanner (`scan_js`) and the scope
helpers on top of it: `containing_markup_tag` keeps a gray text class from
pairing with a background in a sibling tag on the same line, and
`find_ternary_split` / `exclusive_class_scopes` split a `cond ? a : b`
class expression into its arms, recursing into nested ternaries, ignoring
`?.` and `??`, and keeping a common prefix and post-ternary suffix in every
arm. `MatchCtx` now carries the match offset the scope lookup needs.

Verified against origin/main's JS: all eleven cases from the upstream test
file plus a nested / nullish / suffix set produce byte-identical findings on
both engines; they are pinned as Rust unit tests in `regex_matchers` and
`checks::rules`. The shared fixture corpus stays byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: resolve unique --target names in monorepos (#706)

Upstream sha 8b326fc81e.

`resolve_target_path` / `find_unique_bare_target` in `crates/context`: a
`--target` that does not exist and reduces to a single path segment under
cwd resolves to the one workspace candidate with that name, so `--target a`
selects `apps/a`. A caller that already absolutized the name against cwd
(live and the other helpers do) takes the same route. Ambiguous or unknown
names still report the miss.

The context CLI resolves the target once and hands the resolved path to
`load_context`, replacing `path_exists_for_target`.

Oracle: four new `context-monorepo-target-bare-*` cases (bare name,
absolutized bare name, unknown name, bare name from a child cwd).
`context-monorepo-target-b-inherits` was re-recorded: resolving the target
before `load_context` changes its `surfaceBriefReason` from `not-found` to
`invalid-target`, which is what origin/main's `context.mjs` prints for the
same run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix Next.js 16 CSP and parent hook discovery (#710)

Upstream sha 672ca29642.

CSP. `detect-csp` recognizes Next.js 16's `proxy.{ts,js,mjs}` request hook
beside `middleware.*`, but only where it sits at a project root or its `src/`
directory: the scan root itself, or a nested directory carrying a Next
project marker (a `next.config.*`, an `app` / `pages` dir, or a `next`
dependency). A same-named helper elsewhere in the tree is not the framework
hook.

Context. `find_git_boundary_root` gives `resolve_project` a git-boundary
notion: an explicit target inside its own repository resolves against that
repository, and an external target resolves against its own root, so caller
context never leaks across the boundary. `hook_manifest_search_roots`
replaces the cwd/projectRoot/repoRoot triple with a walk up from the
project root that stops at the first git boundary, and each root's own hook
lifecycle config is honored before its manifest counts as coverage.

Verified against origin/main's JS: nine `detect-csp` placements and five
hook-discovery scenarios (enclosing harness root, that root disabled,
sibling target, nested git target, markerless nested git target) produce
identical output.

Oracle: five `csp-proxy-*` cases and five `context-hook-*` /
`context-markerless-nested-git-target` cases. Four route-target goldens were
re-recorded because #710 resolves a `/`-prefixed target outside the
workspace; each was cross-checked against origin/main, and
`surface-brief-write-route` has a DELTAS entry for the one wording
difference (an unwritable filesystem root).

`tests/framework-fixtures.test.mjs`'s new proxy-placement block came in from
the merge importing the deleted `detectCsp`; it now drives `detect-csp`
through the binary like the rest of that file.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: fail URL scans when the browser is unavailable (#711)

Upstream sha f2f9958be1e6a4ecb1fbd5ef1ae1b7d9c53e0d24 (Fix: fail URL scans
when the browser is unavailable).

`detect` gains an operational-failure flag. Exit 1 now means at least one
requested target could not be scanned, and it takes precedence over exit 2,
because findings from the targets that did scan do not turn a partial scan
into a complete one. The flag is set by an unreachable path, an unreadable
directory or file in a dir walk, a per-file scan that throws, a URL scan
that throws, and a shared-browser setup failure.

- `walk_dir_reporting` and `build_import_graph_reporting` take a read-error
  callback; the plain wrappers stay for callers that do not report. A file
  the graph could not read is skipped for the scan too.
- `SharedBrowser::ensure_launched` is the eager half of
  `createBrowserDetector()`: the CLI brings the browser up before the loop so
  a launch failure prints one `Error:` line and every URL target is skipped,
  instead of the lazy launch reporting once per URL.
- The static engine and the text path spell a permission failure the way Node
  does (`EACCES: permission denied, open '<path>'`), which is what
  `Error: cannot scan <target>: <message>` prints.
- Usage text and docs/CLI-CONTRACT.md carry the exit-status block.

Verified against origin/main's JS: missing target, missing target alongside a
flagging file, unreadable file, unreadable file beside a readable sibling,
unreadable directory, unreadable nested directory, a clean scan, and a
browser-unavailable scan of one and of two URLs all agree on exit code,
stdout and stderr (the browser-not-found wording is the pre-existing
puppeteer-vs-discovery difference).

Oracle: `detect-missing-file` and `detect-missing-file-json` re-recorded at
exit 1, plus new `detect-missing-file-with-findings`,
`detect-unreadable-file-json` and `detect-unreadable-file-in-dir`, each
cross-checked against origin/main. `detect-help` carries the new block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: OpenCode slash command bridge (#483)

Upstream sha 9736a9f6e9.

OpenCode does not honor `user-invocable: true` on SKILL.md frontmatter, so a
pinned skill never reaches its slash menu. `pin` now writes
`commands/impeccable-<cmd>.md` on the OpenCode command schema instead, and
skips `.opencode` in the SKILL.md loop so no unreachable
`.opencode/skills/<cmd>` is left behind. `unpin` mirrors it, marker-guarded,
and reaches both scopes even when the skill itself is gone.

`find_opencode_commands_dirs` covers the project-local dir when the project
has the skill and the user config dir when Impeccable is installed globally,
resolving that dir the way the CLI does (`OPENCODE_CONFIG_DIR` ->
`XDG_CONFIG_HOME/opencode` -> `~/.config/opencode`).

The build-tooling half of the upstream change (transformers, the OpenCode
command the build generates, `root-commands-sync`) came in with the merge and
needed no port.

Verified against origin/main's pin.mjs across seven scenarios (no harness,
project scope, user scope, a foreign command file, pin then unpin, unpin over
a foreign file, unpin with nothing pinned): identical stdout, identical file
sets, identical file contents apart from the one deliberate difference.

Oracle: five `pin-opencode-*` cases, with a DELTAS entry for the bridge body
naming the launcher rather than `node .../context.mjs`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix Codex skill version metadata (#703)

Upstream sha 482368511a.

Codex's validator rejects unknown top-level keys, so the Codex and `.agents`
skills now carry `version` under the spec-defined `metadata:` map. Both
version readers learn the same parser: `parse_skill_frontmatter_version` in
`crates/context` (the boot update check) and `extract_version` in
`crates/skills` (`getSkillsVersion`). A metadata version wins, a legacy
top-level one still reads, only the map's own indent level counts, tabs count
as two spaces, and a comment line is skipped.

The build-tooling half (`versionInMetadata` on the two providers, the YAML
emitter's nested-object branch) came in with the merge.

Fourteen frontmatter shapes were recorded from origin/main's
`parseSkillFrontmatterVersion` and pinned as unit tests in both crates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: Fix skill subcommand help handling (#708)

Upstream sha a264199177.

`install`, `link`, `update` and `check` render static help before entering
any operational path, through both the top-level verb and the legacy `skills`
namespace, for `--help` and `-h` alike.

Verified against origin/main's `cli/bin/cli.js`: all six spellings produce
identical text and exit codes.

Oracle: a new `tests/oracle/cases/skills.mjs` with seven help cases. Only the
help paths are pinned there; every other installer path writes into harness
directories or reaches the network.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Oracle: goldens for the three fixtures the merge added

`tests/fixtures/antipatterns/` gained `flat-type-hierarchy.html` (#702) and
`linked-url-patterns.{css,html}` (#709) with the merge, so the corpus
generator produced six `detect-fixture-*` cases with no goldens and the
directory-wide cases (`detect-dir-*`, `detect-scope-*`, `detect-no-advisory-*`)
moved.

Every golden here was recorded from the binary and then cross-checked against
`node cli/bin/cli.js` on an origin/main worktree over the same files: the six
per-fixture cases agree byte for byte in JSON and text, and a full scan of
`tests/fixtures/antipatterns` produces 432 findings identical on both engines
after normalizing the repo path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Port: the installer half of the OpenCode command bridge (#483)

Upstream sha 9736a9f6e9, the part of it that
lives in `cli/bin/commands/skills.mjs` rather than `pin.mjs`.

`copy_provider_commands` mirrors `copy_provider_skills` for a provider's
compiled `commands/` dir: project scope writes `<root>/<configDir>/commands`,
user scope writes the config dir OpenCode actually scans
(`OPENCODE_CONFIG_DIR` -> `XDG_CONFIG_HOME/opencode` -> `~/.config/opencode`),
and a pre-#406 global install at `~/.opencode/commands/` loses exactly the
files just written while siblings, symlinked dirs and home-rooted git repos
are left alone. It runs on install, on the reinstall refresh, on update, and
on link, which is the only path that can deliver the bridge to a linked
install.

`is_up_to_date` now compares the bundle's command files too, so an install
whose skills match but whose bridge is missing or drifted refreshes instead of
reporting success while the slash command stays absent. Only bundle-shipped
files are compared, so a pinned shortcut never affects freshness.

`tests/copy-provider-commands.test.js` arrived with the merge importing the
deleted `cli/bin/commands/skills.mjs`; its scenarios are ported to
`crates/skills/tests/provider_commands_tests.rs` (project scope, the three
user-scope dir resolutions, the legacy migration and its two guards, a
provider with no commands dir, and the four `isUpToDate` command-awareness
cases), and the file is removed and deregistered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* CI: the first full run on the branch, three fixes

- The oracle harness masks the climb to the root a /-prefixed target
  produces (<UP_TO_ROOT>/): the number of `../` is the staged tmpdir's depth
  (7 on macOS, 2 on Linux), not the verb's behavior. surface-brief-path-slash
  re-recorded.
- Two context test helpers canonicalized their temp dir, which on Windows
  yields a \\?\ verbatim path that takes `/` literally; they strip the prefix
  like Node's realpathSync. The critique-storage identity test compares
  against the platform's own resolved path.
- Every job that drives the binary end to end (live-e2e smoke and full,
  accept-cleanup, the DeepSeek sweep, the remote CLI smoke) builds it from
  the checkout first; before, they looked for a release that does not exist.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* context tests: the verbatim-prefix strip spells the prefix once

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* context test: derive the snapshot identity from the verb's own resolver

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* context test: JSON-quote the snapshot identity, as the verb does

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* detect test: import resolution against platform-form paths

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* hook test: the stock cache path in the host's path form; Windows CI runs every crate's tests before failing

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: skills tests pass on Windows

The two test temp roots kept `canonicalize`'s `\\?\` verbatim prefix, and the
kernel takes a verbatim path literally, so every `/`-joined path built under
them was an invalid filename. Strip it the way Node's `realpathSync` does.
The manifest, artifact and sibling-binary expectations hard-coded POSIX
separators for paths the product joins with the host's semantics; derive them
from `jsp::join` instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: hook tests pass on Windows

Same verbatim-prefix strip on the test temp roots, plus expectations derived
from the helpers the product uses: cache keys and scan targets from
`jsp::join`, the config path in an admin message from the same relative form
`path.relative` renders, and the footer hints from `quote_command_arg`, which
deliberately switches to the double-quoted Windows form (#476 / #533). The
env lock no longer poisons the sibling tests when one of them fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: html oracle goldens compare on Windows

The goldens pin the `<REPO>`-masked fixture path recorded on POSIX. Mask, then
render the remainder with `/` so a Windows checkout's backslashes are not read
as a finding difference. The goldens are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: widen the live read-deadline test's margin

Timing only. The watchdog polls in 50ms steps against a ~15.6ms Windows system
timer while the crate's tests run in parallel, so the later request takes its
turn later there. The bound stays far under the 60s read timeout a
deadline-less read would hold the ticket for, so the test still distinguishes
the fix from the regression.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: the request read deadline was not enforced on Windows

Windows does not unblock a `recv` already parked in the kernel when another
thread calls `shutdown` on the same socket, so the watchdog could not end a
silent connection's read and it held its turnstile place for the whole 60s
header timeout instead of the 10s deadline. Bound the read at the socket too,
which enforces the same deadline everywhere; the watchdog stays as the backstop
for a connection that trickles bytes without ever completing a request. POSIX
behavior is unchanged: the watchdog already closed the socket at the deadline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: hook tests derive the rest of the host path forms

The test temp helper's `write` returned a `PathBuf::join` result, which keeps
the `/` inside the relative part and so does not match what the hook resolves a
relative target to on Windows. Three more admin messages and the cache-root slug
pinned the POSIX spelling of paths the product renders with the host's
semantics (`path.resolve` also prefixes the current drive there).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: skills test fixtures name USERPROFILE, and the win32 quoted form

`os.homedir()` reads USERPROFILE on Windows, so a fixture home that named only
HOME sent the global installs into the runner's real profile. The Windows hook
command carries the JSON-quoted path, so a host path's backslashes arrive
escaped; derive the expectation instead of pinning the POSIX spelling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: check the oracle fixtures out with LF

A finding's snippet carries the scanned file's own bytes, and the goldens were
recorded from a POSIX checkout, so a CRLF checkout of a linked stylesheet reads
as a finding difference. The goldens are untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* windows: check the grok global-install manifests as JSON

The Windows hook command carries the JSON-quoted launcher path, so the path's
backslashes are escaped once inside the command and again by the manifest file
itself. Read the manifest as JSON and look for either quoting form instead of
counting escaping layers in a raw substring match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* npm shim: refuse a download with no verifiable sidecar

The skill launcher and `impeccable install` both fail closed when a
release binary's `.sha256` sidecar cannot be fetched or carries no hash:
they refuse rather than cache an unverified binary. The npm shim did not.
It only compared when a hash was present, so a 404, an empty sidecar, or
a truncated one all wrote the payload straight into
`~/.impeccable/bin/<version>/` and exec'd it.

It now refuses in the same cases, with wording that matches the launcher,
and writes nothing until the hash matches, so a refusal leaves the cache
dir empty. IMPECCABLE_BIN and the optional-dependency lookup are
untouched: neither downloads.

tests/cli-shim.test.mjs runs the real shim against a throwaway HTTP
server and covers missing, empty, and mismatched sidecars, plus the
matching-sidecar and IMPECCABLE_BIN paths. The two refusal cases fail
against the old shim.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Oracle fixture: declare the vite plugin the web workspace imports

`live-workspaces/apps/web/vite.config.js` imports `@vitejs/plugin-react`
but the workspace's package.json listed only `vite`. No oracle case
installs or evaluates that config (the three `live-boot-workspaces-*`
cases stop at root resolution), so the fixture was never wrong at
runtime, only self-contradictory to read. Adding the devDependency keeps
the goldens byte-equal.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Vectors: drop the 12,208 byte-identical repeat lines

The recorder deduplicated by arguments per run, not across runs, so the
frozen call snapshot arrived with 12,208 lines (43% of 28,266) that
repeat an earlier line byte for byte. Every one re-asserts what its first
occurrence already asserts, and `crates/core/tests/vectors.rs` replays
line by line with no count anywhere, so removing them changes nothing it
checks: the replay still reports 8,321 pass, 0 fail.

Duplicates were removed with `awk '!seen[$0]++'`, keeping first
occurrences and file order, and every changed file was checked to equal
that transform of its old contents. No line was added, reordered, or
rewritten, and no vector file gained or lost a distinct call. The tree
drops from 9.2 MB to 5.7 MB.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Fix: restore the live overlay's disabledValues waivers in the engine

The JS engine applied value-level ignore waivers at the tail of
collectBrowserFindings: `_disabledValues` read the entries the live
overlay resolved for the page (skill/scripts/live-browser-ignores.js
sends them as config.disabledValues), and filtered the assembled
findings by the value each one reported, with design-system-color
compared by color value rather than by spelling so a hex waiver
suppressed a finding the browser reported as rgb(...). The Rust port
dropped that stage: `disabledValues` appeared nowhere in the workspace
or in browser-bundle, so a project entry like

    [detector]
    ignoreValues = [{ rule = "overused-font", value = "geist mono" }]

stopped reaching the overlay. The rules the CLI and the edit hook waive
kept drawing markers and counting toward the badge.

Restore it end to end:

* BrowserConfig gains `disabled_values`, parsed leniently so a
  hand-edited __IMPECCABLE_CONFIG__ entry of the wrong shape is dropped
  rather than failing the whole config, the way the JS filter did.
* The driver applies the waivers after every pass, so a rule pack's
  findings are covered the same way the built-in ones are, honoring the
  entries only in extension mode exactly as the JS read them. The
  normalizer, the value extractor (including the rule that bounce-easing
  without a direct ignoreValue offers no value) and the hex/rgb color
  key are ported alongside it.
* collectConfigJson in the in-page bundle and configJson in the
  offscreen bundle forward the field. The extension never sends it, so
  its behavior is unchanged.

Coverage: two driver unit tests (suppression by font value, by hex
waiver across the rgb spelling, and the extension-mode gate; plus the
config parse and the normalizers), a skipScan test that pins the empty
shape for every stage the core produces, and
crates/wasm/tools/disabled-values-check.mjs, a browser-backed check
ported from the retired tests/detect-antipatterns-browser.test.mjs case
that the swap left without a replacement. Against the previous bundle it
fails on exactly the three waiver assertions and passes the skipScan
one, which is the shape of the regression.

Two related review findings were checked and are not defects. skipScan
is gated on extension mode in both the driver and the bundle, which is
what the JS did (index.mjs#skipScanActive), and the live overlay runs in
extension mode: live-browser.js sets `s.dataset.impeccableExtension` on
the injected /detect.js tag, and the overlay's whole detect toggle
travels over the postMessage loop that 50-scan.js installs only under
EXTENSION_MODE. The visual contrast stage is not leaking either:
collectBrowserFindingsAsync and scan() both consult skipScanActive(),
and the offscreen path skips its visual pass on config.skipScan.

The tracked live asset is regenerated (cargo xtask bundle). The oracle
replays with zero unreviewed differences: the new field defaults empty
and the filter is inert without it, and no CLI path sets extension mode.

AI-assisted change: implemented with Claude Code under maintainer
direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Shim test: run from a staged copy and prove the download happened

The three fail-closed cases cleared IMPECCABLE_BIN and pointed
IMPECCABLE_HOME at a temp dir, but locate() prefers an installed
@impeccable/cli-<os>-<arch> before the cache or a download. Those
platform packages ship with every engine release and are a merge
prerequisite, so as soon as one is installed under the repo the cases
would resolve it and go green without fetching anything. Confirmed by
hand: with a platform package staged in node_modules, running the shim
against an unreachable download base still exits 0 from the package.

The shim now runs from a throwaway copy at <tmp>/cli/bin/cli.js beside a
copy of the repo's package.json, with no node_modules on the lookup path
above it, so require.resolve of the platform package fails the way it
does on a machine without the optional dependency. Production code is
unchanged; there is no test-only branch in the shim.

The fixture server also records every request now, and each download case
asserts the asset and sidecar URLs were actually requested, so a future
lookup shortcut fails loudly instead of passing on an untested path. A
sixth case installs a fake platform package next to the staged shim and
asserts the shim prefers it with the server untouched, which pins the
precedence the other cases depend on being absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: the loader now hands off when the resume is the arrival

The overlay could sit in its generating shader over a DOM that already
held all three variants, and only a page refresh cleared it (#719).

The server's generation preflight runs live-wrap with
--defer-source-write, so the wrapper and every variant reach the DOM in a
single HMR batch. The deferred-wrapper scout is constructed at init and
the variant MutationObserver at Go; observer callbacks run in
construction order, so on that batch the scout resumes first and
resumeSession, not the observer, is the transition into CYCLING. It set
the state and the bar but never called hideShaderOverlay(), so the frozen
capture of the original stayed painted over the variants. It also
reported browser_resumed, which does not count as publication progress,
and then disconnected and re-created the observer, dropping the records
that observer had already queued for the same batch, so variants_ready
never fired at all.

resumeSession now finishes the same transition the observer does (shader
down, inline edit off, insert session finalized, params panel rebuilt)
and reports variants_ready when it already holds every variant. The
deferred scout names itself in the journal as
browser_resumed_deferred_wrapper, so the two resume paths are no longer
indistinguishable.

Wrapper resolution goes through findVariantsWrapper, which prefers a
wrapper that actually holds non-original variants. A target inside a
.map() renders one wrapper per item, and an agent that relocates the
wrapper out of the shared primitive live-wrap scaffolded leaves an empty
one behind; first match could pin either and strand the session at 0/N.
With zero or one match this is the querySelector it replaces.

Tests: waitForCycling now asserts the generating shader is gone once the
bar cycles, across every runtime fixture (it failed on vite8-react-plain
before this change and passes after), marked no-retry so the reload
recovery cannot hide it. Source-shape tests pin the transition, the
variants_ready report, and the wrapper preference.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live server: stop ends the process, SSE skips the mutation lane

Two Rust-only regressions found while investigating #719, both of which
can leave a tab waiting on a broadcast that never comes.

/stop ran shutdown() but never set shutting_down, and the accept loop
only breaks on that flag or a signal, so a stopped server kept its port
and kept answering while its server.json was already deleted. The next
`impeccable live` then booted a second server on another port and a tab
could reattach to the zombie. Node's shutdown() ended in process.exit(0).
The flag is now set after the response is written, so `stop` still reads
"stopping" instead of a reset connection, and the accept loop (already
non-blocking) exits on its next pass.

GET /events took a turnstile ticket and waited its turn before
registering, even though handle_sse releases that ticket two statements
later and needs no arrival ordering. A peer that stalls mid-request holds
the lane for the whole READ_REQUEST_DEADLINE, so a reconnecting stream
could sit unregistered for up to 10 seconds (measured 9.71s against 0.00s
on Node); broadcast is fire-and-forget, so a `done` landing in that
window reaches an empty client set and is gone. Registering early can
only make a stream see more broadcasts. The one cost is that the
connected frame's activeSessions snapshot may miss a mutation still in
flight, and the browser treats that snapshot as a hint. Preflights still
take a turn: answering those out of order reorders the POSTs the browser
issues behind them.

The route classification moved into releases_ticket_up_front so it can be
unit tested. tests/live-server-leak.test.mjs gains a guard that a stopped
server's pid is gone and its port is free.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: the shader teardown can no longer race its own construction

The new cycling assertion caught a real defect on CI: vite8-react-insert
reached CYCLING with #impeccable-live-shader still painted over the page.

showShaderOverlay is async. It appends its canvas synchronously, then
awaits createImageBitmap and finishes the GL setup before it publishes
shaderState. hideShaderOverlay returned early on a null shaderState, so a
teardown that landed inside that window did nothing, and the construction
then published itself over a session that had already left GENERATING,
with no teardown left to run. The scroll tick kept repositioning it,
which is why the CI page.html shows the canvas sized from the capture
rect but styled to the cycling anchor.

Every teardown now bumps a shader epoch before it does anything else, and
a construction pins the epoch it owns and abandons its canvas (releasing
the GL context) at every point past an await and before any publish,
including both bitmap-fallback publishes. A teardown also drops a shader
node that no shaderState owns, so an already-orphaned canvas cannot
survive one.

Reproduced by widening the append-to-publish window: with a 400ms delay
after uiAppend, vite8-react-insert failed with the CI error and the probe
showed the teardown arriving at CYCLING with shaderState still null.
The same run passes with this change, as does a 1500ms window on insert
and plain. Locally that window is about 4ms, which is why it only showed
on a slower runner.

The four remaining setLiveState('CYCLING') sites that did not lower the
loader now do: the SSE done handler (the one route that can reach CYCLING
from GENERATING), the Svelte republish remount, and the two accept
failure recoveries.

The e2e assertion already waits up to 5s for the shader to clear, so it
was never racing a legitimate teardown; it is left as it is.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: every active-session wrapper lookup goes through the resolver

Cursor Bugbot on #720: findVariantsWrapper alone was not enough.
resolveBarAnchor, the visible-variant element, mountedParameterCount,
readVisibleVariantFromDOM, showVariantInDOM, the source injection, and
the whole accept path still took the first [data-impeccable-variants]
match, so in the relocated-wrapper case Tune never bound and the bar kept
anchoring to the empty scaffold even after the resume reached CYCLING.

Thirteen call sites now resolve through findVariantsWrapper. The resolver
split in two so a missing id cannot silently widen the lookup to any
session: findVariantsWrapper(sessionId) returns null without an id, and
findAnyVariantsWrapper() is the entry point for the two resume paths that
have no id yet. Both share pickPopulatedVariantsWrapper, which is the old
querySelector whenever there are fewer than two matches.

Discard cleanup now hides every duplicate wrapper rather than the first,
since a target inside a `.map()` renders one per item and hiding one left
the rest of the discarded variants on screen.

What still takes a raw first match is deliberate: bare existence checks,
selector strings for stylesheets and observers (which want to cover every
match), querySelectorAll sweeps, the parsed source document, and the
Svelte component wrapper, which holds no variant children at all. The
source-shape test pins that exact set by name, so a new raw lookup fails
until it is either routed through the resolver or justified there.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Live: a discard releases every wrapper it hid

Bugbot on #720: the non-restoreOriginal discard now hides every matching
wrapper, but the delayed fallback still released only the first
querySelector hit. A target inside a `.map()` renders one wrapper per
item, so the rest stayed at display:none and their original content never
came back on the static and missed-HMR flows that fallback exists for.

The hide, the existence checks, and the release now all speak about the
same set. discardedWrappers(sessionId) is the one place that collects it;
releaseDiscardedStaticWrappers takes the stylesheet down once and
releases each wrapper; releaseDiscardedStaticWrapper drops its sessionId
argument and just unwinds the node it is given. The HMR-ownership
decision still reads the first wrapper, which is fair: duplicates all
render from one source element, so ownership is uniform across them. The
reload branch is unchanged because a reload restores every original at
once.

Covered by a source-shape test rather than an e2e scenario:
hasFrameworkHmrOwnership is true for every React, Vue, and Svelte runtime
fixture, so all of them take the watcher path and none can reach the
static release. The existing framework-ownership guards in the same file
move to the new shape and keep their intent, including the one that says
only non-discard cleanup may blank the wrapper while waiting for HMR.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Release: publish the npm platform packages in one command

bun run release:platform-packages downloads each engine-v<ENGINE_VERSION>
binary with its .sha256 sidecar (required; nothing unverified is
published), stages the package from cli/platform-packages/<target> with
the version stamped, the executable at bin/ and the repo LICENSE, and
runs npm publish --access public. Targets already on the registry are
skipped so a re-run resumes after a partial failure. Preconditions:
package.json pins equal ENGINE_VERSION and npm is logged in.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* release-engine: pin checkout, upload-artifact and download-artifact at v7

The v4 pins target Node 20, which the runner now deprecates and forces
onto Node 24 with a warning on every step. The rest of the workflows
already use v7.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Tests: make the temp-dir helpers unique under a coarse clock

Windows' system clock is coarse enough that two parallel tests could get
the same pid-plus-nanoseconds directory name and then remove each
other's files (rust-windows: close_verb_round_trip_and_ownership,
NotFound). A per-process counter is appended to the name.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

* Tests: declare the temp-dir counter in the hook cache-root tests

The previous commit referenced TMP_SEQ there without defining it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-09-04 10:42:45 -07:00
committed by GitHub
co-authored by Claude
parent 695df68a58
commit e2ff625b63
1676 changed files with 161105 additions and 110026 deletions
+937
View File
@@ -0,0 +1,937 @@
//! Static element adapters from `checks.mjs` Section 5 (`checkElement*`) and
//! their DOM helpers (`scopedIgnoreActive`, `isTabContextElement`,
//! `isStatusContextElement`, `cleanInlineText`, kicker / numbered-label
//! candidate collection, radial spotlight, clipped overflow). Every pure
//! check comes from `impeccable_core::checks`; this file only reads the DOM
//! and the computed style and hands plain data over.
use crate::background::{
a_ge, a_gt, read_own_background_color, resolve_background, resolve_background_info,
resolve_border_radius_px, resolve_gradient_stops, sv, sv_opt, CustomPropMap,
};
use crate::cascade::StyleValues;
use crate::dom::{StaticDocument, StaticElement};
use crate::quality::{collapse_ws, pf0, resolve_font_size_px};
use impeccable_core::checks::measures::{
self, border_colors_from_style, border_widths_from_style, check_gpt_thin_border_wide_shadow,
check_oversized_h1, check_radial_spotlight, positioned_style_implies_escape, resolve_length_px,
GptBorderShadowInput, OversizedH1Input, RadialSpotlightInput, StyleMap,
};
use impeccable_core::checks::rules::{
check_borders, check_colors, check_glow, check_hero_eyebrow, check_hover_contrast,
check_icon_tile, check_italic_serif, check_kicker_above_heading, check_motion,
is_emoji_only_text, is_heading_tag, resolve_hero_heading_size_px, BorderOpts, ColorOpts,
GlowOpts, HeroEyebrowOpts, HoverContrastOpts, IconTileOpts, ItalicSerifOpts, KickerCandidate,
MotionOpts, RuleHit, Sides,
};
use impeccable_core::checks::text_rules::{
check_numbered_section_labels, is_kicker_candidate, is_numbered_section_label_candidate,
parse_numbered_label_text, KickerCandidateInput, NumberedLabelCandidate,
NumberedLabelCandidateInput, HEADING_TAGS, KICKER_CARD_CONTEXT_SELECTOR, KICKER_SKIP_SELECTOR,
POSITIONED_CHILD_INTERACTIVE_SELECTOR,
};
use impeccable_core::color::{composite_color_over, parse_any_color, parse_rgb};
use impeccable_core::js::{self, parse_float, parse_int};
use impeccable_core::js_ext_a::num_truthy;
use impeccable_core::js_ext_b::slice_utf16_prefix;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
/// `StyleMap` view of a computed style for the core helpers.
pub struct StyleRef<'a>(pub &'a StyleValues);
impl StyleMap for StyleRef<'_> {
fn prop(&self, name: &str) -> Option<String> {
self.0.get(name).cloned()
}
}
fn hits(v: Vec<measures::Finding>) -> Vec<RuleHit> {
v.into_iter()
.map(|f| RuleHit {
id: f.id,
snippet: f.snippet,
})
.collect()
}
static WS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{}+", js::WS)).expect("WS_RE"));
static IGNORE_SPLIT_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!("[{},]+", js::WS_CHARS)).expect("IGNORE_SPLIT_RE"));
/// JS: checks.mjs#scopedIgnoreActive(el, ruleId)
pub fn scoped_ignore_active(el: &StaticElement<'_>, rule_id: &str) -> bool {
let rule = js::to_lower_case(rule_id);
let mut cur = Some(*el);
while let Some(e) = cur {
if let Some(attr) = e.get_attribute("data-impeccable-ignore") {
let lowered = js::to_lower_case(js::trim(attr));
let rules: Vec<&str> = IGNORE_SPLIT_RE
.split(&lowered)
.filter(|s| !s.is_empty())
.collect();
if rules.is_empty() || rules.contains(&"*") || rules.contains(&rule.as_str()) {
return true;
}
}
cur = e.parent_element();
}
false
}
static ACTIVE_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)(?:^|[{ws}_-])(?:active|current|selected)(?:$|[{ws}_-])",
ws = js::WS_CHARS
))
.expect("ACTIVE_CLASS_RE")
});
/// JS: checks.mjs#isTabContextElement(el)
pub fn is_tab_context_element(el: &StaticElement<'_>) -> bool {
if el
.closest("[aria-selected=\"true\"], [aria-current]:not([aria-current=\"false\"])")
.is_some()
{
return true;
}
let mut cur = Some(*el);
let mut depth = 0;
while let Some(e) = cur {
if depth >= 6 {
break;
}
if ACTIVE_CLASS_RE.is_match(e.class_name()) {
return true;
}
cur = e.parent_element();
depth += 1;
}
false
}
/// JS: checks.mjs#isStatusContextElement(el)
pub fn is_status_context_element(el: &StaticElement<'_>) -> bool {
el.closest("[role=\"status\"], [role=\"alert\"], [role=\"alertdialog\"], [role=\"log\"], [aria-live=\"polite\"], [aria-live=\"assertive\"]")
.is_some()
}
/// JS: checks.mjs#cleanInlineText(el): direct text nodes joined with a
/// space, whitespace collapsed, trimmed.
pub fn clean_inline_text(el: &StaticElement<'_>) -> String {
let parts: Vec<String> = el
.child_nodes()
.iter()
.filter_map(|c| match c {
crate::dom::ChildNode::Text(t) => Some(t.to_string()),
_ => None,
})
.collect();
js::trim(&collapse_ws(&parts.join(" "))).to_string()
}
/// `(el.textContent || '').replace(/\s+/g, ' ').trim()`
fn collapsed_text_content(el: &StaticElement<'_>) -> String {
js::trim(&collapse_ws(&el.text_content())).to_string()
}
/// JS: checks.mjs#isKickerCardContext(heading, kicker)
fn is_kicker_card_context(heading: &StaticElement<'_>, kicker: &StaticElement<'_>) -> bool {
match heading.closest(KICKER_CARD_CONTEXT_SELECTOR) {
Some(item) => item.contains(kicker),
None => false,
}
}
static HEADING_LEVEL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^h([1-6])$").expect("HEADING_LEVEL_RE"));
/// JS: checks.mjs#kickerHeadingLevel(heading)
fn kicker_heading_level(heading: &StaticElement<'_>) -> f64 {
let tag = heading.tag_lower();
if let Some(m) = HEADING_LEVEL_RE.captures(&tag) {
return parse_int(&m[1], 10);
}
let role = heading.get_attribute("role").unwrap_or("");
if js::to_lower_case(role) != "heading" {
return 0.0;
}
let aria_level = parse_int(heading.get_attribute("aria-level").unwrap_or(""), 10);
if aria_level.is_finite() && aria_level >= 1.0 {
aria_level
} else {
2.0
}
}
/// `(value, fontSize) => resolveLengthPx(value, fontSize) || 0`
fn resolve_len_or_zero(value: &str, font_size: f64) -> f64 {
match resolve_length_px(Some(value), font_size) {
Some(n) if num_truthy(n) => n,
_ => 0.0,
}
}
/// `resolveLetterSpacing(style.fontSize || '', 16) || parseFloat(style.fontSize) || 0`
fn font_size_of(style: &StyleValues) -> f64 {
let raw = sv(style, "fontSize");
let a = resolve_len_or_zero(raw, 16.0);
if num_truthy(a) {
return a;
}
pf0(raw)
}
fn strip_edge_quotes_slice(text: &str, n: usize) -> String {
slice_utf16_prefix(
&impeccable_core::checks::text_rules::strip_edge_quotes(text),
n,
)
}
/// JS: checks.mjs#collectKickerCandidates(doc, getStyle, resolveLetterSpacing)
pub fn collect_kicker_candidates(doc: &StaticDocument) -> Vec<KickerCandidate> {
let mut candidates = Vec::new();
for heading in doc.query_selector_all("h1, h2, h3, h4, [role=\"heading\"]") {
let heading_level = kicker_heading_level(&heading);
if !num_truthy(heading_level) || heading_level > 4.0 {
continue;
}
if heading.closest(KICKER_SKIP_SELECTOR).is_some() {
continue;
}
if heading
.closest("[role=\"tabpanel\"], [role=\"dialog\"], [role=\"application\"], dialog")
.is_some()
{
continue;
}
let Some(kicker) = heading.previous_element_sibling() else {
continue;
};
if kicker.closest(KICKER_SKIP_SELECTOR).is_some() {
continue;
}
if is_kicker_card_context(&heading, &kicker) {
continue;
}
let heading_style = heading.style();
let kicker_style = kicker.style();
let heading_tag = heading.tag_lower();
let heading_text = collapsed_text_content(&heading);
let kicker_text = {
let t = clean_inline_text(&kicker);
if t.is_empty() {
collapsed_text_content(&kicker)
} else {
t
}
};
let heading_font_size = font_size_of(heading_style);
let kicker_font_size = font_size_of(kicker_style);
let kicker_letter_spacing =
resolve_len_or_zero(sv(kicker_style, "letterSpacing"), kicker_font_size);
let kicker_font_variant = format!(
"{} {}",
sv(kicker_style, "fontVariant"),
sv(kicker_style, "fontVariantCaps")
);
if !is_kicker_candidate(&KickerCandidateInput {
heading_level,
heading_text: &heading_text,
heading_font_size,
kicker_tag: &kicker.tag_lower(),
kicker_text: &kicker_text,
kicker_text_transform: sv(kicker_style, "textTransform"),
kicker_font_variant: &kicker_font_variant,
kicker_font_size,
kicker_letter_spacing,
}) {
continue;
}
if heading_tag == "h1" && heading_font_size >= 48.0 && kicker_letter_spacing >= 1.6 {
continue;
}
candidates.push(KickerCandidate {
heading_tag,
heading_text: strip_edge_quotes_slice(&heading_text, 60),
kicker_text: slice_utf16_prefix(&kicker_text, 40),
});
}
candidates
}
/// JS: checks.mjs#checkKickerAboveHeadingFromDoc(doc, win)
pub fn check_kicker_above_heading_from_doc(doc: &StaticDocument) -> Vec<RuleHit> {
check_kicker_above_heading(&collect_kicker_candidates(doc))
}
/// JS: checks.mjs#collectNumberedSectionLabelCandidates(doc, getStyle, resolveLetterSpacing)
pub fn collect_numbered_section_label_candidates(
doc: &StaticDocument,
) -> Vec<NumberedLabelCandidate> {
let mut candidates = Vec::new();
let mut seen_labels: HashSet<ego_tree::NodeId> = HashSet::new();
for heading in doc.query_selector_all("h2, h3, h4") {
if heading.closest(KICKER_SKIP_SELECTOR).is_some() {
continue;
}
let mut label = heading.previous_element_sibling();
if label.is_none() {
if let Some(parent) = heading.parent_element() {
let first_child = parent.children().into_iter().next();
if first_child.is_some_and(|fc| fc == heading) {
label = parent.previous_element_sibling();
}
}
}
let Some(label) = label else {
continue;
};
if seen_labels.contains(&label.id()) {
continue;
}
if label.closest(KICKER_SKIP_SELECTOR).is_some() {
continue;
}
if HEADING_TAGS.contains(&label.tag_lower().as_str()) {
continue;
}
if is_kicker_card_context(&heading, &label) {
continue;
}
let label_text = {
let t = clean_inline_text(&label);
if t.is_empty() {
collapsed_text_content(&label)
} else {
t
}
};
let Some(parsed) = parse_numbered_label_text(Some(&label_text)) else {
continue;
};
let heading_style = heading.style();
let label_style = label.style();
let heading_text = collapsed_text_content(&heading);
let heading_font_size = font_size_of(heading_style);
let label_font_size = font_size_of(label_style);
if !is_numbered_section_label_candidate(&NumberedLabelCandidateInput {
heading_tag: &heading.tag_lower(),
heading_text: &heading_text,
heading_font_size,
label_tag: &label.tag_lower(),
label_index: Some(parsed.index),
label_text: &parsed.text,
label_font_size,
label_letter_spacing: resolve_len_or_zero(
sv(label_style, "letterSpacing"),
label_font_size,
),
label_font_weight: sv(label_style, "fontWeight"),
label_font_family: sv(label_style, "fontFamily"),
label_text_transform: sv(label_style, "textTransform"),
label_color: sv(label_style, "color"),
}) {
continue;
}
seen_labels.insert(label.id());
candidates.push(NumberedLabelCandidate {
index: parsed.index,
label_text: slice_utf16_prefix(&parsed.text, 24),
heading_tag: heading.tag_lower(),
heading_text: strip_edge_quotes_slice(&heading_text, 60),
});
}
candidates
}
/// JS: checks.mjs#checkNumberedSectionLabelsFromDoc(doc, win)
pub fn check_numbered_section_labels_from_doc(doc: &StaticDocument) -> Vec<RuleHit> {
hits(check_numbered_section_labels(
&collect_numbered_section_label_candidates(doc),
None,
))
}
// ─── Radial spotlight ───────────────────────────────────────────────────────
static RADIAL_RE: Lazy<Regex> = Lazy::new(|| Regex::new("(?i)radial-gradient").expect("RADIAL_RE"));
static INLINE_BG_IMAGE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)background(?:-image)?{ws}*:{ws}*([^;]+)",
ws = js::WS
))
.expect("INLINE_BG_IMAGE_RE")
});
/// JS: checks.mjs#elementGradientValue(style, el)
fn element_gradient_value(style: &StyleValues, el: &StaticElement<'_>) -> String {
let bg_image = match sv_opt(style, "backgroundImage") {
Some(v) if !v.is_empty() && v != "none" => v,
_ => "",
};
if RADIAL_RE.is_match(bg_image) {
return bg_image.to_string();
}
let bg = sv(style, "background");
if RADIAL_RE.is_match(bg) {
return bg.to_string();
}
let raw = el.get_attribute("style").unwrap_or("");
if let Some(m) = INLINE_BG_IMAGE_RE.captures(raw) {
if RADIAL_RE.is_match(&m[1]) {
return m[1].to_string();
}
}
String::new()
}
/// JS: checks.mjs#spotlightLabel(el)
fn spotlight_label(el: &StaticElement<'_>) -> String {
if let Some(name) = el.get_attribute("data-name") {
if !name.is_empty() {
return name.to_string();
}
}
let id = el.id_attr();
if !id.is_empty() {
return id.to_string();
}
let cls = js::trim(el.class_name());
if !cls.is_empty() {
if let Some(first) = WS_RE.split(cls).next() {
if !first.is_empty() {
return first.to_string();
}
}
}
el.tag_lower()
}
/// JS: checks.mjs#checkElementRadialSpotlight(el, style, tag, window)
pub fn check_element_radial_spotlight(el: &StaticElement<'_>, style: &StyleValues) -> Vec<RuleHit> {
let gradient_value = element_gradient_value(style, el);
if gradient_value.is_empty() {
return Vec::new();
}
let label = spotlight_label(el);
hits(check_radial_spotlight(&RadialSpotlightInput {
gradient_value: Some(&gradient_value),
width: pf0(sv(style, "width")),
height: pf0(sv(style, "height")),
label: Some(&label),
}))
}
// ─── Element adapters ───────────────────────────────────────────────────────
/// JS: checks.mjs#checkElementBorders(tag, style, overrides = null, resolvedRadius, el)
pub fn check_element_borders(
tag: &str,
style: &StyleValues,
resolved_radius: f64,
el: &StaticElement<'_>,
) -> Vec<RuleHit> {
let widths = Sides {
top: pf0(sv(style, "borderTopWidth")),
right: pf0(sv(style, "borderRightWidth")),
bottom: pf0(sv(style, "borderBottomWidth")),
left: pf0(sv(style, "borderLeftWidth")),
};
let colors = Sides {
top: Some(sv(style, "borderTopColor")),
right: Some(sv(style, "borderRightColor")),
bottom: Some(sv(style, "borderBottomColor")),
left: Some(sv(style, "borderLeftColor")),
};
let own_bg = parse_any_color(sv_opt(style, "backgroundColor"));
check_borders(
tag,
&widths,
&colors,
resolved_radius,
&BorderOpts {
tab_context: is_tab_context_element(el),
status_context: is_status_context_element(el),
badge_like: own_bg.is_some_and(|c| c.alpha_or_one() > 0.1),
},
)
}
/// JS: checks.mjs#checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule)
pub fn check_element_colors(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
custom_props: CustomPropMap<'_>,
) -> Vec<RuleHit> {
if sv_opt(style, "visibility") == Some("hidden") {
return Vec::new();
}
let mut eff_opacity = 1.0f64;
let mut cur = Some(*el);
while let Some(c) = cur {
if !(eff_opacity > 0.02) {
break;
}
let op = sv(c.style(), "opacity");
let op = if op.is_empty() { "1" } else { op };
eff_opacity *= parse_float(op);
cur = c.parent_element();
}
if eff_opacity <= 0.02 {
return Vec::new();
}
let direct_text = el.direct_text();
let has_direct_text = !js::trim(&direct_text).is_empty();
let bg_info = resolve_background_info(el, custom_props);
let effective_bg = bg_info.color;
let mut text_color =
custom_props.and_then(|m| measures::parse_color_resolved(sv_opt(style, "color"), Some(m)));
if text_color.is_none() {
text_color = parse_rgb(sv_opt(style, "color"));
}
// hasAnchorInheritRule is always false in the static engine.
let mut own_bg = custom_props
.and_then(|m| measures::parse_color_resolved(sv_opt(style, "backgroundColor"), Some(m)))
.or_else(|| read_own_background_color(el, style));
let mut final_effective_bg = effective_bg;
let mut surface_unresolved = bg_info.unresolved;
if own_bg.is_none() || own_bg.is_some_and(|c| c.alpha_or_one() <= 0.5) {
if let Some(pseudo) = el.doc.get_pseudo_surface(el.id()) {
own_bg = Some(pseudo);
final_effective_bg = Some(pseudo);
surface_unresolved = false;
}
}
let effective_bg_stops = if surface_unresolved || final_effective_bg.is_some() {
None
} else {
resolve_gradient_stops(el, custom_props)
};
let font_weight = {
let n = parse_int(sv(style, "fontWeight"), 10);
if num_truthy(n) {
n
} else {
400.0
}
};
let font_size = {
let n = parse_float(sv(style, "fontSize"));
if num_truthy(n) {
n
} else {
16.0
}
};
let bg_clip = {
let a = sv(style, "webkitBackgroundClip");
if !a.is_empty() {
a
} else {
sv(style, "backgroundClip")
}
};
check_colors(&ColorOpts {
tag: tag.to_string(),
text_color,
bg_color: own_bg,
effective_bg: if surface_unresolved {
None
} else {
final_effective_bg
},
effective_bg_stops,
font_size,
font_weight,
has_direct_text,
is_emoji_only: is_emoji_only_text(&direct_text),
bg_clip: Some(bg_clip.to_string()),
bg_image: Some(sv(style, "backgroundImage").to_string()),
class_list: Some(el.class_name().to_string()),
detector_is_browser: false,
})
}
/// JS: checks.mjs#checkElementHoverContrast(el, style, tag, window)
pub fn check_element_hover_contrast(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
) -> Vec<RuleHit> {
let Some(hover) = el.doc.get_hover_style(el.id()) else {
return Vec::new();
};
let direct_text = el.direct_text();
if js::trim(&direct_text).is_empty() {
return Vec::new();
}
let Some(text_color) = parse_any_color(sv_opt(hover, "color")) else {
return Vec::new();
};
if text_color.a.is_some_and(|a| a < 1.0) {
return Vec::new();
}
let resting_own_bg = parse_any_color(sv_opt(style, "backgroundColor"));
let hover_own_bg = parse_any_color(sv_opt(hover, "backgroundColor"));
let own_bg = hover_own_bg.or(resting_own_bg);
let bg = if own_bg.is_some_and(|c| a_ge(&c, 0.99)) {
own_bg.unwrap()
} else {
let base_el = el.parent_element().unwrap_or(*el);
let Some(under) = resolve_background(&base_el, None) else {
return Vec::new();
};
match own_bg {
Some(c) if a_gt(&c, 0.1) => composite_color_over(&c, &under),
_ => under,
}
};
let font_weight = {
let n = parse_int(sv(style, "fontWeight"), 10);
if num_truthy(n) {
n
} else {
400.0
}
};
let font_size = {
let n = parse_float(sv(style, "fontSize"));
if num_truthy(n) {
n
} else {
16.0
}
};
check_hover_contrast(&HoverContrastOpts {
tag: tag.to_string(),
text_color: Some(text_color),
bg: Some(bg),
own_bg_alpha: own_bg.map(|c| c.alpha_or_one()),
font_size,
font_weight,
has_direct_text: true,
is_emoji_only: is_emoji_only_text(&direct_text),
})
}
/// JS: checks.mjs#checkElementIconTile(el, tag, window)
pub fn check_element_icon_tile(el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
if !is_heading_tag(tag) {
return Vec::new();
}
let Some(sibling) = el.previous_element_sibling() else {
return Vec::new();
};
let sib_style = sibling.style();
let sib_width = pf0(sv(sib_style, "width"));
let sib_height = pf0(sv(sib_style, "height"));
let icon_child =
sibling.query_selector("svg, i[data-lucide], i[class*=\"fa-\"], i[class*=\"icon\"]");
let mut icon_width = 0.0;
if let Some(icon) = icon_child.as_ref() {
let w = parse_float(sv(icon.style(), "width"));
if num_truthy(w) {
icon_width = w;
} else {
let a = parse_float(icon.get_attribute("width").unwrap_or(""));
icon_width = if num_truthy(a) { a } else { 0.0 };
}
}
let sib_direct_text = sibling.direct_text();
let has_inline_emoji_icon =
sibling.children().is_empty() && is_emoji_only_text(&sib_direct_text);
check_icon_tile(&IconTileOpts {
heading_tag: tag.to_string(),
heading_text: Some(el.text_content()),
heading_top: 0.0,
sibling_tag: Some(sibling.tag_lower()),
sibling_width: sib_width,
sibling_height: sib_height,
sibling_bottom: 0.0,
sibling_bg_color: parse_rgb(sv_opt(sib_style, "backgroundColor")),
sibling_bg_image: Some(sv(sib_style, "backgroundImage").to_string()),
sibling_border_width: pf0(sv(sib_style, "borderTopWidth")),
sibling_border_radius: resolve_border_radius_px(sib_style, sib_width),
has_icon_child: icon_child.is_some() || has_inline_emoji_icon,
icon_child_width: icon_width,
})
}
/// JS: checks.mjs#checkElementItalicSerif(el, style, tag)
pub fn check_element_italic_serif(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
) -> Vec<RuleHit> {
if tag != "h1" && tag != "h2" {
return Vec::new();
}
check_italic_serif(&ItalicSerifOpts {
tag: tag.to_string(),
font_style: Some(sv(style, "fontStyle").to_string()),
font_family: Some(sv(style, "fontFamily").to_string()),
font_size: pf0(sv(style, "fontSize")),
heading_text: Some(el.text_content()),
})
}
/// JS: checks.mjs#checkElementHeroEyebrow(el, style, tag, window, customPropMap)
pub fn check_element_hero_eyebrow(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
) -> Vec<RuleHit> {
if tag != "h1" {
return Vec::new();
}
let Some(sibling) = el.previous_element_sibling() else {
return Vec::new();
};
let sib_style = sibling.style();
// customPropMap is null in the static engine: raw values pass through.
let font_size_raw = sv(sib_style, "fontSize");
let font_weight_raw = sv(sib_style, "fontWeight");
let letter_spacing_raw = sv_opt(sib_style, "letterSpacing");
let color_raw = sv(sib_style, "color");
let heading_font_size_raw = sv_opt(style, "fontSize");
let sibling_font_size = pf0(font_size_raw);
check_hero_eyebrow(&HeroEyebrowOpts {
heading_tag: tag.to_string(),
heading_text: Some(el.text_content()),
heading_font_size: resolve_hero_heading_size_px(heading_font_size_raw),
heading_in_application_context: el
.closest("[role=\"tabpanel\"], [role=\"dialog\"], [role=\"application\"], dialog")
.is_some(),
sibling_tag: Some(sibling.tag_lower()),
sibling_text: Some(sibling.text_content()),
sibling_text_transform: Some(sv(sib_style, "textTransform").to_string()),
sibling_font_size,
sibling_letter_spacing: match resolve_length_px(letter_spacing_raw, sibling_font_size) {
Some(n) if num_truthy(n) => n,
_ => 0.0,
},
sibling_font_weight: Some(font_weight_raw.to_string()),
sibling_color: Some(color_raw.to_string()),
sibling_has_accent_dash_pseudo: el.doc.has_accent_dash_pseudo(sibling.id()),
})
}
/// JS: checks.mjs#checkElementMotion(tag, style)
pub fn check_element_motion(tag: &str, style: &StyleValues) -> Vec<RuleHit> {
let timing: Vec<&str> = [
sv(style, "animationTimingFunction"),
sv(style, "transitionTimingFunction"),
]
.into_iter()
.filter(|s| !s.is_empty())
.collect();
check_motion(&MotionOpts {
tag: tag.to_string(),
transition_property: Some(sv(style, "transitionProperty").to_string()),
animation_name: Some(sv(style, "animationName").to_string()),
timing_functions: Some(timing.join(" ")),
class_list: Some(String::new()),
})
}
/// JS: checks.mjs#checkElementGlow(tag, style, effectiveBg)
pub fn check_element_glow(
style: &StyleValues,
effective_bg: Option<impeccable_core::color::Rgba>,
) -> Vec<RuleHit> {
let box_shadow = match sv_opt(style, "boxShadow") {
Some(v) if !v.is_empty() && v != "none" => v,
_ => "",
};
let text_shadow = match sv_opt(style, "textShadow") {
Some(v) if !v.is_empty() && v != "none" => v,
_ => "",
};
if box_shadow.is_empty() && text_shadow.is_empty() {
return Vec::new();
}
check_glow(&GlowOpts {
box_shadow: Some(box_shadow.to_string()),
text_shadow: Some(text_shadow.to_string()),
effective_bg,
})
}
/// JS: detect-html.mjs#checkElementBrokenImage(el)
pub fn check_element_broken_image(el: &StaticElement<'_>) -> Vec<RuleHit> {
let Some(src) = el.get_attribute("src") else {
return vec![RuleHit::new(
"broken-image",
"<img> with no src attribute".to_string(),
)];
};
let trimmed = js::trim(src);
if trimmed.is_empty() || trimmed == "#" {
return vec![RuleHit::new(
"broken-image",
format!("<img src=\"{}\">", src),
)];
}
Vec::new()
}
/// JS: checks.mjs#checkElementOversizedH1(el, style, tag, window)
pub fn check_element_oversized_h1(el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
if tag != "h1" {
return Vec::new();
}
let font_size = resolve_font_size_px(el);
let heading_text = collapse_ws(js::trim(&el.text_content()));
hits(check_oversized_h1(&OversizedH1Input {
tag,
font_size,
heading_text: &heading_text,
rect: None,
viewport_width: 0.0,
viewport_height: 0.0,
}))
}
/// JS: checks.mjs#checkElementGptBorderShadow(el, style)
pub fn check_element_gpt_border_shadow(style: &StyleValues) -> Vec<RuleHit> {
let s = StyleRef(style);
let widths = border_widths_from_style(&s);
let colors: Vec<Option<String>> = border_colors_from_style(&s)
.into_iter()
.map(|c| if c.is_empty() { None } else { Some(c) })
.collect();
hits(check_gpt_thin_border_wide_shadow(&GptBorderShadowInput {
border_widths: &widths,
border_colors: Some(&colors),
box_shadow: Some(sv(style, "boxShadow")),
}))
}
// ─── Clipped overflow container ─────────────────────────────────────────────
/// JS: checks.mjs#classSelector(el)
pub fn class_selector(el: &StaticElement<'_>) -> String {
let cls = js::trim(el.class_name());
let tokens: Vec<&str> = if cls.is_empty() {
Vec::new()
} else {
WS_RE.split(cls).filter(|s| !s.is_empty()).collect()
};
let tag = el.tag_lower();
if tokens.is_empty() {
tag
} else {
format!("{}.{}", tag, tokens.join("."))
}
}
static DECORATIVE_IDENT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?-u:\b)(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)(?-u:\b)")
.expect("DECORATIVE_IDENT_RE")
});
static VIEWPORT_ROLE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?-u:\b)(carousel|slider)(?-u:\b)").expect("VIEWPORT_ROLE_RE"));
static VIEWPORT_IDENT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?-u:\b)(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)(?-u:\b)")
.expect("VIEWPORT_IDENT_RE")
});
static VIEWPORT_DEMO_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?-u:\b)(demo-area|demo-stage|demo-viewport)(?-u:\b)").expect("VIEWPORT_DEMO_RE")
});
/// JS: checks.mjs#positionedChildHasSubstantiveContent(child)
fn positioned_child_has_substantive_content(child: &StaticElement<'_>) -> bool {
let text = collapsed_text_content(child);
if !text.is_empty() {
return true;
}
// StaticElement has no `matches`; only the descendant query applies.
child
.query_selector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)
.is_some()
}
/// JS: checks.mjs#positionedChildIsDecorative(child)
fn positioned_child_is_decorative(child: &StaticElement<'_>) -> bool {
if child.closest("[aria-hidden=\"true\"]").is_some() {
return true;
}
let role = js::to_lower_case(child.get_attribute("role").unwrap_or(""));
if role == "none" || role == "presentation" {
return true;
}
let tag = child.tag_lower();
if matches!(tag.as_str(), "img" | "svg" | "canvas" | "video") {
return true;
}
let ident = format!(
"{} {}",
child.get_attribute("class").unwrap_or(""),
child.get_attribute("id").unwrap_or("")
);
if DECORATIVE_IDENT_RE.is_match(&ident) && !positioned_child_has_substantive_content(child) {
return true;
}
false
}
/// JS: checks.mjs#clippingContainerIsIntentionalViewport(el)
fn clipping_container_is_intentional_viewport(el: &StaticElement<'_>) -> bool {
let role_description =
js::to_lower_case(el.get_attribute("aria-roledescription").unwrap_or(""));
if VIEWPORT_ROLE_RE.is_match(&role_description) {
return true;
}
let ident = js::to_lower_case(&format!(
"{} {}",
el.get_attribute("class").unwrap_or(""),
el.get_attribute("id").unwrap_or("")
));
VIEWPORT_IDENT_RE.is_match(&ident) || VIEWPORT_DEMO_RE.is_match(&ident)
}
/// JS: checks.mjs#checkClippedOverflow(el, style, getStyle) / checkElementClippedOverflow
pub fn check_element_clipped_overflow(el: &StaticElement<'_>, style: &StyleValues) -> Vec<RuleHit> {
let clips = |v: &str| v == "hidden" || v == "clip";
let scrolls = |v: &str| v == "auto" || v == "scroll";
let ox = sv(style, "overflowX");
let oy = sv(style, "overflowY");
let ov = sv(style, "overflow");
let clip_x = clips(ox) || clips(ov);
let clip_y = clips(oy) || clips(ov);
let any_clip = clip_x || clip_y;
let any_scroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
if !any_clip || any_scroll {
return Vec::new();
}
if clipping_container_is_intentional_viewport(el) {
return Vec::new();
}
for child in el.query_selector_all("*") {
let child_style = child.style();
let pos = sv(child_style, "position");
if pos == "absolute" || pos == "fixed" {
if positioned_child_is_decorative(&child) {
continue;
}
// No layout statically: `positionedChildEscapesClip` is null.
if !positioned_style_implies_escape(&StyleRef(child_style)) {
continue;
}
return vec![RuleHit::new(
"clipped-overflow-container",
format!("{} clips a positioned child", class_selector(el)),
)];
}
}
Vec::new()
}
+333
View File
@@ -0,0 +1,333 @@
//! Section 4 of `cli/engine/rules/checks.mjs`: the unified background walk
//! (`readOwnBackgroundColor`, `readCascadeBackgroundColor`,
//! `resolveBackgroundInfo`, `resolveBackground`, `resolveGradientStops`,
//! `compositeGradientStops`, `resolveBorderRadiusPx`), static-engine
//! branches only (`DETECTOR_IS_BROWSER === false`).
use crate::cascade::StyleValues;
use crate::dom::StaticElement;
use impeccable_core::checks::measures::{parse_color_resolved, parse_radius_to_px, CustomProps};
use impeccable_core::color::{
composite_color_over, is_no_paint_color_value, parse_any_color, parse_gradient_colors,
parse_rgb, split_top_level_commas, Rgba,
};
use impeccable_core::js;
use once_cell::sync::Lazy;
use regex::Regex;
/// `style.x || ''` on a computed style map.
pub fn sv<'a>(style: &'a StyleValues, key: &str) -> &'a str {
style.get(key).map(|s| s.as_str()).unwrap_or("")
}
/// `style.x` as JS sees it: `None` for a key the style object never had.
pub fn sv_opt<'a>(style: &'a StyleValues, key: &str) -> Option<&'a str> {
style.get(key).map(|s| s.as_str())
}
/// JS `c.a >= x` where `a` may be undefined (then false).
pub fn a_ge(c: &Rgba, x: f64) -> bool {
c.a.is_some_and(|a| a >= x)
}
/// JS `c.a > x`.
pub fn a_gt(c: &Rgba, x: f64) -> bool {
c.a.is_some_and(|a| a > x)
}
/// JS `c.a < x`.
pub fn a_lt(c: &Rgba, x: f64) -> bool {
c.a.is_some_and(|a| a < x)
}
/// The static engine's `customPropMap` is always `null`; kept as a
/// parameter so the port mirrors the JS signatures.
pub type CustomPropMap<'a> = Option<&'a dyn CustomProps>;
static INLINE_BG_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)background(?:-color)?{ws}*:{ws}*([^;]+)",
ws = js::WS
))
.expect("INLINE_BG_RE")
});
static INLINE_BG_IMAGE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)background(?:-image)?{ws}*:{ws}*([^;]+)",
ws = js::WS
))
.expect("INLINE_BG_IMAGE_RE")
});
static GRADIENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new("(?i)gradient").expect("GRADIENT_RE"));
static GRADIENT_CALL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(r"(?i)gradient{ws}*\(", ws = js::WS)).expect("GRADIENT_CALL_RE")
});
static URL_CALL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"(?i)url{ws}*\(", ws = js::WS)).expect("URL_CALL_RE"));
static URL_START_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"(?i)^{ws}*url{ws}*\(", ws = js::WS)).expect("URL_START_RE"));
static HEX_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)#([0-9a-f]{6}|[0-9a-f]{3})(?-u:\b)").expect("HEX_RE"));
static CURRENTCOLOR_RE: Lazy<Regex> =
Lazy::new(|| Regex::new("(?i)^currentcolor$").expect("CURRENTCOLOR_RE"));
/// `rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i)` → trimmed value.
fn inline_bg(el: &StaticElement<'_>) -> String {
let raw = el.get_attribute("style").unwrap_or("");
INLINE_BG_RE
.captures(raw)
.map(|m| js::trim(&m[1]).to_string())
.unwrap_or_default()
}
fn hex_to_rgba(h: &str) -> Rgba {
let p = |s: &str| js::parse_int(s, 16);
if h.len() == 6 {
Rgba::new(p(&h[0..2]), p(&h[2..4]), p(&h[4..6]), 1.0)
} else {
let d = |i: usize| {
let c = &h[i..i + 1];
p(&format!("{c}{c}"))
};
Rgba::new(d(0), d(1), d(2), 1.0)
}
}
/// JS: checks.mjs#readOwnBackgroundColor(el, computedStyle)
pub fn read_own_background_color(el: &StaticElement<'_>, style: &StyleValues) -> Option<Rgba> {
let bgc = sv_opt(style, "backgroundColor");
let bg = parse_rgb(bgc).or_else(|| parse_any_color(bgc));
if bg.as_ref().is_some_and(|c| a_ge(c, 0.1)) {
return bg;
}
let inline = inline_bg(el);
if inline.is_empty() {
return bg;
}
if GRADIENT_RE.is_match(&inline) || URL_CALL_RE.is_match(&inline) {
return bg;
}
if let Some(from_rgb) = parse_rgb(Some(&inline)) {
return Some(from_rgb);
}
if let Some(m) = HEX_RE.captures(&inline) {
return Some(hex_to_rgba(&m[1]));
}
bg
}
/// JS: checks.mjs#readCascadeBackgroundColor(current, style, customPropMap)
pub fn read_cascade_background_color(
current: &StaticElement<'_>,
style: &StyleValues,
custom_props: CustomPropMap<'_>,
) -> Option<Rgba> {
let bgc = sv_opt(style, "backgroundColor");
let mut bg = parse_rgb(bgc).or_else(|| parse_any_color(bgc));
if bg.is_none() || bg.as_ref().is_some_and(|c| a_lt(c, 0.1)) {
if let Some(map) = custom_props {
bg = parse_color_resolved(bgc, Some(map));
}
if bg.is_none() || bg.as_ref().is_some_and(|c| a_lt(c, 0.1)) {
let inline = inline_bg(current);
if !inline.is_empty()
&& !GRADIENT_RE.is_match(&inline)
&& !URL_CALL_RE.is_match(&inline)
{
bg = parse_color_resolved(Some(&inline), custom_props)
.or_else(|| parse_any_color(Some(&inline)));
}
}
}
bg
}
/// `{ color, unresolved }` from `resolveBackgroundInfo`.
#[derive(Debug, Clone, PartialEq)]
pub struct BackgroundInfo {
pub color: Option<Rgba>,
pub unresolved: bool,
}
fn flatten(overlays: &[Rgba], base: Rgba) -> Rgba {
let mut acc = base;
for o in overlays.iter().rev() {
acc = composite_color_over(o, &acc);
}
acc
}
/// JS: checks.mjs#resolveBackgroundInfo(el, win, customPropMap)
pub fn resolve_background_info(
el: &StaticElement<'_>,
custom_props: CustomPropMap<'_>,
) -> BackgroundInfo {
let mut current = Some(*el);
let mut overlays: Vec<Rgba> = Vec::new();
while let Some(cur) = current {
let style = cur.style();
let bg_image = sv(style, "backgroundImage");
let has_gradient_or_url = !bg_image.is_empty()
&& bg_image != "none"
&& (GRADIENT_RE.is_match(bg_image) || URL_CALL_RE.is_match(bg_image));
let mut bg = read_cascade_background_color(&cur, style, custom_props);
if (bg.is_none() || bg.as_ref().is_some_and(|c| a_lt(c, 0.1)))
&& CURRENTCOLOR_RE.is_match(js::trim(sv(style, "backgroundColor")))
{
let color = sv_opt(style, "color");
bg = parse_rgb(color).or_else(|| parse_color_resolved(color, custom_props));
}
if let Some(c) = bg.as_ref().filter(|c| a_gt(c, 0.1)) {
if a_ge(c, 0.99) {
return BackgroundInfo {
color: Some(flatten(&overlays, *c)),
unresolved: false,
};
}
overlays.push(*c);
} else if bg.is_none() && !is_no_paint_color_value(sv_opt(style, "backgroundColor")) {
return BackgroundInfo {
color: None,
unresolved: true,
};
}
if has_gradient_or_url {
let layers = split_top_level_commas(bg_image);
let top_paint_layer = layers
.iter()
.find(|layer| GRADIENT_CALL_RE.is_match(layer) || URL_CALL_RE.is_match(layer));
let gradient_on_top = top_paint_layer.is_some_and(|layer| {
GRADIENT_CALL_RE.is_match(layer) && !URL_START_RE.is_match(layer)
});
if !gradient_on_top {
return BackgroundInfo {
color: None,
unresolved: true,
};
}
let top = top_paint_layer.unwrap();
let url_beneath = layers
.iter()
.any(|layer| layer != top && URL_CALL_RE.is_match(layer));
if url_beneath {
let top_stops = parse_gradient_colors(Some(top));
let provably_opaque =
!top_stops.is_empty() && top_stops.iter().all(|s| s.alpha_or_one() >= 0.99);
if !provably_opaque {
return BackgroundInfo {
color: None,
unresolved: true,
};
}
}
return BackgroundInfo {
color: None,
unresolved: false,
};
}
current = cur.parent_element();
}
BackgroundInfo {
color: Some(flatten(&overlays, Rgba::new(255.0, 255.0, 255.0, 1.0))),
unresolved: false,
}
}
/// JS: checks.mjs#resolveBackground(el, win, customPropMap)
pub fn resolve_background(el: &StaticElement<'_>, custom_props: CustomPropMap<'_>) -> Option<Rgba> {
resolve_background_info(el, custom_props).color
}
/// JS: checks.mjs#resolveGradientStops(el, win, customPropMap)
pub fn resolve_gradient_stops(
el: &StaticElement<'_>,
custom_props: CustomPropMap<'_>,
) -> Option<Vec<Rgba>> {
let mut current = Some(*el);
let mut overlays: Vec<Rgba> = Vec::new();
while let Some(cur) = current {
let style = cur.style();
let bg_image = sv(style, "backgroundImage");
if !bg_image.is_empty() && bg_image != "none" && URL_CALL_RE.is_match(bg_image) {
return None;
}
let mut stops: Option<Vec<Rgba>> = None;
if !bg_image.is_empty() && bg_image != "none" && GRADIENT_RE.is_match(bg_image) {
let parsed = parse_gradient_colors(Some(bg_image));
if !parsed.is_empty() {
stops = Some(parsed);
}
}
if stops.is_none() {
let raw = cur.get_attribute("style").unwrap_or("");
if let Some(m) = INLINE_BG_IMAGE_RE.captures(raw) {
if GRADIENT_RE.is_match(&m[1]) {
let parsed = parse_gradient_colors(Some(&m[1]));
if !parsed.is_empty() {
stops = Some(parsed);
}
}
}
}
if let Some(stops) = stops {
let composited = composite_gradient_stops(&stops, &cur, custom_props);
let Some(composited) = composited else {
return None;
};
if overlays.is_empty() {
return Some(composited);
}
return Some(
composited
.into_iter()
.map(|stop| flatten(&overlays, stop))
.collect(),
);
}
let bg = read_cascade_background_color(&cur, style, custom_props);
if let Some(c) = bg.filter(|c| a_gt(c, 0.1)) {
if a_ge(&c, 0.99) {
return None;
}
overlays.push(c);
}
current = cur.parent_element();
}
None
}
/// JS: checks.mjs#compositeGradientStops(stops, gradientEl, win, customPropMap)
pub fn composite_gradient_stops(
stops: &[Rgba],
gradient_el: &StaticElement<'_>,
custom_props: CustomPropMap<'_>,
) -> Option<Vec<Rgba>> {
let has_alpha = stops.iter().any(|s| s.alpha_or_one() < 0.99);
if !has_alpha {
return Some(stops.to_vec());
}
let base_el = gradient_el.parent_element().unwrap_or(*gradient_el);
let base = resolve_background(&base_el, custom_props);
let mut out: Vec<Rgba> = Vec::new();
for s in stops {
let a = s.alpha_or_one();
if a >= 0.99 {
out.push(*s);
continue;
}
if let Some(base) = base.as_ref() {
out.push(composite_color_over(s, base));
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
/// JS: checks.mjs#resolveBorderRadiusPx(el, style, widthPx, win)
pub fn resolve_border_radius_px(style: &StyleValues, width_px: f64) -> f64 {
parse_radius_to_px(sv_opt(style, "borderRadius"), width_px).unwrap_or(0.0)
}
+468
View File
@@ -0,0 +1,468 @@
//! The DOM half of `css-cascade.mjs`: `collectStaticCssText`,
//! `buildStaticStyleMap` (rule matching, inline styles, the pseudo-element
//! accent-dash / surface marking, the computed-style pass and the hover
//! pass). Everything here writes into a [`StaticDocument`].
//!
//! `buildBorderOverrideMap` / `buildCustomPropMap` are not ported: they read
//! the jsdom CSSOM (`document.styleSheets`, `rule.style.borderLeft`) which
//! the static document never had, so in the static path they were dead
//! (`customPropMap` is `null`, `overrides` is `null`).
use super::checks_shim::CustomProps;
use super::{
apply_static_declaration, collect_static_css_rules, compare_static_priority,
is_static_inherited_prop, make_default_style, normalize_static_css_value,
parse_static_style_attribute, static_default_style, CssRule, DeclMeta, SpecifiedDecl,
SpecifiedStore, StyleValues, STATIC_DEFAULT_STYLE,
};
use crate::dom::StaticDocument;
use crate::profile::{self, Meta, ProfileSink};
use ego_tree::NodeId;
use impeccable_core::checks::css_scan::{collect_css_custom_props, css_length_to_px};
use impeccable_core::checks::measures;
use impeccable_core::color::parse_any_color;
use impeccable_common::jsp;
use impeccable_core::js;
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::path::Path;
use std::rc::Rc;
static STYLESHEET_REL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)(?-u:\b)stylesheet(?-u:\b)").expect("STYLESHEET_REL_RE"));
static REMOTE_HREF_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(https?:)?//").expect("REMOTE_HREF_RE"));
/// JS: css-cascade.mjs#resolveLinkedCssPath(fileDir, href)
/// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
/// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
/// invisible to every element-level check.
fn resolve_linked_css_path(file_dir: &str, href: &str) -> String {
let stripped = href.split(['?', '#']).next().unwrap_or("");
let root_relative = stripped.starts_with('/') && !stripped.starts_with("//");
if !root_relative {
return jsp::resolve("/", &[file_dir, stripped]);
}
// Drop "." and reject ".." so /../outside.css cannot walk out of dir.
let trimmed = stripped.trim_start_matches('/');
let segments: Vec<&str> = trimmed
.split(['/', '\\'])
.filter(|p| !p.is_empty() && *p != ".")
.collect();
if segments.iter().any(|p| *p == "..") {
let joined = segments
.iter()
.filter(|p| **p != "..")
.copied()
.collect::<Vec<_>>()
.join(jsp::SEP);
return jsp::join(&[file_dir, &joined]);
}
let rel = segments.join(jsp::SEP);
let mut dir = file_dir.to_string();
loop {
let parent = jsp::dirname(&dir);
if parent == dir {
break; // never use the filesystem root as document root
}
let candidate = jsp::join(&[&dir, &rel]);
if std::fs::metadata(&candidate)
.map(|m| m.is_file())
.unwrap_or(false)
{
return candidate;
}
// Stop at the project root so a coincidental ~/static/app.css cannot win.
if Path::new(&jsp::join(&[&dir, "package.json"])).exists()
|| Path::new(&jsp::join(&[&dir, ".git"])).exists()
{
break;
}
dir = parent;
}
jsp::join(&[file_dir, &rel])
}
/// JS: css-cascade.mjs#collectStaticCssText(root, fileDir, profile, filePath, modules)
/// The text of every `<style>` element plus every local `<link rel=stylesheet>`
/// resolved relative to `file_dir` (query/hash stripped), joined with `\n`.
/// `warn` receives the JS `process.stderr.write` notice for an unreadable
/// linked stylesheet (once per resolved path per scan).
pub fn collect_static_css_text(
doc: &StaticDocument,
file_dir: &Path,
profile: Option<&dyn ProfileSink>,
file_path: &str,
warn: Option<&dyn Fn(&str)>,
) -> String {
let mut style_texts: Vec<String> = Vec::new();
let mut warned_missing_stylesheets: std::collections::HashSet<String> =
std::collections::HashSet::new();
for style_el in doc.query_selector_all("style") {
style_texts.push(style_el.text_content());
}
let file_dir_str = file_dir.to_string_lossy().into_owned();
for link in doc.query_selector_all("link") {
let rel = link.get_attribute("rel").unwrap_or("");
let href = link.get_attribute("href").unwrap_or("");
if !STYLESHEET_REL_RE.is_match(rel) || href.is_empty() || REMOTE_HREF_RE.is_match(href) {
continue;
}
let css_path = resolve_linked_css_path(&file_dir_str, href);
let read = profile::step(
profile,
Meta::new("preprocess", "inline-linked-stylesheet", file_path).with_detail(href),
|| std::fs::read(&css_path),
);
match read {
Ok(bytes) => style_texts.push(String::from_utf8_lossy(&bytes).into_owned()),
Err(_) => {
if warned_missing_stylesheets.insert(css_path.clone()) {
if let Some(warn) = warn {
warn(&format!(
"impeccable detect: could not read linked stylesheet {href} (resolved to {css_path}); color and custom-property rules will be incomplete\n"
));
}
}
}
}
}
style_texts.join("\n")
}
static PSEUDO_RULE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)^(.+?){ws}*::?(?:before|after)$",
ws = js::WS
))
.expect("PSEUDO_RULE_RE")
});
static COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color-mix)\([^)]*(?:\([^)]*\))?[^)]*\)|#[0-9a-f]{3,8}(?-u:\b)")
.expect("COLOR_TOKEN_RE")
});
static ZERO_LEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^0(?:px)?$").expect("ZERO_LEN_RE"));
static WS_SPLIT_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!("{}+", js::WS)).expect("WS_SPLIT_RE"));
static GRADIENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new("(?i)gradient").expect("GRADIENT_RE"));
fn resolve_root(raw: &str, root: &impeccable_core::checks::css_scan::CustomProps) -> String {
let lookup = |name: &str| root.get(name).cloned();
measures::resolve_var_refs(raw, &lookup, 0)
}
/// The `::before` / `::after` rule pre-pass: mark base-selector matches whose
/// pseudo paints a short chromatic dash (`setAccentDashPseudo`) or a
/// full-cover opaque surface (`setPseudoSurface`).
fn mark_pseudo_rule(
doc: &mut StaticDocument,
rule: &CssRule,
base_selector: &str,
root_custom_props: &impeccable_core::checks::css_scan::CustomProps,
) {
let mut decls: IndexMap<String, String> = IndexMap::new();
for d in &rule.declarations {
decls.insert(js::to_lower_case(&d.prop), d.value.clone());
}
let get = |k: &str| decls.get(k).map(|s| s.as_str());
let first_of = |a: &str, b: &str| -> String {
match get(a) {
Some(v) if !v.is_empty() => v.to_string(),
_ => get(b).unwrap_or("").to_string(),
}
};
let w = css_length_to_px(&resolve_root(
&first_of("width", "inline-size"),
root_custom_props,
));
let h = css_length_to_px(&resolve_root(
&first_of("height", "block-size"),
root_custom_props,
));
if let (Some(w), Some(h)) = (w, h) {
if (8.0..=80.0).contains(&w) && (1.0..=6.0).contains(&h) {
let bg_raw = resolve_root(
&first_of("background-color", "background"),
root_custom_props,
);
let token = COLOR_TOKEN_RE.find(&bg_raw).map(|m| m.as_str().to_string());
let c = parse_any_color(Some(token.as_deref().unwrap_or(&bg_raw)));
if let Some(c) = c {
let mx = js::math_max3(c.r, c.g, c.b);
let mn = js::math_min3(c.r, c.g, c.b);
if c.alpha_or_one() >= 0.1 && mx - mn >= 30.0 {
let ids: Vec<NodeId> = doc
.query_selector_all(base_selector)
.iter()
.map(|e| e.id())
.collect();
for id in ids {
doc.set_accent_dash_pseudo(id);
}
}
}
}
}
let pseudo_pos = js::to_lower_case(get("position").unwrap_or(""));
if pseudo_pos == "absolute" || pseudo_pos == "fixed" {
let zero_len = |v: Option<&str>| v.is_some_and(|s| ZERO_LEN_RE.is_match(js::trim(s)));
let inset_raw = js::trim(get("inset").unwrap_or(""));
let covers_box = (!inset_raw.is_empty()
&& WS_SPLIT_RE
.split(inset_raw)
.all(|t| ZERO_LEN_RE.is_match(t)))
|| ["top", "right", "bottom", "left"]
.iter()
.all(|side| zero_len(get(side)))
|| (js::trim(get("width").unwrap_or("")) == "100%"
&& js::trim(get("height").unwrap_or("")) == "100%");
if covers_box && decls.contains_key("content") {
let surf_raw = resolve_root(
&first_of("background-color", "background"),
root_custom_props,
);
let token = COLOR_TOKEN_RE
.find(&surf_raw)
.map(|m| m.as_str().to_string());
let surf = parse_any_color(Some(token.as_deref().unwrap_or(&surf_raw)));
if let Some(surf) = surf {
if surf.alpha_or_one() >= 0.9 && !GRADIENT_RE.is_match(&surf_raw) {
let ids: Vec<NodeId> = doc
.query_selector_all(base_selector)
.iter()
.map(|e| e.id())
.collect();
for id in ids {
doc.set_pseudo_surface(id, surf);
}
}
}
}
}
}
/// JS: css-cascade.mjs#buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath)
pub fn build_static_style_map(
doc: &mut StaticDocument,
css_text: &str,
profile: Option<&dyn ProfileSink>,
file_path: &str,
) {
let mut specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let mut hover_specified: SpecifiedStore<NodeId> = SpecifiedStore::new();
let root_custom_props = collect_css_custom_props(css_text);
let rules = profile::step(
profile,
Meta::new("parse-css", "css-rules", file_path),
|| collect_static_css_rules(css_text),
);
profile::step(
profile,
Meta::new("selector-match", "css-selectors", file_path),
|| {
for rule in &rules {
if !rule.is_hover {
if let Some(pm) = PSEUDO_RULE_RE.captures(&rule.selector) {
let base = pm.get(1).map(|m| m.as_str()).unwrap_or("").to_string();
mark_pseudo_rule(doc, rule, &base, &root_custom_props);
continue;
}
}
let match_selector: Option<&str> = if rule.is_hover {
rule.match_selector.as_deref()
} else {
Some(rule.selector.as_str())
};
let Some(match_selector) = match_selector else {
continue;
};
let matched: Vec<NodeId> = match doc.compile(match_selector) {
Ok(_) => doc
.query_selector_all(match_selector)
.iter()
.map(|e| e.id())
.collect(),
Err(_) => {
profile::record(
profile,
Meta::new("selector-match", "unsupported-selector", file_path)
.with_detail(match_selector),
);
continue;
}
};
let store = if rule.is_hover {
&mut hover_specified
} else {
&mut specified
};
for node in matched {
for decl in &rule.declarations {
let meta = DeclMeta {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
};
apply_static_declaration(store, node, &decl.prop, &decl.value, &meta);
}
}
}
let mut inline_order: i64 = rules.len() as i64 + 1;
let inline_nodes: Vec<(NodeId, String)> = doc
.all_elements()
.iter()
.filter_map(|el| {
el.get_attribute("style")
.filter(|s| !s.is_empty())
.map(|s| (el.id(), s.to_string()))
})
.collect();
for (node, style_text) in inline_nodes {
for decl in parse_static_style_attribute(&style_text, inline_order) {
let meta = DeclMeta {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
};
apply_static_declaration(&mut specified, node, &decl.prop, &decl.value, &meta);
}
inline_order += 1000;
}
},
);
profile::step(
profile,
Meta::new("cascade", "compute-styles", file_path),
|| {
compute_styles(doc, &specified, &hover_specified);
},
);
}
/// The `computeNode` walk over every `tag`-typed element, root children
/// first, parents before children (an explicit stack, so a deep DOM cannot
/// overflow the call stack).
fn compute_styles(
doc: &mut StaticDocument,
specified: &SpecifiedStore<NodeId>,
hover_specified: &SpecifiedStore<NodeId>,
) {
let mut computed: HashMap<NodeId, Rc<StyleValues>> = HashMap::new();
let mut customs: HashMap<NodeId, Rc<CustomProps>> = HashMap::new();
let empty_custom: Rc<CustomProps> = Rc::new(CustomProps::new());
let empty_specified: IndexMap<String, SpecifiedDecl> = IndexMap::new();
// (node, parent) in pre-order.
let mut stack: Vec<(NodeId, Option<NodeId>)> = doc
.root_elements()
.iter()
.rev()
.filter(|e| e.is_plain_tag())
.map(|e| (e.id(), None))
.collect();
let mut hover_out: Vec<(NodeId, StyleValues)> = Vec::new();
while let Some((node, parent)) = stack.pop() {
let parent_style: Option<Rc<StyleValues>> = parent.and_then(|p| computed.get(&p).cloned());
let parent_custom: Rc<CustomProps> = parent
.and_then(|p| customs.get(&p).cloned())
.unwrap_or_else(|| empty_custom.clone());
let specified_map = specified.get(&node).unwrap_or(&empty_specified);
let mut custom_props: CustomProps = (*parent_custom).clone();
for (prop, decl) in specified_map {
if prop.starts_with("--") {
let resolved = super::checks_shim::resolve_var_refs(&decl.value, &custom_props);
custom_props.insert(prop.clone(), resolved);
}
}
let mut values: StyleValues = make_default_style();
for (prop, default) in STATIC_DEFAULT_STYLE {
let inherited = if is_static_inherited_prop(prop) {
parent_style.as_ref().and_then(|ps| ps.get(*prop)).cloned()
} else {
None
};
values.insert(
prop.to_string(),
inherited.unwrap_or_else(|| default.to_string()),
);
}
for (prop, decl) in specified_map {
if prop.starts_with("--") {
continue;
}
let next = normalize_static_css_value(
prop,
&decl.value,
&custom_props,
parent_style.as_deref(),
Some(&values),
);
values.insert(prop.clone(), next);
}
// Hover pass: color / backgroundColor only.
if let Some(hover_map) = hover_specified.get(&node) {
let mut hover_values: Option<StyleValues> = None;
for prop in ["color", "backgroundColor"] {
let Some(hover_decl) = hover_map.get(prop) else {
continue;
};
let resting = specified_map.get(prop).map(|d| &d.meta);
if !compare_static_priority(resting, &hover_decl.meta) {
continue;
}
let next = normalize_static_css_value(
prop,
&hover_decl.value,
&custom_props,
parent_style.as_deref(),
Some(&values),
);
if values.get(prop).map(|s| s.as_str()) == Some(next.as_str()) {
continue;
}
let hv = hover_values.get_or_insert_with(|| values.clone());
hv.insert(prop.to_string(), next);
}
if let Some(hv) = hover_values {
hover_out.push((node, hv));
}
}
let style_rc = Rc::new(values);
computed.insert(node, style_rc);
customs.insert(node, Rc::new(custom_props));
if let Some(el) = doc.element(node) {
let children = el.children();
for child in children.iter().rev() {
stack.push((child.id(), Some(node)));
}
}
}
for (node, style) in computed {
doc.set_style(
node,
Rc::try_unwrap(style).unwrap_or_else(|rc| (*rc).clone()),
);
}
for (node, style) in hover_out {
doc.set_hover_style(node, style);
}
}
/// `STATIC_DEFAULT_STYLE[prop]` lookup re-exported for the adapters.
pub fn default_value(prop: &str) -> Option<&'static str> {
static_default_style(prop)
}
+19
View File
@@ -0,0 +1,19 @@
//! Thin adapters over `impeccable_core::checks::measures` (`resolveVarRefs`,
//! `resolveLengthPx`) for the cascade's ordered custom-property map. The
//! pure logic lives in core; this module only bridges the map type.
use impeccable_core::checks::measures;
/// An ordered `--name -> value` map (JS `Map<string,string>`).
pub type CustomProps = indexmap::IndexMap<String, String>;
/// JS: checks.mjs#resolveVarRefs(raw, customPropMap, depth = 0), via core.
pub fn resolve_var_refs(raw: &str, custom_props: &CustomProps) -> String {
let lookup = |name: &str| custom_props.get(name).cloned();
measures::resolve_var_refs(raw, &lookup, 0)
}
/// JS: checks.mjs#resolveLengthPx(value, fontSizePx), via core.
pub fn resolve_length_px(value: &str, font_size_px: f64) -> Option<f64> {
measures::resolve_length_px(Some(value), font_size_px)
}
+179
View File
@@ -0,0 +1,179 @@
//! The css-tree AST subset the cascade needs (see `parser.rs`).
/// `Declaration.important`: `false`, `true` for `!important`, or the raw
/// ident for hacks like `!ie`.
#[derive(Debug, Clone, PartialEq)]
pub enum Important {
No,
Yes,
Other(String),
}
impl Important {
/// JS `!!child.important`.
pub fn truthy(&self) -> bool {
!matches!(self, Important::No)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Node {
StyleSheet {
children: Vec<Node>,
},
Rule {
prelude: Box<Node>,
block: Box<Node>,
},
Atrule {
name: String,
prelude: Option<Box<Node>>,
block: Option<Box<Node>>,
},
Block {
children: Vec<Node>,
},
Declaration {
important: Important,
property: String,
value: Box<Node>,
},
Raw {
value: String,
},
Comment {
value: String,
},
Cdo,
Cdc,
Value {
children: Vec<Node>,
},
WhiteSpace {
value: String,
},
Hash {
value: String,
},
Operator {
value: String,
},
Parentheses {
children: Vec<Node>,
},
Brackets {
children: Vec<Node>,
},
Str {
value: String,
},
Dimension {
value: String,
unit: String,
},
Percentage {
value: String,
},
Number {
value: String,
},
Function {
name: String,
children: Vec<Node>,
},
Url {
value: String,
},
Identifier {
name: String,
},
UnicodeRange {
value: String,
},
SelectorList {
children: Vec<Node>,
},
Selector {
children: Vec<Node>,
},
TypeSelector {
name: String,
},
ClassSelector {
name: String,
},
IdSelector {
name: String,
},
AttributeSelector {
name: Box<Node>,
matcher: Option<String>,
value: Option<Box<Node>>,
flags: Option<String>,
},
PseudoClassSelector {
name: String,
children: Option<Vec<Node>>,
},
PseudoElementSelector {
name: String,
children: Option<Vec<Node>>,
},
Combinator {
name: String,
},
NestingSelector,
Nth {
nth: Box<Node>,
selector: Option<Box<Node>>,
},
AnPlusB {
a: Option<String>,
b: Option<String>,
},
}
impl Node {
pub fn is_raw(&self) -> bool {
matches!(self, Node::Raw { .. })
}
pub fn type_name(&self) -> &'static str {
match self {
Node::StyleSheet { .. } => "StyleSheet",
Node::Rule { .. } => "Rule",
Node::Atrule { .. } => "Atrule",
Node::Block { .. } => "Block",
Node::Declaration { .. } => "Declaration",
Node::Raw { .. } => "Raw",
Node::Comment { .. } => "Comment",
Node::Cdo => "CDO",
Node::Cdc => "CDC",
Node::Value { .. } => "Value",
Node::WhiteSpace { .. } => "WhiteSpace",
Node::Hash { .. } => "Hash",
Node::Operator { .. } => "Operator",
Node::Parentheses { .. } => "Parentheses",
Node::Brackets { .. } => "Brackets",
Node::Str { .. } => "String",
Node::Dimension { .. } => "Dimension",
Node::Percentage { .. } => "Percentage",
Node::Number { .. } => "Number",
Node::Function { .. } => "Function",
Node::Url { .. } => "Url",
Node::Identifier { .. } => "Identifier",
Node::UnicodeRange { .. } => "UnicodeRange",
Node::SelectorList { .. } => "SelectorList",
Node::Selector { .. } => "Selector",
Node::TypeSelector { .. } => "TypeSelector",
Node::ClassSelector { .. } => "ClassSelector",
Node::IdSelector { .. } => "IdSelector",
Node::AttributeSelector { .. } => "AttributeSelector",
Node::PseudoClassSelector { .. } => "PseudoClassSelector",
Node::PseudoElementSelector { .. } => "PseudoElementSelector",
Node::Combinator { .. } => "Combinator",
Node::NestingSelector => "NestingSelector",
Node::Nth { .. } => "Nth",
Node::AnPlusB { .. } => "AnPlusB",
}
}
}
@@ -0,0 +1,399 @@
//! Port of css-tree 3.2.1 `lib/generator/` (`csstree.generate(node)` in the
//! default "safe" mode) for the node types the parser subset produces.
//!
//! The generator emits tokens and inserts a single space between two
//! adjacent tokens only where re-tokenizing would otherwise merge them
//! (`token-before.js` safe pairs). Raw / Combinator / TypeSelector /
//! Operator / UnicodeRange / AnPlusB values are re-tokenized and emitted
//! verbatim (auto-whitespace suppressed inside the chunk).
use super::ast::Node;
use super::strings::{encode_string, encode_url};
use super::tokenizer::*;
const PLUSSIGN: u32 = 0x2B;
const HYPHENMINUS: u32 = 0x2D;
const REVERSESOLIDUS: u32 = 0x5C;
/// token-before.js `code(type, value)`.
fn code(ty: TokenType, value: &str) -> u32 {
if ty == DELIM {
let c = value.chars().next().map(|c| c as u32).unwrap_or(0);
let c = std::cmp::min(c, 0x80) << 6;
return c << 1;
}
(ty as u32) << 1
}
fn code_char(c: char) -> u32 {
(std::cmp::min(c as u32, 0x80) << 6) << 1
}
/// The `safePairs` set as (prevCode, nextCode) keys.
fn safe_pairs() -> &'static std::collections::HashSet<u64> {
use once_cell::sync::Lazy;
static SET: Lazy<std::collections::HashSet<u64>> = Lazy::new(|| {
#[derive(Clone, Copy)]
enum K {
T(TokenType),
C(char),
}
use K::*;
let pairs: &[(K, K)] = &[
(T(IDENT), T(IDENT)),
(T(IDENT), T(FUNCTION)),
(T(IDENT), T(URL)),
(T(IDENT), T(BAD_URL)),
(T(IDENT), C('-')),
(T(IDENT), T(NUMBER)),
(T(IDENT), T(PERCENTAGE)),
(T(IDENT), T(DIMENSION)),
(T(IDENT), T(CDC)),
(T(IDENT), T(LEFT_PARENTHESIS)),
(T(AT_KEYWORD), T(IDENT)),
(T(AT_KEYWORD), T(FUNCTION)),
(T(AT_KEYWORD), T(URL)),
(T(AT_KEYWORD), T(BAD_URL)),
(T(AT_KEYWORD), C('-')),
(T(AT_KEYWORD), T(NUMBER)),
(T(AT_KEYWORD), T(PERCENTAGE)),
(T(AT_KEYWORD), T(DIMENSION)),
(T(AT_KEYWORD), T(CDC)),
(T(HASH), T(IDENT)),
(T(HASH), T(FUNCTION)),
(T(HASH), T(URL)),
(T(HASH), T(BAD_URL)),
(T(HASH), C('-')),
(T(HASH), T(NUMBER)),
(T(HASH), T(PERCENTAGE)),
(T(HASH), T(DIMENSION)),
(T(HASH), T(CDC)),
(T(DIMENSION), T(IDENT)),
(T(DIMENSION), T(FUNCTION)),
(T(DIMENSION), T(URL)),
(T(DIMENSION), T(BAD_URL)),
(T(DIMENSION), C('-')),
(T(DIMENSION), T(NUMBER)),
(T(DIMENSION), T(PERCENTAGE)),
(T(DIMENSION), T(DIMENSION)),
(T(DIMENSION), T(CDC)),
(C('#'), T(IDENT)),
(C('#'), T(FUNCTION)),
(C('#'), T(URL)),
(C('#'), T(BAD_URL)),
(C('#'), C('-')),
(C('#'), T(NUMBER)),
(C('#'), T(PERCENTAGE)),
(C('#'), T(DIMENSION)),
(C('#'), T(CDC)),
(C('-'), T(IDENT)),
(C('-'), T(FUNCTION)),
(C('-'), T(URL)),
(C('-'), T(BAD_URL)),
(C('-'), C('-')),
(C('-'), T(NUMBER)),
(C('-'), T(PERCENTAGE)),
(C('-'), T(DIMENSION)),
(C('-'), T(CDC)),
(T(NUMBER), T(IDENT)),
(T(NUMBER), T(FUNCTION)),
(T(NUMBER), T(URL)),
(T(NUMBER), T(BAD_URL)),
(T(NUMBER), T(NUMBER)),
(T(NUMBER), T(PERCENTAGE)),
(T(NUMBER), T(DIMENSION)),
(T(NUMBER), C('%')),
(T(NUMBER), T(CDC)),
(C('@'), T(IDENT)),
(C('@'), T(FUNCTION)),
(C('@'), T(URL)),
(C('@'), T(BAD_URL)),
(C('@'), C('-')),
(C('@'), T(CDC)),
(C('.'), T(NUMBER)),
(C('.'), T(PERCENTAGE)),
(C('.'), T(DIMENSION)),
(C('+'), T(NUMBER)),
(C('+'), T(PERCENTAGE)),
(C('+'), T(DIMENSION)),
(C('/'), C('*')),
// safe-mode additions
(T(IDENT), T(HASH)),
(T(DIMENSION), T(HASH)),
(T(HASH), T(HASH)),
(T(AT_KEYWORD), T(LEFT_PARENTHESIS)),
(T(AT_KEYWORD), T(STRING)),
(T(AT_KEYWORD), T(COLON)),
(T(PERCENTAGE), T(PERCENTAGE)),
(T(PERCENTAGE), T(DIMENSION)),
(T(PERCENTAGE), T(FUNCTION)),
(T(PERCENTAGE), C('-')),
(T(RIGHT_PARENTHESIS), T(IDENT)),
(T(RIGHT_PARENTHESIS), T(FUNCTION)),
(T(RIGHT_PARENTHESIS), T(PERCENTAGE)),
(T(RIGHT_PARENTHESIS), T(DIMENSION)),
(T(RIGHT_PARENTHESIS), T(HASH)),
(T(RIGHT_PARENTHESIS), C('-')),
];
let k = |x: K| -> u32 {
match x {
T(t) => (t as u32) << 1,
C(c) => code_char(c),
}
};
pairs
.iter()
.map(|(a, b)| ((k(*a) as u64) << 16) | k(*b) as u64)
.collect()
});
&SET
}
/// token-before.js `safe(prevCode, type, value)`: returns the next code with
/// bit 0 set when a space must be emitted first.
fn token_before_safe(prev_code: u32, ty: TokenType, value: &str) -> u32 {
let next_code = code(ty, value);
let next_char_code = value.chars().next().map(|c| c as u32).unwrap_or(0);
let key = |next: u32| -> u64 { (((prev_code & 0xFFFE) as u64) << 16) | next as u64 };
let emit_ws = if (next_char_code == HYPHENMINUS && ty != IDENT && ty != FUNCTION && ty != CDC)
|| next_char_code == PLUSSIGN
{
safe_pairs().contains(&key(next_char_code << 7))
} else {
safe_pairs().contains(&key(next_code))
};
next_code | u32::from(emit_ws)
}
pub struct Generator {
buffer: String,
prev_code: u32,
}
impl Generator {
fn token(&mut self, ty: TokenType, value: &str, suppress_auto_white_space: bool) {
self.prev_code = token_before_safe(self.prev_code, ty, value);
if !suppress_auto_white_space && (self.prev_code & 1) != 0 {
self.buffer.push(' ');
}
self.buffer.push_str(value);
if ty == DELIM && value.chars().next().map(|c| c as u32) == Some(REVERSESOLIDUS) {
self.buffer.push('\n');
}
}
fn tok(&mut self, ty: TokenType, value: &str) {
self.token(ty, value, false);
}
/// `this.tokenize(raw)`: re-tokenize and emit each token verbatim; only
/// the first may receive auto whitespace.
fn tokenize(&mut self, raw: &str) {
let chars: Vec<char> = raw.chars().collect();
let mut tokens: Vec<(TokenType, usize, usize)> = Vec::new();
tokenize(&chars, |ty, start, end| tokens.push((ty, start, end)));
for (ty, start, end) in tokens {
let value: String = chars[start..end].iter().collect();
self.token(ty, &value, start != 0);
}
}
fn children(&mut self, children: &[Node]) {
for c in children {
self.node(c);
}
}
fn children_with(&mut self, children: &[Node], delimiter: fn(&mut Generator, &Node)) {
let mut prev: Option<&Node> = None;
for c in children {
if let Some(p) = prev {
delimiter(self, p);
}
self.node(c);
prev = Some(c);
}
}
pub fn node(&mut self, node: &Node) {
match node {
Node::StyleSheet { children } => self.children(children),
Node::Rule { prelude, block } => {
self.node(prelude);
self.node(block);
}
Node::Atrule {
name,
prelude,
block,
} => {
self.tok(AT_KEYWORD, &format!("@{}", name));
if let Some(p) = prelude {
self.node(p);
}
match block {
Some(b) => self.node(b),
None => self.tok(SEMICOLON, ";"),
}
}
Node::Block { children } => {
self.tok(LEFT_CURLY_BRACKET, "{");
self.children_with(children, |g, prev| {
if matches!(prev, Node::Declaration { .. }) {
g.tok(SEMICOLON, ";");
}
});
self.tok(RIGHT_CURLY_BRACKET, "}");
}
Node::Declaration {
important,
property,
value,
} => {
self.tok(IDENT, property);
self.tok(COLON, ":");
self.node(value);
match important {
super::ast::Important::No => {}
super::ast::Important::Yes => {
self.tok(DELIM, "!");
self.tok(IDENT, "important");
}
super::ast::Important::Other(s) => {
self.tok(DELIM, "!");
self.tok(IDENT, s);
}
}
}
Node::Raw { value } => self.tokenize(value),
Node::Comment { value } => self.tok(COMMENT, &format!("/*{}*/", value)),
Node::Cdo => self.tok(CDO, "<!--"),
Node::Cdc => self.tok(CDC, "-->"),
Node::Value { children } => self.children(children),
Node::WhiteSpace { value } => self.tok(WHITESPACE, value),
Node::Hash { value } => self.tok(HASH, &format!("#{}", value)),
Node::Operator { value } => self.tokenize(value),
Node::Parentheses { children } => {
self.tok(LEFT_PARENTHESIS, "(");
self.children(children);
self.tok(RIGHT_PARENTHESIS, ")");
}
Node::Brackets { children } => {
self.tok(DELIM, "[");
self.children(children);
self.tok(DELIM, "]");
}
Node::Str { value } => self.tok(STRING, &encode_string(value)),
Node::Dimension { value, unit } => self.tok(DIMENSION, &format!("{}{}", value, unit)),
Node::Percentage { value } => self.tok(PERCENTAGE, &format!("{}%", value)),
Node::Number { value } => self.tok(NUMBER, value),
Node::Function { name, children } => {
self.tok(FUNCTION, &format!("{}(", name));
self.children(children);
self.tok(RIGHT_PARENTHESIS, ")");
}
Node::Url { value } => self.tok(URL, &encode_url(value)),
Node::Identifier { name } => self.tok(IDENT, name),
Node::UnicodeRange { value } => self.tokenize(value),
Node::SelectorList { children } => {
self.children_with(children, |g, _| g.tok(COMMA, ","));
}
Node::Selector { children } => self.children(children),
Node::TypeSelector { name } => self.tokenize(name),
Node::ClassSelector { name } => {
self.tok(DELIM, ".");
self.tok(IDENT, name);
}
Node::IdSelector { name } => {
// Delim instead of Hash: css-tree's hack to avoid a space
// between an ident and an id selector in safe mode.
self.tok(DELIM, &format!("#{}", name));
}
Node::AttributeSelector {
name,
matcher,
value,
flags,
} => {
self.tok(DELIM, "[");
self.node(name);
if let Some(m) = matcher {
self.tokenize(m);
if let Some(v) = value {
self.node(v);
}
}
if let Some(f) = flags {
self.tok(IDENT, f);
}
self.tok(DELIM, "]");
}
Node::PseudoClassSelector { name, children } => {
self.tok(COLON, ":");
match children {
None => self.tok(IDENT, name),
Some(c) => {
self.tok(FUNCTION, &format!("{}(", name));
self.children(c);
self.tok(RIGHT_PARENTHESIS, ")");
}
}
}
Node::PseudoElementSelector { name, children } => {
self.tok(COLON, ":");
self.tok(COLON, ":");
match children {
None => self.tok(IDENT, name),
Some(c) => {
self.tok(FUNCTION, &format!("{}(", name));
self.children(c);
self.tok(RIGHT_PARENTHESIS, ")");
}
}
}
Node::Combinator { name } => self.tokenize(name),
Node::NestingSelector => self.tok(DELIM, "&"),
Node::Nth { nth, selector } => {
self.node(nth);
if let Some(s) = selector {
self.tok(IDENT, "of");
self.node(s);
}
}
Node::AnPlusB { a, b } => {
if let Some(a) = a.as_deref().filter(|s| !s.is_empty()) {
let a_str = match a {
"+1" | "1" => "n".to_string(),
"-1" => "-n".to_string(),
_ => format!("{}n", a),
};
match b.as_deref().filter(|s| !s.is_empty()) {
Some(b) => {
let b_str = if b.starts_with('-') || b.starts_with('+') {
b.to_string()
} else {
format!("+{}", b)
};
self.tokenize(&format!("{}{}", a_str, b_str));
}
None => self.tokenize(&a_str),
}
} else {
// JS `this.tokenize(node.b)`; b is a string here.
let b = b.clone().unwrap_or_default();
self.tokenize(&b);
}
}
}
}
}
/// `csstree.generate(node)`.
pub fn generate(node: &Node) -> String {
let mut g = Generator {
buffer: String::new(),
prev_code: 0,
};
g.node(node);
g.buffer
}
+15
View File
@@ -0,0 +1,15 @@
//! A port of the css-tree 3.2.1 subset that `collectStaticCssRules` relies
//! on: `csstree.parse(cssText, { positions: false, parseValue: true,
//! parseCustomProperty: false })` for a stylesheet, and `csstree.generate`
//! for rule preludes and declaration values. See `rules.rs` for the list of
//! css-tree behaviors the cascade port reproduces through this module.
pub mod ast;
pub mod generator;
pub mod parser;
pub mod strings;
pub mod tokenizer;
pub use ast::{Important, Node};
pub use generator::generate;
pub use parser::{parse_stylesheet, ParseError};
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
//! Port of css-tree `lib/utils/string.js` and `lib/utils/url.js`
//! (decode on parse, encode on generate).
use super::tokenizer::{
consume_escaped, decode_escaped, is_hex_digit, is_valid_escape, is_white_space,
};
const REVERSE_SOLIDUS: u32 = 0x5C;
const QUOTATION_MARK: u32 = 0x22;
const APOSTROPHE: u32 = 0x27;
const SPACE: u32 = 0x20;
const LEFTPARENTHESIS: u32 = 0x28;
const RIGHTPARENTHESIS: u32 = 0x29;
fn code_at(s: &[char], i: usize) -> u32 {
if i < s.len() {
s[i] as u32
} else {
0
}
}
/// Shared body of string/url decode: `[start, end]` inclusive char range.
fn decode_range(s: &[char], start: usize, end: usize, len: usize) -> String {
let mut decoded = String::new();
let mut i = start;
while i <= end && i < len {
let mut code = s[i] as u32;
if code == REVERSE_SOLIDUS {
if i == end {
// if the next input code point is EOF, do nothing
// otherwise include last quote as escaped
if i != len - 1 {
decoded = s[i + 1..].iter().collect();
}
break;
}
i += 1;
code = code_at(s, i);
if is_valid_escape(REVERSE_SOLIDUS, code) {
let escape_start = i - 1;
let escape_end = consume_escaped(s, escape_start);
i = escape_end - 1;
let body: Vec<char> = s[escape_start + 1..escape_end.min(len)].to_vec();
decoded.push_str(&decode_escaped(&body));
} else if code == 0x0D && code_at(s, i + 1) == 0x0A {
i += 1;
}
} else {
decoded.push(s[i]);
}
i += 1;
}
decoded
}
/// css-tree `string.decode`: strips the quotes and resolves escapes.
pub fn decode_string(str_: &str) -> String {
let s: Vec<char> = str_.chars().collect();
let len = s.len();
if len == 0 {
return String::new();
}
let first = s[0] as u32;
let start = if first == QUOTATION_MARK || first == APOSTROPHE {
1
} else {
0
};
let end = if start == 1 && len > 1 && s[len - 1] as u32 == first {
len - 2
} else {
len - 1
};
if end + 1 < start {
return String::new();
}
// JS loops i from start to end inclusive; with start=1,end=len-2 for `""`
// (len 2) that is 1..=0, an empty loop.
if start > end {
return String::new();
}
decode_range(&s, start, end, len)
}
/// css-tree `string.encode` (CSSOM serialize-a-string), double quotes.
pub fn encode_string(str_: &str) -> String {
let mut encoded = String::from("\"");
let mut ws_before_hex_is_needed = false;
for ch in str_.chars() {
let code = ch as u32;
if code == 0 {
encoded.push('\u{FFFD}');
continue;
}
if code <= 0x1F || code == 0x7F {
encoded.push('\\');
encoded.push_str(&format!("{:x}", code));
ws_before_hex_is_needed = true;
continue;
}
if code == QUOTATION_MARK || code == REVERSE_SOLIDUS {
encoded.push('\\');
encoded.push(ch);
ws_before_hex_is_needed = false;
} else {
if ws_before_hex_is_needed && (is_hex_digit(code) || is_white_space(code)) {
encoded.push(' ');
}
encoded.push(ch);
ws_before_hex_is_needed = false;
}
}
encoded.push('"');
encoded
}
/// css-tree `url.decode`: strips `url(` `)` and surrounding whitespace,
/// resolves escapes.
pub fn decode_url(str_: &str) -> String {
let s: Vec<char> = str_.chars().collect();
let len = s.len();
let mut start = 4usize;
let mut end: isize = if len > 0 && s[len - 1] as u32 == RIGHTPARENTHESIS {
len as isize - 2
} else {
len as isize - 1
};
while (start as isize) < end && is_white_space(code_at(&s, start)) {
start += 1;
}
while (start as isize) < end && is_white_space(code_at(&s, end as usize)) {
end -= 1;
}
if end < start as isize {
return String::new();
}
decode_range(&s, start, end as usize, len)
}
/// css-tree `url.encode`.
pub fn encode_url(str_: &str) -> String {
let mut encoded = String::from("url(");
let mut ws_before_hex_is_needed = false;
for ch in str_.chars() {
let code = ch as u32;
if code == 0 {
encoded.push('\u{FFFD}');
continue;
}
if code <= 0x1F || code == 0x7F {
encoded.push('\\');
encoded.push_str(&format!("{:x}", code));
ws_before_hex_is_needed = true;
continue;
}
if code == SPACE
|| code == REVERSE_SOLIDUS
|| code == QUOTATION_MARK
|| code == APOSTROPHE
|| code == LEFTPARENTHESIS
|| code == RIGHTPARENTHESIS
{
encoded.push('\\');
encoded.push(ch);
ws_before_hex_is_needed = false;
} else {
if ws_before_hex_is_needed && is_hex_digit(code) {
encoded.push(' ');
}
encoded.push(ch);
ws_before_hex_is_needed = false;
}
}
encoded.push(')');
encoded
}
@@ -0,0 +1,956 @@
//! Port of css-tree 3.2.1 `lib/tokenizer/` (CSS Syntax Level 3 tokenizer,
//! the char-code predicates, and `TokenStream` with its balance table).
//!
//! The JS walks UTF-16 code units; this port walks `char`s. Every predicate
//! treats any code point >= 0x80 the same way (name-start), so token
//! boundaries and the sliced text come out identical for both units.
// ─── Token types (lib/tokenizer/types.js) ───────────────────────────────────
pub type TokenType = u8;
pub const EOF: TokenType = 0;
pub const IDENT: TokenType = 1;
pub const FUNCTION: TokenType = 2;
pub const AT_KEYWORD: TokenType = 3;
pub const HASH: TokenType = 4;
pub const STRING: TokenType = 5;
pub const BAD_STRING: TokenType = 6;
pub const URL: TokenType = 7;
pub const BAD_URL: TokenType = 8;
pub const DELIM: TokenType = 9;
pub const NUMBER: TokenType = 10;
pub const PERCENTAGE: TokenType = 11;
pub const DIMENSION: TokenType = 12;
pub const WHITESPACE: TokenType = 13;
pub const CDO: TokenType = 14;
pub const CDC: TokenType = 15;
pub const COLON: TokenType = 16;
pub const SEMICOLON: TokenType = 17;
pub const COMMA: TokenType = 18;
pub const LEFT_SQUARE_BRACKET: TokenType = 19;
pub const RIGHT_SQUARE_BRACKET: TokenType = 20;
pub const LEFT_PARENTHESIS: TokenType = 21;
pub const RIGHT_PARENTHESIS: TokenType = 22;
pub const LEFT_CURLY_BRACKET: TokenType = 23;
pub const RIGHT_CURLY_BRACKET: TokenType = 24;
pub const COMMENT: TokenType = 25;
// ─── Char code definitions (lib/tokenizer/char-code-definitions.js) ────────
pub fn is_digit(code: u32) -> bool {
(0x30..=0x39).contains(&code)
}
pub fn is_hex_digit(code: u32) -> bool {
is_digit(code) || (0x41..=0x46).contains(&code) || (0x61..=0x66).contains(&code)
}
pub fn is_uppercase_letter(code: u32) -> bool {
(0x41..=0x5A).contains(&code)
}
pub fn is_lowercase_letter(code: u32) -> bool {
(0x61..=0x7A).contains(&code)
}
pub fn is_letter(code: u32) -> bool {
is_uppercase_letter(code) || is_lowercase_letter(code)
}
pub fn is_non_ascii(code: u32) -> bool {
code >= 0x80
}
pub fn is_name_start(code: u32) -> bool {
is_letter(code) || is_non_ascii(code) || code == 0x5F
}
pub fn is_name(code: u32) -> bool {
is_name_start(code) || is_digit(code) || code == 0x2D
}
pub fn is_non_printable(code: u32) -> bool {
code <= 0x08 || code == 0x0B || (0x0E..=0x1F).contains(&code) || code == 0x7F
}
pub fn is_newline(code: u32) -> bool {
code == 0x0A || code == 0x0D || code == 0x0C
}
pub fn is_white_space(code: u32) -> bool {
is_newline(code) || code == 0x20 || code == 0x09
}
pub fn is_valid_escape(first: u32, second: u32) -> bool {
if first != 0x5C {
return false;
}
if is_newline(second) || second == 0 {
return false;
}
true
}
pub fn is_identifier_start(first: u32, second: u32, third: u32) -> bool {
if first == 0x2D {
return is_name_start(second) || second == 0x2D || is_valid_escape(second, third);
}
if is_name_start(first) {
return true;
}
if first == 0x5C {
return is_valid_escape(first, second);
}
false
}
pub fn is_number_start(first: u32, second: u32, third: u32) -> u32 {
if first == 0x2B || first == 0x2D {
if is_digit(second) {
return 2;
}
return if second == 0x2E && is_digit(third) {
3
} else {
0
};
}
if first == 0x2E {
return if is_digit(second) { 2 } else { 0 };
}
if is_digit(first) {
return 1;
}
0
}
pub fn is_bom(code: u32) -> usize {
if code == 0xFEFF || code == 0xFFFE {
1
} else {
0
}
}
pub const EOF_CATEGORY: u32 = 0x80;
pub const WHITESPACE_CATEGORY: u32 = 0x82;
pub const DIGIT_CATEGORY: u32 = 0x83;
pub const NAME_START_CATEGORY: u32 = 0x84;
pub const NON_PRINTABLE_CATEGORY: u32 = 0x85;
pub fn char_code_category(code: u32) -> u32 {
if code >= 0x80 {
return NAME_START_CATEGORY;
}
if is_white_space(code) {
WHITESPACE_CATEGORY
} else if is_digit(code) {
DIGIT_CATEGORY
} else if is_name_start(code) {
NAME_START_CATEGORY
} else if is_non_printable(code) {
NON_PRINTABLE_CATEGORY
} else if code != 0 {
code
} else {
EOF_CATEGORY
}
}
// ─── Utils (lib/tokenizer/utils.js) ─────────────────────────────────────────
/// `source.charCodeAt(offset)` with 0 past the end (and for negative offsets).
pub fn char_at(source: &[char], offset: usize) -> u32 {
if offset < source.len() {
source[offset] as u32
} else {
0
}
}
pub fn get_newline_length(source: &[char], offset: usize, code: u32) -> usize {
if code == 13 && char_at(source, offset + 1) == 10 {
2
} else {
1
}
}
pub fn cmp_char(source: &[char], offset: usize, reference_code: u32) -> bool {
// JS `testStr.charCodeAt(offset)` yields NaN past the end, which never
// equals the reference; 0 does the same job here.
let mut code = char_at(source, offset);
if is_uppercase_letter(code) {
code |= 32;
}
code == reference_code
}
pub fn cmp_str(source: &[char], start: usize, end: usize, reference: &str) -> bool {
let reference: Vec<char> = reference.chars().collect();
if end < start || end - start != reference.len() {
return false;
}
if end > source.len() {
return false;
}
for i in start..end {
let reference_code = reference[i - start] as u32;
let mut test_code = source[i] as u32;
if is_uppercase_letter(test_code) {
test_code |= 32;
}
if test_code != reference_code {
return false;
}
}
true
}
pub fn find_white_space_end(source: &[char], mut offset: usize) -> usize {
while offset < source.len() {
if !is_white_space(source[offset] as u32) {
break;
}
offset += 1;
}
offset
}
pub fn find_decimal_number_end(source: &[char], mut offset: usize) -> usize {
while offset < source.len() {
if !is_digit(source[offset] as u32) {
break;
}
offset += 1;
}
offset
}
/// § 4.3.7. Consume an escaped code point (offset is at the `\`).
pub fn consume_escaped(source: &[char], mut offset: usize) -> usize {
offset += 2;
if is_hex_digit(char_at(source, offset - 1)) {
let max_offset = std::cmp::min(source.len(), offset + 5);
while offset < max_offset {
if !is_hex_digit(char_at(source, offset)) {
break;
}
offset += 1;
}
let code = char_at(source, offset);
if is_white_space(code) {
offset += get_newline_length(source, offset, code);
}
}
offset
}
/// § 4.3.11. Consume a name.
pub fn consume_name(source: &[char], mut offset: usize) -> usize {
while offset < source.len() {
let code = source[offset] as u32;
if is_name(code) {
offset += 1;
continue;
}
if is_valid_escape(code, char_at(source, offset + 1)) {
offset = consume_escaped(source, offset);
continue;
}
break;
}
offset
}
/// § 4.3.12. Consume a number.
pub fn consume_number(source: &[char], mut offset: usize) -> usize {
let mut code = char_at(source, offset);
if code == 0x2B || code == 0x2D {
offset += 1;
code = char_at(source, offset);
}
if is_digit(code) {
offset = find_decimal_number_end(source, offset + 1);
code = char_at(source, offset);
}
if code == 0x2E && is_digit(char_at(source, offset + 1)) {
offset += 2;
offset = find_decimal_number_end(source, offset);
}
if cmp_char(source, offset, 101) {
let mut sign = 0;
code = char_at(source, offset + 1);
if code == 0x2D || code == 0x2B {
sign = 1;
code = char_at(source, offset + 2);
}
if is_digit(code) {
offset = find_decimal_number_end(source, offset + 1 + sign + 1);
}
}
offset
}
/// § 4.3.14. Consume the remnants of a bad url.
pub fn consume_bad_url_remnants(source: &[char], mut offset: usize) -> usize {
while offset < source.len() {
let code = source[offset] as u32;
if code == 0x29 {
offset += 1;
break;
}
if is_valid_escape(code, char_at(source, offset + 1)) {
offset = consume_escaped(source, offset);
}
offset += 1;
}
offset
}
/// § 4.3.7. Decode an escape body (without the leading `\`).
pub fn decode_escaped(escaped: &[char]) -> String {
if escaped.len() == 1 && !is_hex_digit(escaped[0] as u32) {
return escaped[0].to_string();
}
let hex: String = escaped.iter().collect();
// JS parseInt(escaped, 16): parses the leading hex digits.
let digits: String = hex.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
let mut code: u32 = if digits.is_empty() {
// NaN: JS then compares NaN === 0 (false), NaN in ranges (false)
// and String.fromCodePoint(NaN) throws. Unreachable for valid escapes.
0xFFFD
} else {
u32::from_str_radix(&digits, 16).unwrap_or(0xFFFD)
};
if code == 0 || (0xD800..=0xDFFF).contains(&code) || code > 0x10FFFF {
code = 0xFFFD;
}
char::from_u32(code).unwrap_or('\u{FFFD}').to_string()
}
// ─── tokenize (lib/tokenizer/index.js) ──────────────────────────────────────
/// Runs the CSS tokenizer over `source`, calling `on_token(type, start, end)`
/// for each token in order.
pub fn tokenize<F: FnMut(TokenType, usize, usize)>(source: &[char], mut on_token: F) {
let source_length = source.len();
let get = |offset: usize| char_at(source, offset);
let mut start = is_bom(get(0));
let mut offset = start;
let mut ty: TokenType;
// § 4.3.3. Consume a numeric token
let consume_numeric_token = |offset: &mut usize, ty: &mut TokenType| {
*offset = consume_number(source, *offset);
if is_identifier_start(get(*offset), get(*offset + 1), get(*offset + 2)) {
*ty = DIMENSION;
*offset = consume_name(source, *offset);
return;
}
if get(*offset) == 0x25 {
*ty = PERCENTAGE;
*offset += 1;
return;
}
*ty = NUMBER;
};
// § 4.3.6. Consume a url token
let consume_url_token = |offset: &mut usize, ty: &mut TokenType| {
*ty = URL;
*offset = find_white_space_end(source, *offset);
while *offset < source.len() {
let code = source[*offset] as u32;
match char_code_category(code) {
0x29 => {
*offset += 1;
return;
}
WHITESPACE_CATEGORY => {
*offset = find_white_space_end(source, *offset);
if get(*offset) == 0x29 || *offset >= source.len() {
if *offset < source.len() {
*offset += 1;
}
return;
}
*offset = consume_bad_url_remnants(source, *offset);
*ty = BAD_URL;
return;
}
0x22 | 0x27 | 0x28 | NON_PRINTABLE_CATEGORY => {
*offset = consume_bad_url_remnants(source, *offset);
*ty = BAD_URL;
return;
}
0x5C => {
if is_valid_escape(code, get(*offset + 1)) {
*offset = consume_escaped(source, *offset) - 1;
} else {
*offset = consume_bad_url_remnants(source, *offset);
*ty = BAD_URL;
return;
}
}
_ => {}
}
*offset += 1;
}
};
// § 4.3.4. Consume an ident-like token
let consume_ident_like_token = |offset: &mut usize, ty: &mut TokenType| {
let name_start_offset = *offset;
*offset = consume_name(source, *offset);
if cmp_str(source, name_start_offset, *offset, "url") && get(*offset) == 0x28 {
*offset = find_white_space_end(source, *offset + 1);
if get(*offset) == 0x22 || get(*offset) == 0x27 {
*ty = FUNCTION;
*offset = name_start_offset + 4;
return;
}
consume_url_token(offset, ty);
return;
}
if get(*offset) == 0x28 {
*ty = FUNCTION;
*offset += 1;
return;
}
*ty = IDENT;
};
// § 4.3.5. Consume a string token
let consume_string_token = |offset: &mut usize, ty: &mut TokenType| {
let ending_code_point = get(*offset);
*offset += 1;
*ty = STRING;
while *offset < source.len() {
let code = source[*offset] as u32;
let cat = char_code_category(code);
if cat == ending_code_point {
*offset += 1;
return;
}
match cat {
WHITESPACE_CATEGORY => {
if is_newline(code) {
*offset += get_newline_length(source, *offset, code);
*ty = BAD_STRING;
return;
}
}
0x5C => {
if *offset == source.len() - 1 {
// If the next input code point is EOF, do nothing.
} else {
let next_code = get(*offset + 1);
if is_newline(next_code) {
*offset += get_newline_length(source, *offset + 1, next_code);
} else if is_valid_escape(code, next_code) {
*offset = consume_escaped(source, *offset) - 1;
}
}
}
_ => {}
}
*offset += 1;
}
};
while offset < source_length {
let code = source[offset] as u32;
match char_code_category(code) {
WHITESPACE_CATEGORY => {
ty = WHITESPACE;
offset = find_white_space_end(source, offset + 1);
}
0x22 => {
ty = STRING;
consume_string_token(&mut offset, &mut ty);
}
0x23 => {
if is_name(get(offset + 1)) || is_valid_escape(get(offset + 1), get(offset + 2)) {
ty = HASH;
offset = consume_name(source, offset + 1);
} else {
ty = DELIM;
offset += 1;
}
}
0x27 => {
ty = STRING;
consume_string_token(&mut offset, &mut ty);
}
0x28 => {
ty = LEFT_PARENTHESIS;
offset += 1;
}
0x29 => {
ty = RIGHT_PARENTHESIS;
offset += 1;
}
0x2B => {
if is_number_start(code, get(offset + 1), get(offset + 2)) != 0 {
ty = NUMBER;
consume_numeric_token(&mut offset, &mut ty);
} else {
ty = DELIM;
offset += 1;
}
}
0x2C => {
ty = COMMA;
offset += 1;
}
0x2D => {
if is_number_start(code, get(offset + 1), get(offset + 2)) != 0 {
ty = NUMBER;
consume_numeric_token(&mut offset, &mut ty);
} else if get(offset + 1) == 0x2D && get(offset + 2) == 0x3E {
ty = CDC;
offset += 3;
} else if is_identifier_start(code, get(offset + 1), get(offset + 2)) {
ty = IDENT;
consume_ident_like_token(&mut offset, &mut ty);
} else {
ty = DELIM;
offset += 1;
}
}
0x2E => {
if is_number_start(code, get(offset + 1), get(offset + 2)) != 0 {
ty = NUMBER;
consume_numeric_token(&mut offset, &mut ty);
} else {
ty = DELIM;
offset += 1;
}
}
0x2F => {
if get(offset + 1) == 0x2A {
ty = COMMENT;
// source.indexOf('*/', offset + 2)
let mut found = None;
let mut i = offset + 2;
while i + 1 < source_length {
if source[i] == '*' && source[i + 1] == '/' {
found = Some(i);
break;
}
i += 1;
}
offset = match found {
Some(i) => i + 2,
None => source_length,
};
} else {
ty = DELIM;
offset += 1;
}
}
0x3A => {
ty = COLON;
offset += 1;
}
0x3B => {
ty = SEMICOLON;
offset += 1;
}
0x3C => {
if get(offset + 1) == 0x21 && get(offset + 2) == 0x2D && get(offset + 3) == 0x2D {
ty = CDO;
offset += 4;
} else {
ty = DELIM;
offset += 1;
}
}
0x40 => {
if is_identifier_start(get(offset + 1), get(offset + 2), get(offset + 3)) {
ty = AT_KEYWORD;
offset = consume_name(source, offset + 1);
} else {
ty = DELIM;
offset += 1;
}
}
0x5B => {
ty = LEFT_SQUARE_BRACKET;
offset += 1;
}
0x5C => {
if is_valid_escape(code, get(offset + 1)) {
ty = IDENT;
consume_ident_like_token(&mut offset, &mut ty);
} else {
ty = DELIM;
offset += 1;
}
}
0x5D => {
ty = RIGHT_SQUARE_BRACKET;
offset += 1;
}
0x7B => {
ty = LEFT_CURLY_BRACKET;
offset += 1;
}
0x7D => {
ty = RIGHT_CURLY_BRACKET;
offset += 1;
}
DIGIT_CATEGORY => {
ty = NUMBER;
consume_numeric_token(&mut offset, &mut ty);
}
NAME_START_CATEGORY => {
ty = IDENT;
consume_ident_like_token(&mut offset, &mut ty);
}
_ => {
ty = DELIM;
offset += 1;
}
}
on_token(ty, start, offset);
start = offset;
}
}
// ─── TokenStream (lib/tokenizer/TokenStream.js) ─────────────────────────────
const BLOCK_OPEN_TOKEN: u8 = 1;
const BLOCK_CLOSE_TOKEN: u8 = 2;
fn balance_pair(ty: TokenType) -> TokenType {
match ty {
FUNCTION | LEFT_PARENTHESIS => RIGHT_PARENTHESIS,
LEFT_SQUARE_BRACKET => RIGHT_SQUARE_BRACKET,
LEFT_CURLY_BRACKET => RIGHT_CURLY_BRACKET,
_ => 0,
}
}
fn block_token(ty: TokenType) -> u8 {
match ty {
FUNCTION | LEFT_PARENTHESIS | LEFT_SQUARE_BRACKET | LEFT_CURLY_BRACKET => BLOCK_OPEN_TOKEN,
RIGHT_PARENTHESIS | RIGHT_SQUARE_BRACKET | RIGHT_CURLY_BRACKET => BLOCK_CLOSE_TOKEN,
_ => 0,
}
}
pub fn is_block_opener_token_type(ty: TokenType) -> bool {
block_token(ty) == BLOCK_OPEN_TOKEN
}
pub fn is_block_closer_token_type(ty: TokenType) -> bool {
block_token(ty) == BLOCK_CLOSE_TOKEN
}
/// The tokenized source with css-tree's cursor and balance table.
pub struct TokenStream {
pub source: Vec<char>,
pub first_char_offset: usize,
pub token_count: usize,
types: Vec<TokenType>,
ends: Vec<usize>,
pub balance: Vec<usize>,
pub eof: bool,
pub token_index: usize,
pub token_type: TokenType,
pub token_start: usize,
pub token_end: usize,
}
impl TokenStream {
pub fn new(source: &str) -> TokenStream {
let source: Vec<char> = source.chars().collect();
let source_length = source.len();
let mut types: Vec<TokenType> = Vec::with_capacity(source_length + 1);
let mut ends: Vec<usize> = Vec::with_capacity(source_length + 1);
// JS: Uint32Array(source.length + 1), zero-filled; indexed by token
// index, and by `balanceStart` which starts at source.length.
let mut balance: Vec<usize> = vec![0; source_length + 1];
let mut token_count = 0usize;
let mut first_char_offset: Option<usize> = None;
let mut balance_close_type: TokenType = 0;
let mut balance_start = source_length;
tokenize(&source, |ty, start, end| {
let index = token_count;
token_count += 1;
types.push(ty);
ends.push(end);
if first_char_offset.is_none() {
first_char_offset = Some(start);
}
balance[index] = balance_start;
if ty == balance_close_type {
let prev_balance_start = balance[balance_start];
balance[balance_start] = index;
balance_start = prev_balance_start;
balance_close_type = if prev_balance_start < types.len() {
balance_pair(types[prev_balance_start])
} else {
// JS reads offsetAndType[prevBalanceStart] >> 24 which is
// 0 for an unwritten slot: no close type.
0
};
} else if is_block_opener_token_type(ty) {
balance_start = index;
balance_close_type = balance_pair(ty);
}
});
types.push(EOF);
ends.push(source_length);
balance[token_count] = token_count;
for i in 0..token_count {
let bs = balance[i];
if bs <= i {
let balance_end = balance[bs];
if balance_end != i {
balance[i] = balance_end;
}
} else if bs > token_count {
balance[i] = token_count;
}
}
let mut ts = TokenStream {
source,
first_char_offset: first_char_offset.unwrap_or(0),
token_count,
types,
ends,
balance,
eof: false,
token_index: 0,
token_type: 0,
token_start: 0,
token_end: 0,
};
ts.reset();
ts.next();
ts
}
fn reset(&mut self) {
self.eof = false;
// JS tokenIndex = -1; we keep it as usize and special-case in next().
self.token_index = usize::MAX;
self.token_type = 0;
self.token_start = self.first_char_offset;
self.token_end = self.first_char_offset;
}
/// JS `offsetAndType[i] & OFFSET_MASK` for i in 0..=tokenCount, i.e. the
/// end offset of token i (source length for the EOF slot).
fn raw_end(&self, index: usize) -> usize {
if index <= self.token_count {
self.ends[index]
} else {
0
}
}
fn raw_type(&self, index: usize) -> TokenType {
if index <= self.token_count {
self.types[index]
} else {
0
}
}
pub fn lookup_type(&self, offset: isize) -> TokenType {
let idx = self.token_index as isize + offset;
if idx >= 0 && (idx as usize) < self.token_count {
self.types[idx as usize]
} else {
EOF
}
}
pub fn lookup_type_non_sc(&self, mut idx: usize) -> TokenType {
let mut offset = self.token_index;
while offset < self.token_count {
let ty = self.types[offset];
if ty != WHITESPACE && ty != COMMENT {
if idx == 0 {
return ty;
}
idx -= 1;
}
offset += 1;
}
EOF
}
pub fn lookup_offset(&self, offset: isize) -> usize {
let idx = self.token_index as isize + offset;
if idx >= 0 && (idx as usize) < self.token_count {
let idx = idx as usize;
if idx == 0 {
// JS: offsetAndType[-1] is undefined -> & mask -> 0
0
} else {
self.raw_end(idx - 1)
}
} else {
self.source.len()
}
}
pub fn lookup_value(&self, offset: isize, reference: &str) -> bool {
let idx = self.token_index as isize + offset;
if idx >= 0 && (idx as usize) < self.token_count {
let idx = idx as usize;
let start = if idx == 0 { 0 } else { self.raw_end(idx - 1) };
cmp_str(&self.source, start, self.raw_end(idx), reference)
} else {
false
}
}
pub fn get_token_start(&self, token_index: usize) -> usize {
if token_index == self.token_index {
return self.token_start;
}
if token_index > 0 {
return if token_index < self.token_count {
self.raw_end(token_index - 1)
} else {
self.raw_end(self.token_count)
};
}
self.first_char_offset
}
pub fn get_token_end(&self, token_index: usize) -> usize {
if token_index == self.token_index {
return self.token_end;
}
self.raw_end(std::cmp::min(token_index, self.token_count))
}
pub fn get_token_type(&self, token_index: usize) -> TokenType {
if token_index == self.token_index {
return self.token_type;
}
self.raw_type(std::cmp::min(token_index, self.token_count))
}
pub fn substr_to_cursor(&self, start: usize) -> String {
self.substring(start, self.token_start)
}
pub fn substring(&self, start: usize, end: usize) -> String {
// JS String.prototype.substring: swaps and clamps.
let len = self.source.len();
let (mut a, mut b) = (start.min(len), end.min(len));
if a > b {
std::mem::swap(&mut a, &mut b);
}
self.source[a..b].iter().collect()
}
pub fn char_code_at(&self, offset: usize) -> u32 {
char_at(&self.source, offset)
}
pub fn is_balance_edge(&self, token_index: usize) -> bool {
self.balance[self.token_index.min(self.token_count)] < token_index
}
pub fn is_delim(&self, code: u32) -> bool {
self.token_type == DELIM && self.char_code_at(self.token_start) == code
}
pub fn is_delim_at(&self, code: u32, offset: isize) -> bool {
if offset != 0 {
return self.lookup_type(offset) == DELIM
&& self.char_code_at(self.lookup_offset(offset)) == code;
}
self.is_delim(code)
}
/// JS `skip(tokenCount)` (may be negative).
pub fn skip(&mut self, count: isize) {
let next = self.token_index as isize + count;
if next >= 0 && (next as usize) < self.token_count {
let next = next as usize;
self.token_index = next;
self.token_start = if next == 0 { 0 } else { self.raw_end(next - 1) };
self.token_type = self.types[next];
self.token_end = self.ends[next];
} else {
self.token_index = self.token_count;
self.next();
}
}
/// Move the cursor to an absolute token index.
pub fn seek(&mut self, index: usize) {
let delta = index as isize - self.token_index as isize;
self.skip(delta);
}
pub fn next(&mut self) {
let next = self.token_index.wrapping_add(1);
if next < self.token_count {
self.token_index = next;
self.token_start = self.token_end;
self.token_type = self.types[next];
self.token_end = self.ends[next];
} else {
self.eof = true;
self.token_index = self.token_count;
self.token_type = EOF;
self.token_start = self.source.len();
self.token_end = self.source.len();
}
}
pub fn skip_sc(&mut self) {
while self.token_type == WHITESPACE || self.token_type == COMMENT {
self.next();
}
}
/// JS `skipUntilBalanced(startToken, stopConsume)`; `stop_consume` gets
/// the char code at each token start and returns 0 (continue), 1 (stop
/// before) or 2 (stop after).
pub fn skip_until_balanced<F: Fn(u32) -> u8>(&mut self, start_token: usize, stop_consume: F) {
let mut cursor = start_token;
while cursor < self.token_count {
let balance_end = self.balance[cursor];
if balance_end < start_token {
break;
}
let offset = if cursor > 0 {
self.raw_end(cursor - 1)
} else {
self.first_char_offset
};
match stop_consume(self.char_code_at(offset)) {
1 => break,
2 => {
cursor += 1;
break;
}
_ => {
if is_block_opener_token_type(self.types[cursor]) {
cursor = balance_end;
}
}
}
cursor += 1;
}
let delta = cursor as isize - self.token_index as isize;
self.skip(delta);
}
}
+213
View File
@@ -0,0 +1,213 @@
//! The static cascade's tables: which properties inherit, the default
//! computed style, the CSS-name to camelCase map, and the extra named colors.
//!
//! JS: css-cascade.mjs#STATIC_INHERITED_PROPS, #STATIC_DEFAULT_STYLE,
//! #STATIC_PROP_MAP, #STATIC_NAMED_COLORS, #NAMED_COLORS, #BORDER_SHORTHAND_RE
use impeccable_core::color::Rgba;
use once_cell::sync::Lazy;
use regex::Regex;
/// JS: css-cascade.mjs#BORDER_SHORTHAND_RE
/// `/^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i`
pub static BORDER_SHORTHAND_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?is)^([0-9]+(?:\.[0-9]+)?)px{ws}+(solid|dashed|dotted|double|groove|ridge|inset|outset){ws}+(.+)$",
ws = impeccable_core::js::WS
))
.expect("BORDER_SHORTHAND_RE")
});
/// JS: css-cascade.mjs#NAMED_COLORS (the small table `normalizeColorForCheck`
/// resolves; not the shared CSS_NAMED_COLORS).
pub const NAMED_COLORS: &[(&str, [u32; 3])] = &[
("white", [255, 255, 255]),
("black", [0, 0, 0]),
("gray", [128, 128, 128]),
("grey", [128, 128, 128]),
("silver", [192, 192, 192]),
("red", [255, 0, 0]),
("green", [0, 128, 0]),
("blue", [0, 0, 255]),
("yellow", [255, 255, 0]),
];
/// JS: css-cascade.mjs#STATIC_INHERITED_PROPS
pub const STATIC_INHERITED_PROPS: &[&str] = &[
"color",
"fontFamily",
"fontSize",
"fontStyle",
"fontWeight",
"fontVariant",
"lineHeight",
"letterSpacing",
"textTransform",
"textAlign",
"hyphens",
"webkitHyphens",
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
// relies on descendants of a hidden container computing as hidden. A child
// that declares `visibility: visible` still overrides the inherited value.
"visibility",
];
/// JS `STATIC_INHERITED_PROPS.has(prop)`.
pub fn is_static_inherited_prop(prop: &str) -> bool {
STATIC_INHERITED_PROPS.contains(&prop)
}
/// JS: css-cascade.mjs#STATIC_DEFAULT_STYLE, in the JS object's key order
/// (the computed-style model iterates this order).
pub const STATIC_DEFAULT_STYLE: &[(&str, &str)] = &[
("color", "rgb(0, 0, 0)"),
("backgroundColor", "rgba(0, 0, 0, 0)"),
("backgroundImage", "none"),
("borderTopWidth", "0px"),
("borderRightWidth", "0px"),
("borderBottomWidth", "0px"),
("borderLeftWidth", "0px"),
("borderTopColor", "rgb(0, 0, 0)"),
("borderRightColor", "rgb(0, 0, 0)"),
("borderBottomColor", "rgb(0, 0, 0)"),
("borderLeftColor", "rgb(0, 0, 0)"),
("borderRadius", "0px"),
("outlineWidth", "0px"),
("outlineColor", "rgb(0, 0, 0)"),
("outlineStyle", "none"),
("boxShadow", "none"),
// NOT in STATIC_INHERITED_PROPS even though text-shadow inherits in real
// CSS: the glow check only needs to fire once, on the element that
// declares the shadow, not on every descendant.
("textShadow", "none"),
("fontFamily", ""),
("fontSize", "16px"),
("fontStyle", "normal"),
("fontVariant", "normal"),
("fontWeight", "400"),
("lineHeight", "normal"),
("letterSpacing", "normal"),
("textTransform", "none"),
("textAlign", "start"),
("hyphens", "manual"),
("webkitHyphens", "manual"),
("transitionProperty", ""),
("transitionTimingFunction", ""),
("animationName", ""),
("animationTimingFunction", ""),
("webkitBackgroundClip", ""),
("backgroundClip", ""),
("width", ""),
("height", ""),
("paddingTop", "0px"),
("paddingRight", "0px"),
("paddingBottom", "0px"),
("paddingLeft", "0px"),
("marginTop", "0px"),
("marginRight", "0px"),
("marginBottom", "0px"),
("marginLeft", "0px"),
("position", "static"),
("visibility", "visible"),
("contentVisibility", "visible"),
("opacity", "1"),
("top", "auto"),
("right", "auto"),
("bottom", "auto"),
("left", "auto"),
("inset", ""),
("display", ""),
("overflow", "visible"),
("overflowX", "visible"),
("overflowY", "visible"),
];
/// JS `STATIC_DEFAULT_STYLE[prop]`; `None` when the key is absent (the JS
/// `!= null` test). Note an empty-string default is `Some("")`.
pub fn static_default_style(prop: &str) -> Option<&'static str> {
STATIC_DEFAULT_STYLE
.iter()
.find(|(k, _)| *k == prop)
.map(|(_, v)| *v)
}
/// JS: css-cascade.mjs#STATIC_PROP_MAP
pub const STATIC_PROP_MAP: &[(&str, &str)] = &[
("background-color", "backgroundColor"),
("background-image", "backgroundImage"),
("background-clip", "backgroundClip"),
("-webkit-background-clip", "webkitBackgroundClip"),
("border-radius", "borderRadius"),
("border-top-width", "borderTopWidth"),
("border-right-width", "borderRightWidth"),
("border-bottom-width", "borderBottomWidth"),
("border-left-width", "borderLeftWidth"),
("border-top-color", "borderTopColor"),
("border-right-color", "borderRightColor"),
("border-bottom-color", "borderBottomColor"),
("border-left-color", "borderLeftColor"),
("outline-width", "outlineWidth"),
("outline-color", "outlineColor"),
("outline-style", "outlineStyle"),
("box-shadow", "boxShadow"),
("text-shadow", "textShadow"),
("font-family", "fontFamily"),
("font-size", "fontSize"),
("font-style", "fontStyle"),
("font-weight", "fontWeight"),
("line-height", "lineHeight"),
("letter-spacing", "letterSpacing"),
("text-transform", "textTransform"),
("text-align", "textAlign"),
("hyphens", "hyphens"),
("-webkit-hyphens", "webkitHyphens"),
("transition-property", "transitionProperty"),
("transition-timing-function", "transitionTimingFunction"),
("animation-name", "animationName"),
("animation-timing-function", "animationTimingFunction"),
("width", "width"),
("height", "height"),
("padding-top", "paddingTop"),
("padding-right", "paddingRight"),
("padding-bottom", "paddingBottom"),
("padding-left", "paddingLeft"),
("margin-top", "marginTop"),
("margin-right", "marginRight"),
("margin-bottom", "marginBottom"),
("margin-left", "marginLeft"),
("position", "position"),
("visibility", "visibility"),
("opacity", "opacity"),
("top", "top"),
("right", "right"),
("bottom", "bottom"),
("left", "left"),
("inset", "inset"),
("display", "display"),
("overflow", "overflow"),
("overflow-x", "overflowX"),
("overflow-y", "overflowY"),
];
/// JS `STATIC_PROP_MAP[prop]`.
pub fn static_prop_map(prop: &str) -> Option<&'static str> {
STATIC_PROP_MAP
.iter()
.find(|(k, _)| *k == prop)
.map(|(_, v)| *v)
}
/// JS: css-cascade.mjs#STATIC_NAMED_COLORS. parseStaticColor tries
/// parseAnyColor first, which already resolves every name in the shared
/// CSS_NAMED_COLORS table. This fallback only carries the keywords
/// parseAnyColor deliberately returns null for: the cascade needs
/// `transparent` to read as an actual zero-alpha color.
pub const STATIC_NAMED_COLORS: &[(&str, Rgba)] = &[("transparent", Rgba::new(0.0, 0.0, 0.0, 0.0))];
/// JS `STATIC_NAMED_COLORS[name]`.
pub fn static_named_color(name: &str) -> Option<Rgba> {
STATIC_NAMED_COLORS
.iter()
.find(|(k, _)| *k == name)
.map(|(_, c)| *c)
}
+27
View File
@@ -0,0 +1,27 @@
//! Port of `cli/engine/engines/static-html/css-cascade.mjs`, the plain-data
//! half: value/color/token helpers, shorthand expansion, cascade priority,
//! specificity, the specified-declaration store, and the stylesheet rule
//! collector (a port of the css-tree parse + generate subset the JS relies
//! on lives in [`csstree`]).
//!
//! The DOM half of that file lives in [`build`] (`buildStaticStyleMap`,
//! `collectStaticCssText`) and `crate::dom` (`StaticElement`,
//! `StaticDocument`; `buildStaticWindow` is the document's accessor
//! methods). `buildBorderOverrideMap` is jsdom-only and not ported.
pub mod build;
pub mod checks_shim;
pub mod csstree;
pub mod defaults;
pub mod rules;
pub mod shorthand;
pub mod values;
#[cfg(feature = "vectors")]
pub mod vectors;
pub use build::*;
pub use defaults::*;
pub use rules::*;
pub use shorthand::*;
pub use values::*;
+408
View File
@@ -0,0 +1,408 @@
//! Cascade priority, specificity, the specified-declaration store, inline
//! `style=""` parsing, and the stylesheet rule collector.
//!
//! JS: css-cascade.mjs#compareStaticPriority, #staticSpecificity,
//! #applyStaticDeclaration, #parseStaticStyleAttribute,
//! #collectStaticCssRules
//!
//! ## css-tree behaviors `collect_static_css_rules` reproduces
//!
//! The JS hands the stylesheet to css-tree 3.2.1 and reads back
//! `csstree.generate(rule.prelude)` and `csstree.generate(decl.value)`. The
//! port in [`super::csstree`] re-implements the exact subset so the rule list
//! is byte-equal. Behaviors that had to be reproduced:
//!
//! - **Tolerant parsing with Raw fallback.** A rule prelude that fails the
//! selector grammar becomes `Raw` text up to the `{` (so `} .b{...}` after
//! a stray brace yields the selector `} .b`); a declaration whose value
//! fails becomes a Raw *value* (`Raw` value text is emitted verbatim,
//! e.g. `url(x y.png)`, `@x`, `1..2`, `progid:...`); a declaration that
//! fails entirely (`b:c !important d`, `color:red !important !important`,
//! `--y` without colon, nested `.c{d:e}`) becomes a Raw *block child*, which
//! the JS skips (`child.type !== 'Declaration'`). Recovery consumes tokens
//! with css-tree's balance table (`skipUntilBalanced`), so a stray `;`
//! inside parens or a `{...}` block does not end the raw span.
//! - **Selector normalization.** Preludes are regenerated: whitespace around
//! combinators and commas is dropped (`a > b , c` -> `a>b,c`), a
//! descendant combinator becomes a single space, `:not( .x , .y )` ->
//! `:not(.x,.y)`, attribute selectors lose inner spaces and re-quote their
//! value with double quotes (`[ data-x = 'y' i ]` -> `[data-x="y"i]`), an
//! `An+B` argument is canonicalized (`2n + 1` -> `2n+1`). Comments inside
//! selectors vanish. Case is preserved.
//! - **Value normalization** (`parseValue: true`). Values are re-tokenized
//! and re-emitted with a space only where two tokens would otherwise merge
//! (css-tree's "safe" `token-before` table): `rgb( 1 , 2 , 3 )` ->
//! `rgb(1,2,3)`, `a , b` -> `a,b`, `x y` -> `x y`, `1 / 2` -> `1/2`,
//! `-1px +2px` -> `-1px+2px`, `1 -2` -> `1-2` but `1 - 2` stays (an
//! operator keeps the spaces around it), `url(foo.png) no-repeat` ->
//! `url(foo.png)no-repeat`, `1.2.3` -> `1.2 .3`. Strings are decoded and
//! re-encoded with double quotes and CSSOM escaping (`'}'` -> `"}"`,
//! `"\201C"` -> `"\u{201C}"`); `url("x y.png")` -> `url(x\ y.png)`,
//! `URL(x.png)` -> `url(x.png)`; `var( --x , red )` -> `var(--x, red )`
//! (the fallback is a raw span). Comments inside values vanish.
//! - **Custom properties** (`parseCustomProperty: false`) keep their raw text
//! including surrounding whitespace, which the JS then trims.
//! - **`!important`** parses as `true`; another ident (`!ie`) is kept as a
//! string; the JS coerces both with `!!`. `! important` with a space is
//! accepted.
//! - **At-rules.** Only `@media`, `@supports`, `@layer` (named or anonymous,
//! nested to any depth, in any order) are descended; `@keyframes`,
//! `@font-face`, `@page`, `@container`, `@import` and unknown at-rules are
//! skipped along with everything inside them. Nested style rules inside a
//! style rule's block (`a { &:hover {...} }`) are not collected: the JS
//! only walks top-level and at-rule-nested `Rule` nodes and only reads
//! `Declaration` children.
//! - **Empty / whitespace-only stylesheets, CDO/CDC (`<!--` `-->`), and
//! `/*! */` comments** contribute nothing.
//! - Selectors are split on top-level commas of the *generated* prelude
//! with `splitCssList` (attribute values with commas stay intact) and each
//! gets its own rule with the same `order`; empty selectors are dropped.
//! - Two css-tree details are intentionally simplified because they cannot
//! change the output here: at-rule preludes are always consumed as Raw
//! (see `parser.rs`), and the tokenizer's balance table is computed on a
//! fresh buffer (css-tree reuses a typed array across parses, which can
//! only differ for a stray top-level `)`/`]` after an earlier, longer
//! parse in the same process).
use super::csstree::{self, Important, Node};
use super::shorthand::expand_static_declaration;
use impeccable_core::js;
use indexmap::IndexMap;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;
use std::hash::Hash;
/// The cascade metadata carried by a specified declaration
/// (`{ important, specificity, order, inline }`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclMeta {
pub important: bool,
pub specificity: [u32; 3],
pub order: i64,
pub inline: bool,
}
/// A winning declaration in the specified store: `{ ...meta, prop, value }`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpecifiedDecl {
pub meta: DeclMeta,
pub prop: String,
pub value: String,
}
/// JS: css-cascade.mjs#compareStaticPriority(a, b)
/// True when `b` should replace the existing `a` (or there is no `a`).
pub fn compare_static_priority(a: Option<&DeclMeta>, b: &DeclMeta) -> bool {
let Some(a) = a else {
return true;
};
if b.important != a.important {
return b.important;
}
if b.inline != a.inline {
return b.inline;
}
for i in 0..3 {
if b.specificity[i] != a.specificity[i] {
return b.specificity[i] > a.specificity[i];
}
}
b.order >= a.order
}
static WHERE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r":where\([^)]*\)").expect("WHERE_RE"));
static ID_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"#[0-9A-Za-z_-]+").expect("ID_RE"));
// JS `:(?!:)[\w-]+`: the lookahead is implied because `[\w-]` excludes `:`.
static CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\.[0-9A-Za-z_-]+|\[[^\]]+\]|:[0-9A-Za-z_-]+(?:\([^)]*\))?").expect("CLASS_RE")
});
static CLASS_STRIP_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\.[0-9A-Za-z_-]+|\[[^\]]+\]|:{1,2}[0-9A-Za-z_-]+(?:\([^)]*\))?")
.expect("CLASS_STRIP_RE")
});
static PUNCT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[*>+~(),]").expect("PUNCT_RE"));
static TYPE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?-u:\b)[a-zA-Z][0-9A-Za-z_-]*(?-u:\b)").expect("TYPE_RE"));
/// JS: css-cascade.mjs#staticSpecificity(selector) -> [ids, classes, types]
pub fn static_specificity(selector: &str) -> [u32; 3] {
let no_where = WHERE_RE.replace_all(selector, "");
let ids = ID_RE.find_iter(&no_where).count() as u32;
let classes = CLASS_RE.find_iter(&no_where).count() as u32;
let stripped = ID_RE.replace_all(&no_where, " ");
let stripped = CLASS_STRIP_RE.replace_all(&stripped, " ");
let stripped = PUNCT_RE.replace_all(&stripped, " ");
let types = TYPE_RE.find_iter(&stripped).count() as u32;
[ids, classes, types]
}
/// The JS `specified` map: node -> (expanded prop -> winning declaration).
/// Generic over the node key so the DOM engine can key it by node id.
#[derive(Debug, Clone)]
pub struct SpecifiedStore<K: Hash + Eq> {
map: HashMap<K, IndexMap<String, SpecifiedDecl>>,
}
impl<K: Hash + Eq> Default for SpecifiedStore<K> {
fn default() -> Self {
SpecifiedStore {
map: HashMap::new(),
}
}
}
impl<K: Hash + Eq> SpecifiedStore<K> {
pub fn new() -> Self {
Self::default()
}
/// JS `specified.get(node)`: the per-property winners in insertion order.
pub fn get(&self, node: &K) -> Option<&IndexMap<String, SpecifiedDecl>> {
self.map.get(node)
}
pub fn get_mut(&mut self, node: &K) -> Option<&mut IndexMap<String, SpecifiedDecl>> {
self.map.get_mut(node)
}
pub fn contains(&self, node: &K) -> bool {
self.map.contains_key(node)
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&K, &IndexMap<String, SpecifiedDecl>)> {
self.map.iter()
}
}
/// JS: css-cascade.mjs#applyStaticDeclaration(specified, node, prop, value, meta)
pub fn apply_static_declaration<K: Hash + Eq>(
specified: &mut SpecifiedStore<K>,
node: K,
prop: &str,
value: &str,
meta: &DeclMeta,
) {
let map = specified.map.entry(node).or_default();
for (expanded_prop, expanded_value) in expand_static_declaration(prop, value) {
let existing = map.get(&expanded_prop).map(|d| &d.meta);
if compare_static_priority(existing, meta) {
let next = SpecifiedDecl {
meta: meta.clone(),
prop: expanded_prop.clone(),
value: expanded_value,
};
map.insert(expanded_prop, next);
}
}
}
/// One declaration from a `style=""` attribute.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StyleAttrDecl {
pub prop: String,
pub value: String,
pub important: bool,
pub order: i64,
}
static IMPORTANT_TAIL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(r"(?i)!important{ws}*$", ws = js::WS)).expect("IMPORTANT_TAIL_RE")
});
static IMPORTANT_STRIP_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(r"(?i){ws}*!important{ws}*$", ws = js::WS)).expect("IMPORTANT_STRIP_RE")
});
/// JS: css-cascade.mjs#parseStaticStyleAttribute(styleText, orderBase = 0)
pub fn parse_static_style_attribute(style_text: &str, order_base: i64) -> Vec<StyleAttrDecl> {
let mut decls: Vec<StyleAttrDecl> = Vec::new();
for part in style_text.split(';') {
let Some(idx) = part.find(':') else {
continue;
};
if idx == 0 {
continue;
}
let prop = js::trim(&part[..idx]).to_string();
let mut value = js::trim(&part[idx + 1..]).to_string();
let important = IMPORTANT_TAIL_RE.is_match(&value);
value = js::trim(&IMPORTANT_STRIP_RE.replace(&value, "")).to_string();
let order = order_base + decls.len() as i64;
decls.push(StyleAttrDecl {
prop,
value,
important,
order,
});
}
decls
}
/// A declaration inside a collected rule.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuleDecl {
pub prop: String,
pub value: String,
pub important: bool,
}
/// One entry of `collectStaticCssRules`' output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CssRule {
pub selector: String,
pub declarations: Vec<RuleDecl>,
pub specificity: [u32; 3],
pub order: i64,
pub is_hover: bool,
/// The state-stripped selector for :hover rules (`None` when the rule
/// is not a hover rule or the stripped selector is unusable).
pub match_selector: Option<String>,
}
static HOVER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i):hover(?-u:\b)").expect("HOVER_RE"));
static TRAILING_COMBINATOR_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(r"[>+~]{ws}*$", ws = js::WS)).expect("TRAILING_COMBINATOR_RE")
});
fn is_combinator_or_ws(c: char) -> bool {
js::is_js_whitespace(c) || c == '>' || c == '+' || c == '~'
}
/// JS `matchSelector.replace(/(^|[\s>+~])(?=$|[\s>+~])/g, '$1*')`, done by
/// hand because the regex crate has no lookahead. Walks the string the way
/// a global replace does: at each position try `^` (only at 0), then one
/// combinator/whitespace char; the lookahead requires end-of-string or a
/// combinator/whitespace char right after group 1. An empty match inserts
/// `*` and advances one char; a one-char match appends `*` after it.
fn star_empty_compounds(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let mut out = String::new();
let mut pos = 0usize;
let look = |at: usize| -> bool { at >= n || is_combinator_or_ws(chars[at]) };
while pos <= n {
if pos == 0 && look(0) {
// `^` alternative: empty match, replacement is `*`.
out.push('*');
if n == 0 {
break;
}
out.push(chars[0]);
pos = 1;
continue;
}
if pos < n && is_combinator_or_ws(chars[pos]) && look(pos + 1) {
out.push(chars[pos]);
out.push('*');
pos += 1;
continue;
}
if pos < n {
out.push(chars[pos]);
}
pos += 1;
}
out
}
/// JS: css-cascade.mjs#collectStaticCssRules(cssText, csstree)
pub fn collect_static_css_rules(css_text: &str) -> Vec<CssRule> {
let mut rules: Vec<CssRule> = Vec::new();
let ast = match csstree::parse_stylesheet(css_text) {
Ok(ast) => ast,
Err(_) => return rules,
};
let mut order: i64 = 0;
let Node::StyleSheet { children } = &ast else {
return rules;
};
walk_list(children, &[], &mut rules, &mut order);
rules
}
fn walk_list(list: &[Node], at_rule_stack: &[String], rules: &mut Vec<CssRule>, order: &mut i64) {
for node in list {
match node {
Node::Rule { prelude, block } => {
if at_rule_stack
.iter()
.any(|name| js::to_lower_case(name).ends_with("keyframes"))
{
continue;
}
let selector_text = csstree::generate(prelude);
let selector_text = js::trim(&selector_text);
let mut declarations: Vec<RuleDecl> = Vec::new();
if let Node::Block { children } = &**block {
for child in children {
if let Node::Declaration {
important,
property,
value,
} = child
{
let generated = csstree::generate(value);
declarations.push(RuleDecl {
prop: property.clone(),
value: js::trim(&generated).to_string(),
important: matches!(
important,
Important::Yes | Important::Other(_)
),
});
}
}
}
for selector in super::values::split_css_list(selector_text) {
if selector.is_empty() {
continue;
}
// :hover rules can't be matched statically as-is (no
// interaction state), but they carry real cascade weight
// while hovered. Tag them and record a state-stripped
// selector so the hover pass can find their targets;
// specificity stays computed from the ORIGINAL selector
// (per CSS, :hover counts as a class).
let is_hover = HOVER_RE.is_match(&selector);
let mut match_selector: Option<String> = None;
if is_hover {
let stripped = HOVER_RE.replace_all(&selector, "");
let stripped = js::trim(&stripped).to_string();
if stripped.is_empty() || TRAILING_COMBINATOR_RE.is_match(&stripped) {
match_selector = None;
} else {
match_selector = Some(star_empty_compounds(&stripped));
}
}
let specificity = static_specificity(&selector);
rules.push(CssRule {
selector,
declarations: declarations.clone(),
specificity,
order: *order,
is_hover,
match_selector,
});
*order += 1;
}
}
Node::Atrule {
name,
block: Some(block),
..
} => {
let lower = js::to_lower_case(name);
if lower == "media" || lower == "supports" || lower == "layer" {
if let Node::Block { children } = &**block {
let mut stack: Vec<String> = at_rule_stack.to_vec();
stack.push(lower);
walk_list(children, &stack, rules, order);
}
}
}
_ => {}
}
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Shorthand expansion of the static cascade.
//!
//! JS: css-cascade.mjs#expandStaticBoxValues, #parseStaticBorder,
//! #parseStaticFont, #parseStaticTransition, #parseStaticAnimation,
//! #expandStaticDeclaration
use super::defaults::{is_static_inherited_prop, static_default_style};
use super::values::{css_prop_to_camel, extract_static_color, split_css_list, split_css_tokens};
use impeccable_core::js;
use once_cell::sync::Lazy;
use regex::Regex;
/// A `[prop, value]` pair as emitted by `expandStaticDeclaration`.
pub type Expanded = (String, String);
/// JS: css-cascade.mjs#expandStaticBoxValues(tokens)
pub fn expand_static_box_values(tokens: &[String]) -> [String; 4] {
match tokens.len() {
0 => ["0px".into(), "0px".into(), "0px".into(), "0px".into()],
1 => [
tokens[0].clone(),
tokens[0].clone(),
tokens[0].clone(),
tokens[0].clone(),
],
2 => [
tokens[0].clone(),
tokens[1].clone(),
tokens[0].clone(),
tokens[1].clone(),
],
3 => [
tokens[0].clone(),
tokens[1].clone(),
tokens[2].clone(),
tokens[1].clone(),
],
_ => [
tokens[0].clone(),
tokens[1].clone(),
tokens[2].clone(),
tokens[3].clone(),
],
}
}
/// `{ width, color }` from `parseStaticBorder`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StaticBorder {
pub width: String,
pub color: String,
}
static BORDER_WIDTH_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^-?[0-9.]+(?:px|rem|em|%)$").expect("BORDER_WIDTH_RE"));
/// JS: css-cascade.mjs#parseStaticBorder(value)
pub fn parse_static_border(value: &str) -> StaticBorder {
let mut out = StaticBorder::default();
for token in split_css_tokens(value) {
if out.width.is_empty() && BORDER_WIDTH_RE.is_match(&token) {
out.width = token.clone();
}
if out.color.is_empty() {
out.color = extract_static_color(&token);
}
}
out
}
static FONT_SIZE_SLASH_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?:^|{ws})([0-9.]+(?:px|rem|em|%))(?:/([^{wsc}]+))?",
ws = js::WS,
wsc = js::WS_CHARS
))
.expect("FONT_SIZE_SLASH_RE")
});
static ITALIC_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)(?-u:\b)italic(?-u:\b)").expect("ITALIC_RE"));
static FONT_WEIGHT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?-u:\b)([1-9]00|bold|normal|lighter|bolder)(?-u:\b)").expect("FONT_WEIGHT_RE")
});
/// JS: css-cascade.mjs#parseStaticFont(value)
pub fn parse_static_font(value: &str) -> Vec<Expanded> {
let mut out: Vec<Expanded> = Vec::new();
let slash_parts = FONT_SIZE_SLASH_RE.captures(value);
if ITALIC_RE.is_match(value) {
out.push(("fontStyle".into(), "italic".into()));
}
if let Some(w) = FONT_WEIGHT_RE.captures(value) {
out.push(("fontWeight".into(), w[1].to_string()));
}
if let Some(m) = slash_parts {
out.push(("fontSize".into(), m[1].to_string()));
if let Some(lh) = m.get(2) {
if !lh.as_str().is_empty() {
out.push(("lineHeight".into(), lh.as_str().to_string()));
}
}
let whole = m.get(0).unwrap().as_str();
// JS: value.indexOf(slashParts[0]) + slashParts[0].length
let family_start = match value.find(whole) {
Some(idx) => idx + whole.len(),
// indexOf returned -1 in JS: -1 + length; unreachable since the
// match text is a substring of value.
None => whole.len().saturating_sub(1),
};
let family = js::trim(&value[family_start.min(value.len())..]);
if !family.is_empty() {
out.push(("fontFamily".into(), family.to_string()));
}
}
out
}
/// `{ property, timing }` from `parseStaticTransition`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StaticTransition {
pub property: String,
pub timing: String,
}
/// `{ name, timing }` from `parseStaticAnimation`.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct StaticAnimation {
pub name: String,
pub timing: String,
}
static TIMING_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^(?:ease|linear|step-|cubic-bezier\()").expect("TIMING_RE"));
static TRANSITION_PROP_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^[a-z-]+$").expect("TRANSITION_PROP_RE"));
static TRANSITION_KEYWORD_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$")
.expect("TRANSITION_KEYWORD_RE")
});
static ENDS_WITH_S_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"s$").expect("ENDS_WITH_S_RE"));
/// JS: css-cascade.mjs#parseStaticTransition(value)
pub fn parse_static_transition(value: &str) -> StaticTransition {
let mut props: Vec<String> = Vec::new();
let mut timings: Vec<String> = Vec::new();
for item in split_css_list(value) {
let tokens = split_css_tokens(&item);
if let Some(timing) = tokens.iter().find(|t| TIMING_RE.is_match(t)) {
timings.push(timing.clone());
}
if let Some(prop) = tokens.iter().find(|t| {
TRANSITION_PROP_RE.is_match(t)
&& !TRANSITION_KEYWORD_RE.is_match(t)
&& !ENDS_WITH_S_RE.is_match(t)
}) {
props.push(prop.clone());
}
}
StaticTransition {
property: props.join(", "),
timing: timings.join(", "),
}
}
static ANIMATION_NAME_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^[a-z_-][0-9A-Za-z_-]*$").expect("ANIMATION_NAME_RE"));
static ANIMATION_KEYWORD_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$",
)
.expect("ANIMATION_KEYWORD_RE")
});
/// JS: css-cascade.mjs#parseStaticAnimation(value)
pub fn parse_static_animation(value: &str) -> StaticAnimation {
let mut names: Vec<String> = Vec::new();
let mut timings: Vec<String> = Vec::new();
for item in split_css_list(value) {
let tokens = split_css_tokens(&item);
if let Some(timing) = tokens.iter().find(|t| TIMING_RE.is_match(t)) {
timings.push(timing.clone());
}
if let Some(name) = tokens
.iter()
.find(|t| ANIMATION_NAME_RE.is_match(t) && !ANIMATION_KEYWORD_RE.is_match(t))
{
names.push(name.clone());
}
}
StaticAnimation {
name: names.join(", "),
timing: timings.join(", "),
}
}
static BG_IMAGE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)gradient|url\(").expect("BG_IMAGE_RE"));
static BG_IMAGE_SPLIT_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(")
.expect("BG_IMAGE_SPLIT_RE")
});
static VAR_ANYWHERE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)var\(").expect("VAR_ANYWHERE_RE"));
static OUTLINE_STYLE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$")
.expect("OUTLINE_STYLE_RE")
});
static ZERO_LENGTH_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^0(?:px|rem|em|%)?$").expect("ZERO_LENGTH_RE"));
static BORDER_SIDE_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^border-(top|right|bottom|left)$").expect("BORDER_SIDE_RE"));
fn box4(names: [&str; 4], vals: [String; 4]) -> Vec<Expanded> {
let [a, b, c, d] = vals;
vec![
(names[0].to_string(), a),
(names[1].to_string(), b),
(names[2].to_string(), c),
(names[3].to_string(), d),
]
}
/// JS: css-cascade.mjs#expandStaticDeclaration(prop, value)
pub fn expand_static_declaration(prop: &str, value: &str) -> Vec<Expanded> {
let p = js::to_lower_case(prop);
let v = js::trim(value);
if v.is_empty() {
return Vec::new();
}
if p.starts_with("--") {
return vec![(p, v.to_string())];
}
if p == "background" {
let mut out: Vec<Expanded> = Vec::new();
let has_image = BG_IMAGE_RE.is_match(v);
if has_image {
out.push(("backgroundImage".into(), v.to_string()));
}
let before_image: &str = if has_image {
match BG_IMAGE_SPLIT_RE.find(v) {
Some(m) => &v[..m.start()],
None => v,
}
} else {
v
};
let color = extract_static_color(if has_image { before_image } else { v });
if !color.is_empty() {
out.push(("backgroundColor".into(), color.clone()));
}
// The `background` shorthand resets every longhand it does not set.
// Without this, `pre code { background: none }` leaves an earlier
// `background: var(--surface)` color standing and the contrast checks
// measure text against a surface the browser never paints. var() values
// stay untouched: they may resolve to a color later in the pipeline.
if color.is_empty() && !has_image && !VAR_ANYWHERE_RE.is_match(v) {
out.push(("backgroundColor".into(), "rgba(0, 0, 0, 0)".into()));
out.push(("backgroundImage".into(), "none".into()));
}
return out;
}
if p == "border" {
let parsed = parse_static_border(v);
let mut out: Vec<Expanded> = Vec::new();
for side in ["Top", "Right", "Bottom", "Left"] {
if !parsed.width.is_empty() {
out.push((format!("border{}Width", side), parsed.width.clone()));
}
if !parsed.color.is_empty() {
out.push((format!("border{}Color", side), parsed.color.clone()));
}
}
return out;
}
if p == "outline" {
// `outline` shorthand: width | style | color, in any order. Reuse the
// border parser for width + color, then sniff a style keyword from the
// tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros
// the width and effectively hides the outline.
let tokens = split_css_tokens(v);
let parsed = parse_static_border(v);
let style_token = tokens.iter().find(|t| OUTLINE_STYLE_RE.is_match(t));
let mut out: Vec<Expanded> = Vec::new();
if !parsed.width.is_empty() {
out.push(("outlineWidth".into(), parsed.width.clone()));
}
if !parsed.color.is_empty() {
out.push(("outlineColor".into(), parsed.color.clone()));
}
if let Some(st) = style_token {
out.push(("outlineStyle".into(), js::to_lower_case(st)));
}
// `outline: 0` with no other tokens: explicit zero width.
if parsed.width.is_empty() && ZERO_LENGTH_RE.is_match(js::trim(v)) {
out.push(("outlineWidth".into(), "0px".into()));
}
return out;
}
if let Some(m) = BORDER_SIDE_RE.captures(&p) {
let parsed = parse_static_border(v);
let raw_side = &m[1];
let mut side = String::new();
let mut chars = raw_side.chars();
if let Some(first) = chars.next() {
side.push_str(&first.to_uppercase().to_string());
side.push_str(chars.as_str());
}
let mut out: Vec<Expanded> = Vec::new();
if !parsed.width.is_empty() {
out.push((format!("border{}Width", side), parsed.width.clone()));
}
if !parsed.color.is_empty() {
out.push((format!("border{}Color", side), parsed.color.clone()));
}
return out;
}
if p == "border-width" {
let vals = expand_static_box_values(&split_css_tokens(v));
return box4(
[
"borderTopWidth",
"borderRightWidth",
"borderBottomWidth",
"borderLeftWidth",
],
vals,
);
}
if p == "border-color" {
let vals = expand_static_box_values(&split_css_tokens(v));
return box4(
[
"borderTopColor",
"borderRightColor",
"borderBottomColor",
"borderLeftColor",
],
vals,
);
}
if p == "padding" {
let vals = expand_static_box_values(&split_css_tokens(v));
return box4(
["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"],
vals,
);
}
if p == "margin" {
let vals = expand_static_box_values(&split_css_tokens(v));
return box4(
["marginTop", "marginRight", "marginBottom", "marginLeft"],
vals,
);
}
if p == "font" {
return parse_static_font(v);
}
if p == "transition" {
let parsed = parse_static_transition(v);
let mut out: Vec<Expanded> = Vec::new();
if !parsed.property.is_empty() {
out.push(("transitionProperty".into(), parsed.property));
}
if !parsed.timing.is_empty() {
out.push(("transitionTimingFunction".into(), parsed.timing));
}
return out;
}
if p == "animation" {
let parsed = parse_static_animation(v);
let mut out: Vec<Expanded> = Vec::new();
if !parsed.name.is_empty() {
out.push(("animationName".into(), parsed.name));
}
if !parsed.timing.is_empty() {
out.push(("animationTimingFunction".into(), parsed.timing));
}
return out;
}
let mapped = css_prop_to_camel(&p);
if static_default_style(&mapped).is_some() || is_static_inherited_prop(&mapped) {
return vec![(mapped, v.to_string())];
}
Vec::new()
}
+396
View File
@@ -0,0 +1,396 @@
//! Color / length / token helpers of the static cascade.
//!
//! JS: css-cascade.mjs#splitCssList, #splitCssTokens, #cssPropToCamel,
//! #staticColorToCss, #parseStaticColor, #extractStaticColor,
//! #normalizeStaticCssValue, #normalizeColorForCheck, #unwrapCssAtLayer
use super::checks_shim::{resolve_length_px, resolve_var_refs, CustomProps};
use super::defaults::{
static_default_style, static_named_color, static_prop_map, NAMED_COLORS, STATIC_NAMED_COLORS,
};
use impeccable_core::color::{parse_any_color, Rgba, CSS_NAMED_COLORS};
use impeccable_core::js;
use once_cell::sync::Lazy;
use regex::Regex;
/// A computed / partially-computed style: an ordered map of camelCase
/// property name to value string. `parentStyle` and `values` in the JS are
/// plain objects read with `style[prop]`; a missing key reads as undefined
/// (`None` here), an empty string is present but falsy.
pub type StyleValues = indexmap::IndexMap<String, String>;
/// JS `{ ...STATIC_DEFAULT_STYLE }`: a fresh style map holding every default,
/// in table order (the base `computeNode` fills before applying winners).
pub fn make_default_style() -> StyleValues {
super::defaults::STATIC_DEFAULT_STYLE
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
/// JS `style?.[prop]` on an optional style.
pub fn style_get<'a>(style: Option<&'a StyleValues>, prop: &str) -> Option<&'a str> {
style.and_then(|s| s.get(prop).map(|v| v.as_str()))
}
// ─── splitCssList / splitCssTokens ──────────────────────────────────────────
/// JS: css-cascade.mjs#splitCssList(value)
/// Split on top-level commas (outside quotes and parens/brackets), trimming
/// each part and dropping an empty tail.
pub fn split_css_list(value: &str) -> Vec<String> {
let chars: Vec<char> = value.chars().collect();
let mut parts: Vec<String> = Vec::new();
let mut depth: i64 = 0;
let mut quote: Option<char> = None;
let mut start = 0usize;
for i in 0..chars.len() {
let ch = chars[i];
if let Some(q) = quote {
if ch == q && (i == 0 || chars[i - 1] != '\\') {
quote = None;
}
continue;
}
if ch == '"' || ch == '\'' {
quote = Some(ch);
continue;
}
if ch == '(' || ch == '[' {
depth += 1;
} else if ch == ')' || ch == ']' {
depth = std::cmp::max(0, depth - 1);
} else if ch == ',' && depth == 0 {
let piece: String = chars[start..i].iter().collect();
parts.push(js::trim(&piece).to_string());
start = i + 1;
}
}
let tail: String = chars[start.min(chars.len())..].iter().collect();
let tail = js::trim(&tail);
if !tail.is_empty() {
parts.push(tail.to_string());
}
parts
}
/// JS: css-cascade.mjs#splitCssTokens(value)
/// Split on top-level whitespace (outside quotes and parens).
pub fn split_css_tokens(value: &str) -> Vec<String> {
let chars: Vec<char> = value.chars().collect();
let mut tokens: Vec<String> = Vec::new();
let mut depth: i64 = 0;
let mut quote: Option<char> = None;
let mut current = String::new();
for i in 0..chars.len() {
let ch = chars[i];
if let Some(q) = quote {
current.push(ch);
if ch == q && (i == 0 || chars[i - 1] != '\\') {
quote = None;
}
continue;
}
if ch == '"' || ch == '\'' {
quote = Some(ch);
current.push(ch);
continue;
}
if ch == '(' {
depth += 1;
current.push(ch);
continue;
}
if ch == ')' {
depth = std::cmp::max(0, depth - 1);
current.push(ch);
continue;
}
if js::is_js_whitespace(ch) && depth == 0 {
if !current.is_empty() {
tokens.push(std::mem::take(&mut current));
}
continue;
}
current.push(ch);
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
// ─── cssPropToCamel ─────────────────────────────────────────────────────────
static DASH_LOWER_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"-([a-z])").expect("DASH_LOWER_RE"));
/// JS: css-cascade.mjs#cssPropToCamel(prop)
pub fn css_prop_to_camel(prop: &str) -> String {
if prop.is_empty() {
return prop.to_string();
}
if let Some(mapped) = static_prop_map(prop) {
return mapped.to_string();
}
DASH_LOWER_RE
.replace_all(prop, |m: &regex::Captures| m[1].to_ascii_uppercase())
.into_owned()
}
// ─── Colors ─────────────────────────────────────────────────────────────────
/// JS: css-cascade.mjs#staticColorToCss(c)
pub fn static_color_to_css(c: Option<&Rgba>) -> String {
let Some(c) = c else {
return String::new();
};
let n = js::number_to_string;
match c.a {
Some(a) if a < 1.0 => {
let rounded = js::string_to_number(&js::to_fixed(a, 3));
format!("rgba({}, {}, {}, {})", n(c.r), n(c.g), n(c.b), n(rounded))
}
_ => format!("rgb({}, {}, {})", n(c.r), n(c.g), n(c.b)),
}
}
/// JS: css-cascade.mjs#parseStaticColor(value)
pub fn parse_static_color(value: &str) -> Option<Rgba> {
if let Some(parsed) = parse_any_color(Some(value)) {
return Some(parsed);
}
let key = js::to_lower_case(js::trim(value));
static_named_color(&key)
}
/// JS `NAMED_COLOR_TOKENS`: every shared + static named color, longest
/// first (stable), joined with `|`.
static NAMED_COLOR_TOKENS: Lazy<String> = Lazy::new(|| {
let mut names: Vec<&str> = CSS_NAMED_COLORS
.iter()
.map(|(n, _)| *n)
.chain(STATIC_NAMED_COLORS.iter().map(|(n, _)| *n))
.collect();
// JS Array.prototype.sort is stable; sort_by is stable too.
names.sort_by(|a, b| b.len().cmp(&a.len()));
names.join("|")
});
/// JS `STATIC_COLOR_TOKEN_RE`.
static STATIC_COLOR_TOKEN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{{3,8}}(?-u:\b)|(?-u:\b)(?:{})(?-u:\b))",
*NAMED_COLOR_TOKENS
))
.expect("STATIC_COLOR_TOKEN_RE")
});
static VAR_HEAD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)^var\(").expect("VAR_HEAD_RE"));
static COLOR_MIX_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)color-mix\(").expect("COLOR_MIX_RE"));
/// JS: css-cascade.mjs#extractStaticColor(value)
pub fn extract_static_color(value: &str) -> String {
if value.is_empty() {
return String::new();
}
let raw = js::trim(value);
if VAR_HEAD_RE.is_match(raw) {
return raw.to_string();
}
// color-mix(...) needs balanced-paren capture (its arguments regularly
// contain nested var()/oklch() calls AND the keyword `transparent`, which
// the flat regex below would otherwise pluck out of the middle of the
// expression and report as the whole color).
if let Some(m) = COLOR_MIX_RE.find(raw) {
let mix_start = m.start();
let bytes = raw.as_bytes();
// JS: raw.indexOf('(', mixStart) — the `(` right after `color-mix`.
let mut i = match raw[mix_start..].find('(') {
Some(off) => mix_start + off,
None => raw.len(),
};
let mut depth: i64 = 0;
while i < bytes.len() {
if bytes[i] == b'(' {
depth += 1;
} else if bytes[i] == b')' {
depth -= 1;
if depth == 0 {
return raw[mix_start..=i].to_string();
}
}
i += 1;
}
return String::new();
}
match STATIC_COLOR_TOKEN_RE.find(raw) {
Some(m) => m.as_str().to_string(),
None => String::new(),
}
}
static MODERN_BORDER_PROP_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^border[A-Z][a-z]+Color$").expect("MODERN_BORDER_PROP_RE"));
static MODERN_COLOR_FN_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)^(?:oklch|oklab|lch|lab|hsl|hwb)\(").expect("MODERN_COLOR_FN_RE")
});
static COLOR_TAIL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?i)color$").expect("COLOR_TAIL_RE"));
/// JS `parseFloat(style?.fontSize) || 16`.
fn font_size_base(style: Option<&StyleValues>) -> f64 {
let n = match style_get(style, "fontSize") {
Some(v) => js::parse_float(v),
None => f64::NAN,
};
if n.is_nan() || n == 0.0 {
16.0
} else {
n
}
}
/// JS `parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16`.
fn font_size_base2(current: Option<&StyleValues>, parent: Option<&StyleValues>) -> f64 {
let v = match style_get(current, "fontSize") {
Some(v) if !v.is_empty() => Some(v),
_ => style_get(parent, "fontSize"),
};
let n = match v {
Some(v) => js::parse_float(v),
None => f64::NAN,
};
if n.is_nan() || n == 0.0 {
16.0
} else {
n
}
}
/// JS: css-cascade.mjs#normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null)
pub fn normalize_static_css_value(
prop: &str,
value: &str,
custom_props: &CustomProps,
parent_style: Option<&StyleValues>,
current_style: Option<&StyleValues>,
) -> String {
let mut resolved = resolve_var_refs(js::trim(value), custom_props);
if resolved == "inherit" {
if let Some(v) = style_get(parent_style, prop) {
if !v.is_empty() {
return v.to_string();
}
}
if let Some(d) = static_default_style(prop) {
if !d.is_empty() {
return d.to_string();
}
}
return String::new();
}
let is_modern_border_color =
MODERN_BORDER_PROP_RE.is_match(prop) && MODERN_COLOR_FN_RE.is_match(&resolved);
if !is_modern_border_color
&& (COLOR_TAIL_RE.is_match(prop) || prop == "color" || prop == "backgroundColor")
{
if let Some(parsed) = parse_static_color(&resolved) {
resolved = static_color_to_css(Some(&parsed));
}
}
if prop == "fontSize" {
let base = font_size_base(parent_style);
if let Some(px) = resolve_length_px(&resolved, base) {
resolved = format!("{}px", js::number_to_string(px));
}
}
if prop == "letterSpacing" {
let base = font_size_base2(current_style, parent_style);
if let Some(px) = resolve_length_px(&resolved, base) {
resolved = format!("{}px", js::number_to_string(px));
}
}
if prop == "lineHeight" && resolved != "normal" {
let base = font_size_base2(current_style, parent_style);
if let Some(px) = resolve_length_px(&resolved, base) {
resolved = format!("{}px", js::number_to_string(px));
}
}
resolved
}
// ─── normalizeColorForCheck ─────────────────────────────────────────────────
static HEX6_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$").expect("HEX6_RE"));
static HEX3_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?i)^#([0-9a-f])([0-9a-f])([0-9a-f])$").expect("HEX3_RE"));
/// JS: css-cascade.mjs#normalizeColorForCheck(value)
/// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
/// CSS variables typically hold hex or named colors, so normalize those to
/// rgb() before handing the value off to the shared check. Anything we don't
/// recognise is passed through unchanged (trimmed).
pub fn normalize_color_for_check(value: &str) -> String {
if value.is_empty() {
return value.to_string();
}
let v = js::trim(value);
if let Some(m) = HEX6_RE.captures(v) {
let p = |i: usize| u32::from_str_radix(&m[i], 16).unwrap_or(0);
return format!("rgb({}, {}, {})", p(1), p(2), p(3));
}
if let Some(m) = HEX3_RE.captures(v) {
let p = |i: usize| u32::from_str_radix(&format!("{}{}", &m[i], &m[i]), 16).unwrap_or(0);
return format!("rgb({}, {}, {})", p(1), p(2), p(3));
}
let lower = js::to_lower_case(v);
if let Some((_, rgb)) = NAMED_COLORS.iter().find(|(n, _)| *n == lower) {
return format!("rgb({}, {}, {})", rgb[0], rgb[1], rgb[2]);
}
v.to_string()
}
// ─── unwrapCssAtLayer ───────────────────────────────────────────────────────
static AT_LAYER_OPEN_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"@layer(?-u:\b)[^{;]*\{").expect("AT_LAYER_OPEN_RE"));
/// JS: css-cascade.mjs#unwrapCssAtLayer(source)
/// Rewrite `@layer name { ... }` blocks to their inner rules as flat CSS
/// (jsdom doesn't implement @layer). Walks the source balancing braces so
/// nested style rules inside the layer block are handled; an unbalanced
/// block returns the source unchanged.
pub fn unwrap_css_at_layer(source: &str) -> String {
if source.is_empty() || !source.contains("@layer") {
return source.to_string();
}
let bytes = source.as_bytes();
let mut out = String::new();
let mut last_idx = 0usize;
let mut search_from = 0usize;
while let Some(m) = AT_LAYER_OPEN_RE.find_at(source, search_from) {
let open_start = m.start();
let open_end = m.end();
let mut depth: i64 = 1;
let mut i = open_end;
while i < bytes.len() && depth > 0 {
let c = bytes[i];
if c == b'{' {
depth += 1;
} else if c == b'}' {
depth -= 1;
}
i += 1;
}
if depth != 0 {
return source.to_string();
}
out.push_str(&source[last_idx..open_start]);
out.push_str(&source[open_end..i - 1]);
last_idx = i;
search_from = i;
}
out.push_str(&source[last_idx..]);
out
}
+294
View File
@@ -0,0 +1,294 @@
//! Vector-replay dispatch for `engines.static-html.css-cascade`, mirroring
//! `impeccable_core::vectors::call` (`tests/vectors.rs` replays the recorded
//! JS calls through it).
use super::checks_shim::CustomProps;
use super::rules::{
compare_static_priority, parse_static_style_attribute, static_specificity, DeclMeta,
};
use super::shorthand::{
expand_static_box_values, expand_static_declaration, parse_static_animation,
parse_static_border, parse_static_font, parse_static_transition,
};
use super::values::{
css_prop_to_camel, extract_static_color, normalize_static_css_value, parse_static_color,
split_css_list, split_css_tokens, static_color_to_css, StyleValues,
};
use impeccable_core::color::Rgba;
use impeccable_core::js;
use impeccable_core::vectors::{decode, encode, Js};
use serde_json::Value;
pub const MODULE: &str = "engines.static-html.css-cascade";
/// (module, fn) pairs this crate replays.
pub const KNOWN: &[(&str, &[&str])] = &[(
MODULE,
&[
"compareStaticPriority",
"cssPropToCamel",
"expandStaticBoxValues",
"expandStaticDeclaration",
"extractStaticColor",
"normalizeStaticCssValue",
"parseStaticAnimation",
"parseStaticBorder",
"parseStaticColor",
"parseStaticFont",
"parseStaticStyleAttribute",
"parseStaticTransition",
"splitCssList",
"splitCssTokens",
"staticColorToCss",
"staticSpecificity",
],
)];
fn get<'a>(j: &'a Js, key: &str) -> Option<&'a Js> {
match j {
Js::Obj(fields) => fields.iter().find(|(k, _)| k == key).map(|(_, v)| v),
_ => None,
}
}
fn to_number(j: &Js) -> f64 {
match j {
Js::Undef => f64::NAN,
Js::Null => 0.0,
Js::Bool(b) => {
if *b {
1.0
} else {
0.0
}
}
Js::Num(n) => *n,
Js::Str(s) => js::string_to_number(s),
_ => f64::NAN,
}
}
fn truthy(j: Option<&Js>) -> bool {
match j {
None | Some(Js::Undef) | Some(Js::Null) => false,
Some(Js::Bool(b)) => *b,
Some(Js::Num(n)) => !(*n == 0.0 || n.is_nan()),
Some(Js::Str(s)) => !s.is_empty(),
Some(_) => true,
}
}
/// JS `String(value || '')` for the string-ish arguments the vectors carry.
fn str_of(j: &Js) -> String {
match j {
Js::Str(s) => s.clone(),
Js::Undef | Js::Null => String::new(),
Js::Num(n) => {
if *n == 0.0 || n.is_nan() {
String::new()
} else {
js::number_to_string(*n)
}
}
Js::Bool(b) => {
if *b {
"true".to_string()
} else {
String::new()
}
}
_ => String::new(),
}
}
fn str_vec(j: &Js) -> Vec<String> {
match j {
Js::Arr(items) => items.iter().map(str_of).collect(),
_ => Vec::new(),
}
}
fn arr_of_str(items: &[String]) -> Js {
Js::Arr(items.iter().map(|s| Js::Str(s.clone())).collect())
}
fn pairs_to_js(pairs: &[(String, String)]) -> Js {
Js::Arr(
pairs
.iter()
.map(|(a, b)| Js::Arr(vec![Js::Str(a.clone()), Js::Str(b.clone())]))
.collect(),
)
}
fn rgba_from(j: &Js) -> Option<Rgba> {
match j {
Js::Obj(_) => Some(Rgba {
r: get(j, "r").map(to_number).unwrap_or(f64::NAN),
g: get(j, "g").map(to_number).unwrap_or(f64::NAN),
b: get(j, "b").map(to_number).unwrap_or(f64::NAN),
a: match get(j, "a") {
None | Some(Js::Undef) | Some(Js::Null) => None,
Some(v) => Some(to_number(v)),
},
}),
_ => None,
}
}
fn rgba_to_js(c: &Rgba) -> Js {
let mut fields = vec![
("r".to_string(), Js::Num(c.r)),
("g".to_string(), Js::Num(c.g)),
("b".to_string(), Js::Num(c.b)),
];
if let Some(a) = c.a {
fields.push(("a".to_string(), Js::Num(a)));
}
Js::Obj(fields)
}
fn meta_from(j: &Js) -> Option<DeclMeta> {
if matches!(j, Js::Undef | Js::Null) {
return None;
}
let spec: Vec<Js> = match get(j, "specificity") {
Some(Js::Arr(items)) => items.clone(),
_ => Vec::new(),
};
let s = |i: usize| -> u32 {
// JS `(b.specificity[i] || 0)`
match spec.get(i) {
Some(v) => {
let n = to_number(v);
if n.is_nan() {
0
} else {
n as u32
}
}
None => 0,
}
};
Some(DeclMeta {
important: truthy(get(j, "important")),
inline: truthy(get(j, "inline")),
specificity: [s(0), s(1), s(2)],
order: get(j, "order").map(to_number).unwrap_or(f64::NAN) as i64,
})
}
fn style_from(j: &Js) -> Option<StyleValues> {
match j {
Js::Obj(fields) => {
let mut m = StyleValues::new();
for (k, v) in fields {
if let Js::Str(s) = v {
m.insert(k.clone(), s.clone());
}
}
Some(m)
}
_ => None,
}
}
fn custom_props_from(j: &Js) -> CustomProps {
let mut m = CustomProps::new();
if let Js::Map(entries) = j {
for (k, v) in entries {
if let (Js::Str(k), Js::Str(v)) = (k, v) {
m.insert(k.clone(), v.clone());
}
}
}
m
}
/// Invoke the Rust port of `<module>.<fn_name>` with recorder-encoded
/// arguments; `None` when the function is not known here.
pub fn call(module: &str, fn_name: &str, args: &[Value]) -> Option<Value> {
if module != MODULE {
return None;
}
let a: Vec<Js> = args.iter().map(decode).collect();
let arg = |i: usize| a.get(i).cloned().unwrap_or(Js::Undef);
let s = |i: usize| str_of(&arg(i));
let result: Js = match fn_name {
"compareStaticPriority" => {
let a0 = arg(0);
let b = meta_from(&arg(1))?;
Js::Bool(compare_static_priority(meta_from(&a0).as_ref(), &b))
}
"cssPropToCamel" => Js::Str(css_prop_to_camel(&s(0))),
"expandStaticBoxValues" => arr_of_str(&expand_static_box_values(&str_vec(&arg(0)))),
"expandStaticDeclaration" => pairs_to_js(&expand_static_declaration(&s(0), &s(1))),
"extractStaticColor" => Js::Str(extract_static_color(&s(0))),
"normalizeStaticCssValue" => {
let custom = custom_props_from(&arg(2));
let parent = style_from(&arg(3));
let current = style_from(&arg(4));
Js::Str(normalize_static_css_value(
&s(0),
&s(1),
&custom,
parent.as_ref(),
current.as_ref(),
))
}
"parseStaticAnimation" => {
let r = parse_static_animation(&s(0));
Js::Obj(vec![
("name".to_string(), Js::Str(r.name)),
("timing".to_string(), Js::Str(r.timing)),
])
}
"parseStaticBorder" => {
let r = parse_static_border(&s(0));
Js::Obj(vec![
("width".to_string(), Js::Str(r.width)),
("color".to_string(), Js::Str(r.color)),
])
}
"parseStaticColor" => match parse_static_color(&s(0)) {
Some(c) => rgba_to_js(&c),
None => Js::Null,
},
"parseStaticFont" => pairs_to_js(&parse_static_font(&s(0))),
"parseStaticStyleAttribute" => {
let order_base = match arg(1) {
Js::Undef => 0.0,
v => to_number(&v),
} as i64;
Js::Arr(
parse_static_style_attribute(&s(0), order_base)
.into_iter()
.map(|d| {
Js::Obj(vec![
("prop".to_string(), Js::Str(d.prop)),
("value".to_string(), Js::Str(d.value)),
("important".to_string(), Js::Bool(d.important)),
("order".to_string(), Js::Num(d.order as f64)),
])
})
.collect(),
)
}
"parseStaticTransition" => {
let r = parse_static_transition(&s(0));
Js::Obj(vec![
("property".to_string(), Js::Str(r.property)),
("timing".to_string(), Js::Str(r.timing)),
])
}
"splitCssList" => arr_of_str(&split_css_list(&s(0))),
"splitCssTokens" => arr_of_str(&split_css_tokens(&s(0))),
"staticColorToCss" => Js::Str(static_color_to_css(rgba_from(&arg(0)).as_ref())),
"staticSpecificity" => {
let sp = static_specificity(&s(0));
Js::Arr(sp.iter().map(|n| Js::Num(*n as f64)).collect())
}
_ => return None,
};
Some(encode(&result))
}
+597
View File
@@ -0,0 +1,597 @@
//! The static DOM: `StaticDocument` / `StaticElement` from
//! `cli/engine/engines/static-html/css-cascade.mjs`, over an html5ever tree.
//!
//! The JS engine parses with htmlparser2 (`lowerCaseTags: true`) and queries
//! with css-select. Two htmlparser2 quirks the JS wrapper exposes and this
//! port reproduces:
//!
//! - `<script>` and `<style>` nodes have type `script` / `style`, not `tag`.
//! css-select still matches them (`isTag` is true for all three), so they
//! appear in `querySelectorAll` results and get element checks run on
//! them, but `StaticElement.children` / `parentElement` /
//! `previousElementSibling` / `closest` only walk `tag`-typed nodes and the
//! cascade never computes a style for them (they read as the default
//! style). [`StaticElement::is_plain_tag`] is that distinction.
//! - `childNodes` maps every non-text, non-tag child (comments, doctypes,
//! script/style elements) to a `nodeType: 8` stub.
//!
//! html5ever differs from htmlparser2 in tree construction. What this port
//! normalizes: `<template>` fragments are flattened back under the
//! `<template>` element, and `html` / `head` / `body` elements the source
//! never spelled out are unwrapped again (htmlparser2 never implies them, so
//! a partial keeps its flat top-level shape and `:root` matches each
//! top-level element). What stays different, none of which the fixture
//! corpus exercises (see `tests/oracle_html.rs`; the JS repo's
//! `tests/oracle/DELTAS.md` is where a reviewed delta would be listed):
//!
//! - attribute names: htmlparser2 runs with `lowerCaseAttributeNames: false`,
//! so `<div CLASS="card" STYLE="...">` has no `class` / `style` for the JS
//! engine, while html5ever lowercases attribute names (the browser
//! behavior) and the port sees them;
//! - implied `<tbody>` in tables, `<p>` auto-close on block starts, foster
//! parenting of stray table content, nested `<a>` (adoption agency), and
//! `<noscript>` content (raw text here, markup in htmlparser2);
//! - SVG tag names keep their case (`linearGradient`); tag matching is
//! ASCII case-insensitive on both sides so selectors agree, but
//! `tagName` reads lowercase as htmlparser2 (`lowerCaseTags`) reports it.
use crate::select::{El, Selector, SelectorError};
use ego_tree::{NodeId, NodeRef};
use html5ever::tendril::TendrilSink;
use html5ever::tree_builder::TreeBuilderOpts;
use html5ever::ParseOpts;
use impeccable_core::color::Rgba;
use impeccable_core::js;
use once_cell::sync::Lazy;
use regex::Regex;
use scraper::{Html, HtmlTreeSink, Node};
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use crate::cascade::StyleValues;
/// The parsed document plus everything the cascade attaches to its nodes.
pub struct StaticDocument {
pub html: Html,
styles: HashMap<NodeId, StyleValues>,
hover_styles: HashMap<NodeId, StyleValues>,
accent_dash: HashSet<NodeId>,
pseudo_surface: HashMap<NodeId, Rgba>,
selector_cache: RefCell<HashMap<String, Result<Selector, SelectorError>>>,
unsupported_selectors: RefCell<Vec<String>>,
}
impl std::fmt::Debug for StaticDocument {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticDocument")
.field("styles", &self.styles.len())
.finish()
}
}
/// JS: `modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true })`
/// Parse with html5ever, then flatten template fragments.
///
/// Scripting stays enabled (the html5ever default), so `<noscript>` content
/// is raw text. htmlparser2 parses it as markup, but the alternative
/// (`scripting_enabled: false`) moves a head-level `<noscript>`'s prose out
/// to `<body>` as bare text, which the JS never sees; raw text inside the
/// (non-rendered) `noscript` element is the closer match.
pub fn parse_html(source: &str) -> Html {
let opts = ParseOpts {
tree_builder: TreeBuilderOpts {
scripting_enabled: true,
..Default::default()
},
..Default::default()
};
let sink = HtmlTreeSink::new(Html::new_document());
let mut html = html5ever::parse_document(sink, opts).one(source);
flatten_fragments(&mut html);
unwrap_synthesized_wrappers(&mut html, source);
html
}
static HTML_TAG_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"(?i)<html[{}/>]", js::WS_CHARS)).expect("HTML_TAG_RE"));
static HEAD_TAG_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"(?i)<head[{}/>]", js::WS_CHARS)).expect("HEAD_TAG_RE"));
static BODY_TAG_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"(?i)<body[{}/>]", js::WS_CHARS)).expect("BODY_TAG_RE"));
static COMMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?s)<!--.*?-->").expect("COMMENT_RE"));
/// html5ever always synthesizes `<html>`, `<head>` and `<body>`; htmlparser2
/// never does. For every one of the three the source does not spell out
/// (comments stripped first), splice the element's children into its parent
/// at its position and drop it, so a fragment or partial keeps the flat
/// top-level shape the JS engine walks (`:root` then matches each top-level
/// element, `html {}` rules match nothing, and so on).
fn unwrap_synthesized_wrappers(html: &mut Html, source: &str) {
let stripped = COMMENT_RE.replace_all(source, "");
let missing: Vec<&str> = [
("html", &*HTML_TAG_RE),
("head", &*HEAD_TAG_RE),
("body", &*BODY_TAG_RE),
]
.into_iter()
.filter(|(_, re)| !re.is_match(&stripped))
.map(|(name, _)| name)
.collect();
if missing.is_empty() {
return;
}
// Innermost first (body, head, then html) so parents are still present.
for name in ["body", "head", "html"] {
if !missing.contains(&name) {
continue;
}
let target = html.tree.root().descendants().find(|n| {
n.value()
.as_element()
.is_some_and(|e| e.name.ns == html5ever::ns!(html) && e.name.local.as_ref() == name)
});
let Some(target) = target else {
continue;
};
let id = target.id();
let children: Vec<NodeId> = target.children().map(|c| c.id()).collect();
for child in children {
if let Some(mut t) = html.tree.get_mut(id) {
t.insert_id_before(child);
}
}
if let Some(mut t) = html.tree.get_mut(id) {
t.detach();
}
}
}
/// Move `<template>` fragment children under the template element and drop
/// the fragment node, so the tree walk sees template content as ordinary
/// descendants (htmlparser2 has no template special-casing).
fn flatten_fragments(html: &mut Html) {
let fragments: Vec<(NodeId, NodeId)> = html
.tree
.root()
.descendants()
.filter(|n| n.value().is_fragment())
.filter_map(|n| n.parent().map(|p| (p.id(), n.id())))
.collect();
for (parent, frag) in fragments {
if let Some(mut p) = html.tree.get_mut(parent) {
p.reparent_from_id_append(frag);
}
if let Some(mut f) = html.tree.get_mut(frag) {
f.detach();
}
}
}
impl StaticDocument {
/// Wrap a parsed tree.
pub fn new(html: Html) -> Self {
StaticDocument {
html,
styles: HashMap::new(),
hover_styles: HashMap::new(),
accent_dash: HashSet::new(),
pseudo_surface: HashMap::new(),
selector_cache: RefCell::new(HashMap::new()),
unsupported_selectors: RefCell::new(Vec::new()),
}
}
/// Parse and wrap.
pub fn parse(source: &str) -> Self {
Self::new(parse_html(source))
}
fn root(&self) -> NodeRef<'_, Node> {
self.html.tree.root()
}
/// Wrap a node id as an element (None when it is not an element).
pub fn element(&self, id: NodeId) -> Option<StaticElement<'_>> {
let node = self.html.tree.get(id)?;
node.value()
.is_element()
.then(|| StaticElement { doc: self, node })
}
fn wrap<'a>(&'a self, node: NodeRef<'a, Node>) -> StaticElement<'a> {
StaticElement { doc: self, node }
}
/// Compile (and cache) a selector; `Err` where css-select throws. The
/// first failure of each selector text is recorded for the parity report.
pub fn compile(&self, selector: &str) -> Result<Selector, SelectorError> {
if let Some(r) = self.selector_cache.borrow().get(selector) {
return r.clone();
}
let r = Selector::parse(selector);
if r.is_err() {
self.unsupported_selectors
.borrow_mut()
.push(selector.to_string());
}
self.selector_cache
.borrow_mut()
.insert(selector.to_string(), r.clone());
r
}
/// Selectors css-select would refuse that were seen during this scan.
pub fn unsupported_selectors(&self) -> Vec<String> {
self.unsupported_selectors.borrow().clone()
}
/// css-select `selectAll(selector, nodes)`: every element among `nodes`
/// and their descendants (document order) matching `selector`.
fn select_all_in<'a>(
&'a self,
selector: &Selector,
roots: impl Iterator<Item = NodeRef<'a, Node>>,
) -> Vec<StaticElement<'a>> {
let mut out = Vec::new();
for root in roots {
for n in root.descendants() {
if n.value().is_element() && selector.matches(&El(n)) {
out.push(self.wrap(n));
}
}
}
out
}
fn select_one_in<'a>(
&'a self,
selector: &Selector,
roots: impl Iterator<Item = NodeRef<'a, Node>>,
) -> Option<StaticElement<'a>> {
for root in roots {
for n in root.descendants() {
if n.value().is_element() && selector.matches(&El(n)) {
return Some(self.wrap(n));
}
}
}
None
}
/// JS `document.querySelectorAll(selector)`: `[]` for an unsupported selector.
pub fn query_selector_all(&self, selector: &str) -> Vec<StaticElement<'_>> {
match self.compile(selector) {
Ok(sel) => self.select_all_in(&sel, self.root().children()),
Err(_) => Vec::new(),
}
}
/// JS `document.querySelector(selector)`.
pub fn query_selector(&self, selector: &str) -> Option<StaticElement<'_>> {
match self.compile(selector) {
Ok(sel) => self.select_one_in(&sel, self.root().children()),
Err(_) => None,
}
}
/// JS `document.documentElement`.
pub fn document_element(&self) -> Option<StaticElement<'_>> {
self.query_selector("html")
}
/// JS `document.body`.
pub fn body(&self) -> Option<StaticElement<'_>> {
self.query_selector("body")
}
/// Every element node in document order (JS `selectAll('*', root.children)`).
pub fn all_elements(&self) -> Vec<StaticElement<'_>> {
self.root()
.descendants()
.filter(|n| n.value().is_element())
.map(|n| self.wrap(n))
.collect()
}
/// Element children of the document root, in order (JS `root.children`
/// filtered to tags).
pub fn root_elements(&self) -> Vec<StaticElement<'_>> {
self.root()
.children()
.filter(|n| n.value().is_element())
.map(|n| self.wrap(n))
.collect()
}
// ── cascade attachments ─────────────────────────────────────────────
pub fn set_style(&mut self, node: NodeId, style: StyleValues) {
self.styles.insert(node, style);
}
/// JS `getStyle(el)`: the computed style, or the default style for a
/// node the cascade never visited.
pub fn get_style(&self, node: NodeId) -> &StyleValues {
self.styles.get(&node).unwrap_or_else(|| default_style())
}
pub fn set_hover_style(&mut self, node: NodeId, style: StyleValues) {
self.hover_styles.insert(node, style);
}
pub fn get_hover_style(&self, node: NodeId) -> Option<&StyleValues> {
self.hover_styles.get(&node)
}
pub fn set_accent_dash_pseudo(&mut self, node: NodeId) {
self.accent_dash.insert(node);
}
pub fn has_accent_dash_pseudo(&self, node: NodeId) -> bool {
self.accent_dash.contains(&node)
}
pub fn set_pseudo_surface(&mut self, node: NodeId, color: Rgba) {
self.pseudo_surface.insert(node, color);
}
pub fn get_pseudo_surface(&self, node: NodeId) -> Option<Rgba> {
self.pseudo_surface.get(&node).copied()
}
}
static DEFAULT_STYLE: once_cell::sync::Lazy<StyleValues> =
once_cell::sync::Lazy::new(crate::cascade::make_default_style);
/// JS `makeStaticStyle()`: the untouched default style.
pub fn default_style() -> &'static StyleValues {
&DEFAULT_STYLE
}
/// One entry of JS `childNodes`.
#[derive(Debug, Clone, Copy)]
pub enum ChildNode<'a> {
/// `nodeType: 3`
Text(&'a str),
/// `nodeType: 1` (a `tag`-typed element)
Element(StaticElement<'a>),
/// `nodeType: 8` (comment, doctype, script/style element)
Other,
}
/// JS `StaticElement`: a wrapper over one element node.
#[derive(Clone, Copy)]
pub struct StaticElement<'a> {
pub doc: &'a StaticDocument,
pub node: NodeRef<'a, Node>,
}
impl std::fmt::Debug for StaticElement<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<{}>", self.tag_lower())
}
}
impl PartialEq for StaticElement<'_> {
fn eq(&self, other: &Self) -> bool {
self.node.id() == other.node.id()
}
}
impl Eq for StaticElement<'_> {}
impl std::hash::Hash for StaticElement<'_> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.node.id().hash(state);
}
}
/// htmlparser2 element type: `script` / `style` are not `tag`.
fn node_is_plain_tag(n: &NodeRef<'_, Node>) -> bool {
match n.value() {
Node::Element(e) => {
let name = e.name.local.as_ref();
!name.eq_ignore_ascii_case("script") && !name.eq_ignore_ascii_case("style")
}
_ => false,
}
}
impl<'a> StaticElement<'a> {
pub fn id(&self) -> NodeId {
self.node.id()
}
fn elem(&self) -> &'a scraper::node::Element {
self.node.value().as_element().expect("element node")
}
/// htmlparser2 `type === 'tag'` (false for `<script>` / `<style>`).
pub fn is_plain_tag(&self) -> bool {
node_is_plain_tag(&self.node)
}
/// JS `el.tagName.toLowerCase()` (htmlparser2 lowercases tag names).
pub fn tag_lower(&self) -> String {
self.elem().name.local.as_ref().to_ascii_lowercase()
}
/// JS `el.tagName` (upper-cased).
pub fn tag_upper(&self) -> String {
self.elem().name.local.as_ref().to_ascii_uppercase()
}
/// JS `getAttribute(name)`: `null` when absent.
pub fn get_attribute(&self, name: &str) -> Option<&'a str> {
self.elem().attr(name)
}
/// JS `el.className` (`getAttribute('class') || ''`).
pub fn class_name(&self) -> &'a str {
self.get_attribute("class").unwrap_or("")
}
/// JS `el.id` (`getAttribute('id') || ''`).
pub fn id_attr(&self) -> &'a str {
self.get_attribute("id").unwrap_or("")
}
/// JS `parentElement`: the nearest `tag`-typed ancestor.
pub fn parent_element(&self) -> Option<StaticElement<'a>> {
let mut cur = self.node.parent();
while let Some(n) = cur {
if node_is_plain_tag(&n) {
return Some(self.doc.wrap(n));
}
if n.value().is_document() {
return None;
}
cur = n.parent();
}
None
}
/// JS `previousElementSibling`: the nearest preceding `tag`-typed sibling.
pub fn previous_element_sibling(&self) -> Option<StaticElement<'a>> {
self.node
.prev_siblings()
.find(node_is_plain_tag)
.map(|n| self.doc.wrap(n))
}
/// JS `children`: `tag`-typed child nodes.
pub fn children(&self) -> Vec<StaticElement<'a>> {
self.node
.children()
.filter(node_is_plain_tag)
.map(|n| self.doc.wrap(n))
.collect()
}
/// JS `childNodes`.
pub fn child_nodes(&self) -> Vec<ChildNode<'a>> {
self.node
.children()
.map(|n| match n.value() {
Node::Text(t) => ChildNode::Text(&t.text),
Node::Element(_) if node_is_plain_tag(&n) => ChildNode::Element(self.doc.wrap(n)),
_ => ChildNode::Other,
})
.collect()
}
/// The concatenated direct text-node content (`childNodes` of type 3).
pub fn direct_text(&self) -> String {
let mut s = String::new();
for c in self.node.children() {
if let Node::Text(t) = c.value() {
s.push_str(&t.text);
}
}
s
}
/// Whether any direct text node's trimmed length exceeds `min_len`
/// (JS `childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > min_len)`).
pub fn has_direct_text_longer_than(&self, min_len: usize) -> bool {
self.node.children().any(|c| match c.value() {
Node::Text(t) => {
impeccable_core::js_ext_b::utf16_len(impeccable_core::js::trim(&t.text)) > min_len
}
_ => false,
})
}
/// JS `textContent` (domutils): all descendant text, including
/// `<script>` / `<style>` payloads, excluding comments.
pub fn text_content(&self) -> String {
let mut s = String::new();
for n in self.node.descendants().skip(1) {
if let Node::Text(t) = n.value() {
s.push_str(&t.text);
}
}
s
}
/// JS `el.querySelectorAll(selector)`: `[]` for an unsupported selector.
pub fn query_selector_all(&self, selector: &str) -> Vec<StaticElement<'a>> {
match self.doc.compile(selector) {
Ok(sel) => self.doc.select_all_in(&sel, self.node.children()),
Err(_) => Vec::new(),
}
}
/// JS `el.querySelector(selector)`.
pub fn query_selector(&self, selector: &str) -> Option<StaticElement<'a>> {
match self.doc.compile(selector) {
Ok(sel) => self.doc.select_one_in(&sel, self.node.children()),
Err(_) => None,
}
}
/// JS `closest(selector)`: self-or-ancestor walk over `tag`-typed nodes;
/// `null` for an unsupported selector or when `self` is script/style.
pub fn closest(&self, selector: &str) -> Option<StaticElement<'a>> {
let sel = self.doc.compile(selector).ok()?;
let mut cur = Some(*self);
while let Some(el) = cur {
if !el.is_plain_tag() {
return None;
}
if sel.matches(&El(el.node)) {
return Some(el);
}
cur = el.parent_element();
}
None
}
/// JS `contains(other)`: `other` is `self` or a descendant.
pub fn contains(&self, other: &StaticElement<'_>) -> bool {
let mut cur = Some(other.node);
while let Some(n) = cur {
if n.id() == self.node.id() {
return true;
}
cur = n.parent();
}
false
}
/// The computed style (default when the cascade skipped this node).
pub fn style(&self) -> &'a StyleValues {
self.doc.get_style(self.node.id())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn script_style_are_not_tags() {
let doc = StaticDocument::parse(
"<html><body><style>p{}</style><h1>Hi</h1><script>x</script><div><template><p>t</p></template></div></body></html>",
);
let h1 = doc.query_selector("h1").unwrap();
assert!(h1.previous_element_sibling().is_none());
let body = doc.body().unwrap();
assert_eq!(body.children().len(), 2);
assert_eq!(body.child_nodes().len(), 4);
assert!(body.text_content().contains("p{}"));
let style = doc.query_selector("style").unwrap();
assert!(style.closest("body").is_none());
assert!(h1.closest("body").is_some());
assert_eq!(doc.query_selector_all("template p").len(), 1);
// No `<head>` in the source: the synthesized one is unwrapped.
assert_eq!(doc.query_selector_all("*").len(), 8);
assert!(doc.query_selector_all("p:focus").is_empty());
assert_eq!(doc.unsupported_selectors(), vec!["p:focus".to_string()]);
}
#[test]
fn fragments_keep_top_level_shape() {
let doc = StaticDocument::parse("<style>x{}</style><div class=a>one</div><p>two</p>");
let tags: Vec<String> = doc.all_elements().iter().map(|e| e.tag_lower()).collect();
assert_eq!(tags, vec!["style", "div", "p"]);
assert_eq!(doc.query_selector_all(":root").len(), 3);
assert!(doc.document_element().is_none());
assert!(doc.body().is_none());
let full = StaticDocument::parse("<!-- <html> --><html><body><p>x</p></body></html>");
let tags: Vec<String> = full.all_elements().iter().map(|e| e.tag_lower()).collect();
assert_eq!(tags, vec!["html", "body", "p"]);
}
}
+400
View File
@@ -0,0 +1,400 @@
//! Port of `cli/engine/engines/static-html/detect-html.mjs#detectHtml`: the
//! whole static engine run for one HTML file, in the exact JS finding order
//! (element rules in `STATIC_ELEMENT_RULES` order, design-system findings,
//! then the page-level checks for full pages), then inline ignores.
//!
//! Two pieces the JS reaches into other engines for are exposed as hooks on
//! [`DetectHtmlOptions`] instead of being duplicated here:
//! `runTextContentAnalyzers` (regex engine) and the design-system trio
//! (`checkSourceDesignSystem`, `collectStaticDesignSystemFindings`,
//! `mergeDesignSystemFindings`). The `detect` crate wires them.
use crate::adapters::{
check_element_borders, check_element_broken_image, check_element_clipped_overflow,
check_element_colors, check_element_glow, check_element_gpt_border_shadow,
check_element_hero_eyebrow, check_element_hover_contrast, check_element_icon_tile,
check_element_italic_serif, check_element_motion, check_element_oversized_h1,
check_element_radial_spotlight, check_kicker_above_heading_from_doc,
check_numbered_section_labels_from_doc, scoped_ignore_active,
};
use crate::background::{resolve_background, resolve_border_radius_px, sv};
use crate::cascade::{build_static_style_map, collect_static_css_text};
use crate::dom::{StaticDocument, StaticElement};
use crate::page::{
check_cream_palette, check_page_layout, check_repeated_container_text_from_doc,
check_static_page_typography,
};
use crate::profile::{self, Meta, ProfileSink};
use crate::quality::{check_element_quality, check_page_quality_from_doc, pf0};
use impeccable_core::checks::html_patterns::{check_html_patterns, HtmlPatternCorpora};
use impeccable_core::checks::rules::RuleHit;
use impeccable_core::findings::{try_finding, Finding};
use impeccable_core::inline_ignores::apply_inline_ignores;
use impeccable_core::page::is_full_page;
use once_cell::sync::Lazy;
use regex::Regex;
use std::path::Path;
/// The design-system pieces of `detectHtml` (`design-system.mjs`), supplied
/// by the orchestrating crate when a project design system is loaded.
pub trait DesignSystemHook {
/// JS `checkSourceDesignSystem(html, filePath, { designSystem })`.
fn check_source(&self, html: &str, file_path: &str) -> Vec<Finding>;
/// JS `collectStaticDesignSystemFindings(document, window, filePath, designSystem)`.
fn collect_static(&self, doc: &StaticDocument, file_path: &str) -> Vec<Finding>;
/// JS `mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings)`.
fn merge(&self, static_findings: Vec<Finding>, source_findings: Vec<Finding>) -> Vec<Finding>;
}
/// JS `runTextContentAnalyzers(html, filePath, options)` from the regex
/// engine: em-dash overuse, marketing buzzwords, aphoristic cadence.
pub type TextContentAnalyzers<'a> = &'a dyn Fn(&str, &str) -> Vec<Finding>;
/// The static engine's half of a rule pack (`impeccable_core::rule_pack`):
/// rules written against the parsed page. The [`StaticDocument`] model is
/// this crate's, so this hook cannot live on the engine-wide `RulePack`
/// trait; a pack implements both and hands the same value to both fields of
/// [`DetectHtmlOptions`].
///
/// Findings come back as full [`Finding`] values, built with
/// `impeccable_core::findings::finding_for(row, file_path, snippet, line)` so
/// they carry the pack's own registry metadata.
pub trait StaticRulePack: Send + Sync + std::fmt::Debug {
/// Runs once per HTML file, after every built-in pass and before inline
/// ignores.
fn check_document(&self, doc: &StaticDocument, file_path: &str) -> Vec<Finding>;
}
/// The `options` object `detectHtml` reads.
#[derive(Default, Clone, Copy)]
pub struct DetectHtmlOptions<'a> {
/// JS `options.inlineIgnores === false` disables the whole-file
/// `impeccable-disable` waivers; anything else applies them.
pub inline_ignores_disabled: bool,
/// JS `options.designSystem` (present only when a DESIGN.md loaded).
pub design_system: Option<&'a dyn DesignSystemHook>,
/// The regex engine's text-content analyzers; `None` skips them.
pub text_content_analyzers: Option<TextContentAnalyzers<'a>>,
/// JS `options.profile`.
pub profile: Option<&'a dyn ProfileSink>,
/// Sink for the JS `process.stderr.write` notices (unreadable linked
/// stylesheets); `None` drops them.
pub warn: Option<&'a dyn Fn(&str)>,
/// A rule pack's static-document hook: rules over the parsed page.
pub static_rule_pack: Option<&'static dyn StaticRulePack>,
/// The same pack's engine-wide text hook. An HTML file gets **one** pack
/// pass: `static_rule_pack` when it is set, otherwise this one over the
/// raw HTML source (which is how a text-only pack still covers `.html`
/// files, the way the built-in text-content analyzers do). A pack that
/// implements both therefore never reports twice for the same file.
pub rule_pack: Option<&'static dyn impeccable_core::rule_pack::RulePack>,
}
/// Errors of the static engine.
#[derive(Debug, thiserror::Error)]
pub enum HtmlEngineError {
#[error("cannot read {path}: {source}")]
Read {
path: String,
#[source]
source: std::io::Error,
},
}
/// The per-element rules of `STATIC_ELEMENT_RULES`, in table order.
const STATIC_ELEMENT_RULES: &[(&str, &str)] = &[
("border-rules", "*"),
("color-rules", "*"),
("hover-color-rules", "*"),
("dark-glow", "*"),
("motion-rules", "*"),
("icon-tile-stack", "h1,h2,h3,h4,h5,h6"),
("italic-serif-display", "h1,h2"),
("hero-eyebrow-chip", "h1"),
("broken-image", "img"),
("quality-rules", "*"),
("oversized-h1", "h1"),
("clipped-overflow-container", "*"),
("gpt-thin-border-wide-shadow", "*"),
("radial-spotlight-glow", "*"),
];
fn run_rule(rule_id: &str, el: &StaticElement<'_>, tag: &str) -> Vec<RuleHit> {
let style = el.style();
match rule_id {
"border-rules" => {
let radius = resolve_border_radius_px(style, pf0(sv(style, "width")));
check_element_borders(tag, style, radius, el)
}
"color-rules" => check_element_colors(el, style, tag, None),
"hover-color-rules" => check_element_hover_contrast(el, style, tag),
"dark-glow" => {
let base = el.parent_element().unwrap_or(*el);
check_element_glow(style, resolve_background(&base, None))
}
"motion-rules" => check_element_motion(tag, style),
"icon-tile-stack" => check_element_icon_tile(el, tag),
"italic-serif-display" => check_element_italic_serif(el, style, tag),
"hero-eyebrow-chip" => check_element_hero_eyebrow(el, style, tag),
"broken-image" => check_element_broken_image(el),
"quality-rules" => check_element_quality(el, style, tag),
"oversized-h1" => check_element_oversized_h1(el, tag),
"clipped-overflow-container" => check_element_clipped_overflow(el, style),
"gpt-thin-border-wide-shadow" => check_element_gpt_border_shadow(style),
"radial-spotlight-glow" => check_element_radial_spotlight(el, style),
_ => Vec::new(),
}
}
static PSEUDO_STRIP_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"::?[a-zA-Z-]+(\([^)]*\))?").expect("PSEUDO_STRIP_RE"));
fn read_source(file_path: &Path) -> Result<String, HtmlEngineError> {
let bytes = std::fs::read(file_path).map_err(|source| HtmlEngineError::Read {
path: file_path.to_string_lossy().into_owned(),
source,
})?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
/// JS `detectHtml(filePath, options)`: read the file and scan it.
pub fn detect_html(
file_path: &Path,
options: &DetectHtmlOptions<'_>,
) -> Result<Vec<Finding>, HtmlEngineError> {
let file_str = file_path.to_string_lossy().into_owned();
let html = profile::step(
options.profile,
Meta::new("setup", "read-html", &file_str),
|| read_source(file_path),
)?;
Ok(detect_html_source(&html, file_path, options))
}
/// `detectHtml` for HTML already in memory. `file_path` names the file in
/// findings and resolves linked stylesheets (its parent directory).
pub fn detect_html_source(
html: &str,
file_path: &Path,
options: &DetectHtmlOptions<'_>,
) -> Vec<Finding> {
let profile = options.profile;
let file_str = file_path.to_string_lossy().into_owned();
let fp = file_str.as_str();
// JS loads htmlparser2 / css-select / css-tree / domutils here (and falls
// back to the regex engine with a DEGRADED notice when they are missing);
// the port links them in, so the step is only kept for the profile shape.
profile::step(
profile,
Meta::new("setup", "import-static-parser", fp),
|| (),
);
// JS `path.dirname(path.resolve(filePath))`.
let resolved = if file_path.is_absolute() {
file_path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(file_path))
.unwrap_or_else(|_| file_path.to_path_buf())
};
let file_dir = resolved
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| resolved.clone());
let mut doc = profile::step(
profile,
Meta::new("parse-html", "parse-document", fp),
|| StaticDocument::parse(html),
);
let css_text = collect_static_css_text(&doc, &file_dir, profile, fp, options.warn);
build_static_style_map(&mut doc, css_text.as_str(), profile, fp);
let doc = doc;
let mut findings: Vec<Finding> = Vec::new();
let mk = |id: &str, snippet: &str| try_finding(id, fp, snippet, 0.0);
for (rule_id, selector) in STATIC_ELEMENT_RULES {
let elements = doc.query_selector_all(selector);
for el in &elements {
let tag = el.tag_lower();
let hits = profile::findings(
profile,
Meta::new("element", rule_id, fp),
|h: &RuleHit| h.id.as_str(),
|| run_rule(rule_id, el, &tag),
);
for h in hits {
if scoped_ignore_active(el, &h.id) {
continue;
}
if let Some(f) = mk(&h.id, &h.snippet) {
findings.push(f);
}
}
}
}
if let Some(ds) = options.design_system {
let source_design = profile::findings(
profile,
Meta::new("source", "design-system", fp),
|f: &Finding| f.antipattern.as_str(),
|| ds.check_source(html, fp),
);
let static_design = profile::findings(
profile,
Meta::new("page", "design-system", fp),
|f: &Finding| f.antipattern.as_str(),
|| ds.collect_static(&doc, fp),
);
findings.extend(ds.merge(static_design, source_design));
}
if is_full_page(html) {
let page = |rule_id: &str, f: &dyn Fn() -> Vec<RuleHit>| -> Vec<RuleHit> {
profile::findings(
profile,
Meta::new("page", rule_id, fp),
|h: &RuleHit| h.id.as_str(),
f,
)
};
let mut push_hits = |hits: Vec<RuleHit>| {
for h in hits {
if let Some(f) = mk(&h.id, &h.snippet) {
findings.push(f);
}
}
};
push_hits(page("typography-rules", &|| {
check_static_page_typography(&doc)
}));
push_hits(page("kicker-above-heading", &|| {
check_kicker_above_heading_from_doc(&doc)
}));
push_hits(page("numbered-section-labels", &|| {
check_numbered_section_labels_from_doc(&doc)
}));
push_hits(page("repeated-container-text", &|| {
check_repeated_container_text_from_doc(&doc)
}));
push_hits(page("layout-rules", &|| check_page_layout(&doc)));
push_hits(page("cream-palette", &|| check_cream_palette(&doc)));
push_hits(page("skipped-heading", &|| {
check_page_quality_from_doc(&doc)
}));
// Scoped corpora for the pattern checks: cssText already carries the
// <style> blocks and linked local stylesheets; style/class attributes
// come from the parsed document.
let mut style_attr_parts: Vec<String> = Vec::new();
let mut class_attr_parts: Vec<String> = Vec::new();
for el in doc.query_selector_all("*") {
if let Some(s) = el.get_attribute("style").filter(|s| !s.is_empty()) {
style_attr_parts.push(format!("style=\"{}\"", s));
}
if let Some(c) = el.get_attribute("class").filter(|c| !c.is_empty()) {
class_attr_parts.push(c.to_string());
}
}
let mut style_parts = vec![css_text.clone()];
style_parts.extend(style_attr_parts);
let corpora = HtmlPatternCorpora {
style_text: style_parts.join("\n"),
class_text: class_attr_parts.join("\n"),
};
let pattern_hits = profile::findings(
profile,
Meta::new("page", "html-patterns", fp),
|f: &impeccable_core::checks::css_scan::PatternFinding| f.id.as_str(),
|| {
check_html_patterns(html, Some(&corpora))
.into_iter()
.filter(|item| item.id != "bounce-easing" && item.id != "layout-transition")
.collect()
},
);
for f in pattern_hits {
if let Some(selector) = f.selector.as_deref() {
let stripped = PSEUDO_STRIP_RE.replace_all(selector, "");
let stripped = impeccable_core::js::trim(&stripped);
let matches = match doc.compile(stripped) {
Ok(_) => Some(doc.query_selector_all(stripped)),
Err(_) => None,
};
if let Some(matches) = matches {
if !matches.is_empty()
&& matches.iter().all(|el| scoped_ignore_active(el, &f.id))
{
continue;
}
}
}
if let Some(mut item) = mk(&f.id, &f.snippet) {
if let Some(sev) = f.severity.as_ref() {
item.severity = sev.clone();
}
impeccable_core::findings::derive_advisory_flag(&mut item);
findings.push(item);
}
}
if let Some(analyzers) = options.text_content_analyzers {
let text_findings = profile::findings(
profile,
Meta::new("page", "text-content", fp),
|f: &Finding| f.antipattern.as_str(),
|| analyzers(html, fp),
);
for f in text_findings {
if let Some(item) = mk(&f.antipattern, &f.snippet) {
findings.push(item);
}
}
}
}
// A rule pack sees the page after every built-in pass (element rules, the
// design-system merge, the page-level checks) and before inline ignores:
// appending keeps built-in output byte-identical when no pack is
// installed, and pack findings are waivable like built-in ones.
if let Some(pack) = options.static_rule_pack {
let pack_findings = profile::findings(
profile,
Meta::new("page", "rule-pack", fp),
|f: &Finding| f.antipattern.as_str(),
|| pack.check_document(&doc, fp),
);
findings.extend(pack_findings);
} else if let Some(pack) = options.rule_pack {
let ext = impeccable_detect::detect_text::ext_from_file_path(fp);
let pack_findings = profile::findings(
profile,
Meta::new("source", "rule-pack", fp),
|f: &Finding| f.antipattern.as_str(),
|| pack.check_text(html, fp, &ext),
);
findings.extend(pack_findings);
}
if options.inline_ignores_disabled {
findings
} else {
apply_inline_ignores(findings, Some(html))
}
}
/// The selectors css-select would refuse that a scan of `html` hits (for the
/// parity report; not part of the JS API).
pub fn unsupported_selectors(html: &str, file_path: &Path) -> Vec<String> {
let file_str = file_path.to_string_lossy().into_owned();
let file_dir = file_path
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_default();
let mut doc = StaticDocument::parse(html);
let css_text = collect_static_css_text(&doc, &file_dir, None, &file_str, None);
build_static_style_map(&mut doc, &css_text, None, &file_str);
doc.unsupported_selectors()
}
+22
View File
@@ -0,0 +1,22 @@
//! impeccable-html: the static HTML engine (DOM model, CSS cascade, adapters)
//! ported from `cli/engine/engines/static-html/` with byte-for-byte
//! behavioral parity against the JS goldens.
//!
//! Entry points: [`engine::detect_html`] / [`engine::detect_html_source`].
pub mod adapters;
pub mod background;
pub mod cascade;
pub mod dom;
pub mod engine;
pub mod page;
pub mod profile;
pub mod quality;
pub mod select;
pub mod static_engine;
pub use engine::{
detect_html, detect_html_source, DesignSystemHook, DetectHtmlOptions, HtmlEngineError,
StaticRulePack, TextContentAnalyzers,
};
pub use static_engine::{DetectDesignSystemHook, DetectorProfileSink, StaticHtmlEngine};
+422
View File
@@ -0,0 +1,422 @@
//! Page-level checks: `checkStaticPageTypography` (detect-html.mjs) and the
//! Section 6 document walks from `checks.mjs` (`isCardLike`,
//! `checkPageLayout`, `collectRepeatedContainerTextFindings`,
//! `checkRepeatedContainerTextFromDoc`, `checkCreamPalette`).
use crate::adapters::{class_selector, StyleRef};
use crate::background::{read_own_background_color, resolve_border_radius_px, sv};
use crate::dom::{StaticDocument, StaticElement};
use crate::quality::{has_nonblank_direct_text, pf0};
use impeccable_core::checks::measures::{cream_from_class_list, is_cream_color};
use impeccable_core::checks::rules::{
check_flat_type_hierarchy_samples, is_card_like_from_props, type_hierarchy_role, RuleHit,
TypeSample, TYPE_HIERARCHY_SELECTOR,
};
use impeccable_core::checks::text_rules::{
is_repeated_text_container, REPEATED_TEXT_CONTAINER_TAGS, REPEATED_TEXT_SKIP_SELECTOR,
};
use impeccable_core::constants::{CSS_GENERIC_FONTS, OVERUSED_FONTS, SAFE_TAGS};
use impeccable_core::js::{self, number_to_string, parse_float};
use impeccable_core::js_ext_b::{slice_utf16_prefix, utf16_len};
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
static WS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{}+", js::WS)).expect("WS_RE"));
/// `f.trim().replace(/^['"]|['"]$/g, '').toLowerCase()`
fn font_token(f: &str) -> String {
let t = js::trim(f);
let t = t.strip_prefix(['\'', '"']).unwrap_or(t);
let t = t.strip_suffix(['\'', '"']).unwrap_or(t);
js::to_lower_case(t)
}
/// JS: detect-html.mjs#checkStaticPageTypography(document, window)
pub fn check_static_page_typography(doc: &StaticDocument) -> Vec<RuleHit> {
let mut findings = Vec::new();
let mut overused_found: Vec<String> = Vec::new();
for el in doc.query_selector_all(
"p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div",
) {
if !has_nonblank_direct_text(&el) {
continue;
}
let ff = sv(el.style(), "fontFamily");
// JS-PARITY: detect-html.mjs#checkStaticPageTypography uses
// primaryFontFace(ff) whose default skip is CSS_GENERIC_FONTS, so a
// system stack keeps its system face as primary (fix #678).
let primary = ff
.split(',')
.map(font_token)
.find(|f| !f.is_empty() && !CSS_GENERIC_FONTS.contains(&f.as_str()));
let Some(primary) = primary else {
continue;
};
if OVERUSED_FONTS.contains(&primary.as_str()) && !overused_found.contains(&primary) {
overused_found.push(primary);
}
}
for font in &overused_found {
findings.push(RuleHit::new(
"overused-font",
format!("Primary font: {}", font),
));
}
findings.extend(check_flat_type_hierarchy_from_doc(doc));
findings
}
/// JS: checks.mjs#isRenderedTypeElement over the static cascade.
///
/// JS-PARITY: jsdom's `el.hidden` reflects the `hidden` attribute, which the
/// attribute test already covers. `contentVisibility` only ever reads its
/// `STATIC_DEFAULT_STYLE` default here: css-cascade.mjs#STATIC_PROP_MAP has no
/// `content-visibility` entry, so a declared `content-visibility: hidden`
/// never reaches the static computed style.
fn is_rendered_type_element(el: &StaticElement<'_>) -> bool {
let mut current = Some(el.clone());
while let Some(node) = current {
if node.get_attribute("hidden").is_some() {
return false;
}
let style = node.style();
let display = js::to_lower_case(sv(style, "display"));
let visibility = js::to_lower_case(sv(style, "visibility"));
let content_visibility = js::to_lower_case(sv(style, "contentVisibility"));
if display == "none"
|| visibility == "hidden"
|| visibility == "collapse"
|| content_visibility == "hidden"
{
return false;
}
let opacity = parse_float(sv(style, "opacity"));
if opacity.is_finite() && opacity <= 0.01 {
return false;
}
current = node.parent_element();
}
true
}
/// JS: checks.mjs#checkFlatTypeHierarchyFromDoc over the static document.
pub fn check_flat_type_hierarchy_from_doc(doc: &StaticDocument) -> Vec<RuleHit> {
let mut samples: Vec<TypeSample> = Vec::new();
for el in doc.query_selector_all(TYPE_HIERARCHY_SELECTOR) {
if js::trim(&el.text_content()).is_empty() || !is_rendered_type_element(&el) {
continue;
}
let font_size = parse_float(sv(el.style(), "fontSize"));
if !font_size.is_finite() || font_size < 8.0 || font_size >= 200.0 {
continue;
}
samples.push(TypeSample {
role: type_hierarchy_role(&el.tag_lower()),
size: font_size,
});
}
check_flat_type_hierarchy_samples(&samples)
}
// ─── Nested cards ───────────────────────────────────────────────────────────
static SHADOW_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?-u:\b)shadow(?:-sm|-md|-lg|-xl|-2xl)?(?-u:\b)").expect("SHADOW_CLASS_RE")
});
static BOX_SHADOW_RE: Lazy<Regex> =
Lazy::new(|| Regex::new("(?i)box-shadow").expect("BOX_SHADOW_RE"));
static BORDER_CLASS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?-u:\b)border(?-u:\b)").expect("BORDER_CLASS_RE"));
static ROUNDED_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?-u:\b)rounded(?:-sm|-md|-lg|-xl|-2xl|-full)?(?-u:\b)").expect("ROUNDED_CLASS_RE")
});
static BORDER_RADIUS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new("(?i)border-radius").expect("BORDER_RADIUS_RE"));
static BG_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?-u:\b)bg-(?:white|gray-[0-9]+|slate-[0-9]+)(?-u:\b)").expect("BG_CLASS_RE")
});
static BG_DECL_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)background(?:-color)?{ws}*:({ws}*)",
ws = js::WS
))
.expect("BG_DECL_RE")
});
static POSITIONED_CLASS_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?-u:\b)(?:absolute|fixed)(?-u:\b)").expect("POSITIONED_CLASS_RE"));
static POSITIONED_STYLE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)position{ws}*:{ws}*(?:absolute|fixed)",
ws = js::WS
))
.expect("POSITIONED_STYLE_RE")
});
static OVERLAY_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?i)(?-u:\b)(?:dropdown|popover|tooltip|menu|modal|dialog)(?-u:\b)")
.expect("OVERLAY_CLASS_RE")
});
/// JS `/background(?:-color)?\s*:\s*(?!transparent)/i.test(rawStyle)`. With
/// backtracking, `\s*` gives back whitespace until the lookahead sees a
/// space, so the test only fails when `transparent` follows the colon with
/// no whitespace at all.
fn bg_decl_not_transparent(raw_style: &str) -> bool {
for m in BG_DECL_RE.captures_iter(raw_style) {
let ws = m.get(1).map(|g| g.as_str()).unwrap_or("");
if !ws.is_empty() {
return true;
}
let rest = &raw_style[m.get(0).unwrap().end()..];
let head: String = rest.chars().take("transparent".len()).collect();
if !head.eq_ignore_ascii_case("transparent") {
return true;
}
}
false
}
/// JS: checks.mjs#isCardLike(el, win)
pub fn is_card_like(el: &StaticElement<'_>) -> bool {
let tag = el.tag_lower();
if SAFE_TAGS.contains(&tag.as_str())
|| matches!(
tag.as_str(),
"input" | "select" | "textarea" | "img" | "video" | "canvas" | "picture"
)
{
return false;
}
let style = el.style();
let raw_style = el.get_attribute("style").unwrap_or("");
let cls = el.get_attribute("class").unwrap_or("");
let box_shadow = sv(style, "boxShadow");
let has_shadow = (!box_shadow.is_empty() && box_shadow != "none")
|| SHADOW_CLASS_RE.is_match(cls)
|| BOX_SHADOW_RE.is_match(raw_style);
let has_border = BORDER_CLASS_RE.is_match(cls);
let width_px = pf0(sv(style, "width"));
let has_radius = resolve_border_radius_px(style, width_px) > 0.0
|| ROUNDED_CLASS_RE.is_match(cls)
|| BORDER_RADIUS_RE.is_match(raw_style);
let has_bg = BG_CLASS_RE.is_match(cls) || bg_decl_not_transparent(raw_style);
is_card_like_from_props(has_shadow, has_border, has_radius, has_bg)
}
/// JS: checks.mjs#checkPageLayout(doc, win)
pub fn check_page_layout(doc: &StaticDocument) -> Vec<RuleHit> {
let mut findings = Vec::new();
let all = doc.query_selector_all("*");
let mut flagged: Vec<StaticElement<'_>> = Vec::new();
for el in &all {
if !is_card_like(el) {
continue;
}
if flagged.contains(el) {
continue;
}
let tag = el.tag_lower();
let cls = el.get_attribute("class").unwrap_or("");
let raw_style = el.get_attribute("style").unwrap_or("");
if tag == "pre" || tag == "code" {
continue;
}
if POSITIONED_CLASS_RE.is_match(cls) || POSITIONED_STYLE_RE.is_match(raw_style) {
continue;
}
if utf16_len(js::trim(&el.text_content())) < 10 {
continue;
}
if OVERLAY_CLASS_RE.is_match(cls) {
continue;
}
let mut parent = el.parent_element();
while let Some(p) = parent {
if is_card_like(&p) {
flagged.push(*el);
break;
}
parent = p.parent_element();
}
}
for el in &flagged {
let is_ancestor_of_flagged = flagged
.iter()
.any(|other| other != el && el.contains(other));
if !is_ancestor_of_flagged {
findings.push(RuleHit::new(
"nested-cards",
format!("Card inside card ({})", el.tag_lower()),
));
}
}
findings
}
// ─── Repeated container text ────────────────────────────────────────────────
static ICON_CLASS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(
r"(?i)icon|material-symbols|(?:^|{ws})fa[srlbd]?(?:{ws}|-|$)",
ws = js::WS
))
.expect("ICON_CLASS_RE")
});
static ALPHA_RE: Lazy<Regex> = Lazy::new(|| Regex::new("[a-zA-Z]").expect("ALPHA_RE"));
fn is_visible(el: &StaticElement<'_>) -> bool {
sv(el.style(), "display") != "none"
}
/// JS: checks.mjs#collectRepeatedContainerTextFindings(doc, getStyle, opts)
/// with `isVisible = display !== 'none'` (`checkRepeatedContainerTextFromDoc`).
pub fn check_repeated_container_text_from_doc(doc: &StaticDocument) -> Vec<RuleHit> {
let mut findings = Vec::new();
let mut containers: Vec<StaticElement<'_>> = Vec::new();
let mut container_set: HashSet<ego_tree::NodeId> = HashSet::new();
for el in doc.query_selector_all("*") {
if !REPEATED_TEXT_CONTAINER_TAGS.contains(&el.tag_lower().as_str()) {
continue;
}
if el.closest(REPEATED_TEXT_SKIP_SELECTOR).is_some() {
continue;
}
if !is_repeated_text_container(Some(&StyleRef(el.style()))) {
continue;
}
containers.push(el);
container_set.insert(el.id());
}
for container in &containers {
if !is_visible(container) {
continue;
}
let descendants = container.query_selector_all("*");
if descendants.len() > 250 {
continue;
}
// text -> signatures, in first-seen order.
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
for d in &descendants {
let mut anc = d.parent_element();
let mut owned_by_inner = false;
while let Some(a) = anc {
if a == *container {
break;
}
if container_set.contains(&a.id()) {
owned_by_inner = true;
break;
}
anc = a.parent_element();
}
if owned_by_inner {
continue;
}
if d.closest(REPEATED_TEXT_SKIP_SELECTOR).is_some() {
continue;
}
if ICON_CLASS_RE.is_match(d.get_attribute("class").unwrap_or("")) {
continue;
}
if !is_visible(d) {
continue;
}
let direct = crate::adapters::clean_inline_text(d);
let len = utf16_len(&direct);
if !(4..=48).contains(&len) {
continue;
}
if !ALPHA_RE.is_match(&direct) {
continue;
}
let mut sig: Vec<String> = Vec::new();
let mut cur = Some(*d);
while let Some(c) = cur {
if c == *container {
break;
}
let raw_cls = js::trim(c.get_attribute("class").unwrap_or(""));
let mut cls: Vec<&str> = if raw_cls.is_empty() {
Vec::new()
} else {
WS_RE.split(raw_cls).filter(|s| !s.is_empty()).collect()
};
cls.sort_by(|a, b| a.encode_utf16().cmp(b.encode_utf16()));
let cls = cls.join(".");
sig.push(if cls.is_empty() {
c.tag_lower()
} else {
format!("{}.{}", c.tag_lower(), cls)
});
cur = c.parent_element();
}
let joined = sig.join(">");
match groups.iter_mut().find(|(t, _)| *t == direct) {
Some((_, sigs)) => sigs.push(joined),
None => groups.push((direct, vec![joined])),
}
}
for (text, sigs) in &groups {
if sigs.len() < 3 {
continue;
}
let distinct: HashSet<&String> = sigs.iter().collect();
if distinct.len() < 3 {
continue;
}
findings.push(RuleHit::new(
"repeated-container-text",
format!(
"\"{}\" rendered {}× in distinct spots inside {}",
slice_utf16_prefix(text, 40),
sigs.len(),
class_selector(container)
),
));
}
}
findings
}
// ─── Cream palette ──────────────────────────────────────────────────────────
/// JS: checks.mjs#checkCreamPalette(doc, win)
pub fn check_cream_palette(doc: &StaticDocument) -> Vec<RuleHit> {
let mut findings = Vec::new();
let Some(body) = doc.body() else {
return findings;
};
let html = doc.document_element();
let mut bg = read_own_background_color(&body, body.style());
if bg.is_none() || bg.is_some_and(|c| c.a == Some(0.0)) {
if let Some(h) = html.as_ref() {
bg = read_own_background_color(h, h.style());
}
}
if is_cream_color(bg.as_ref()) {
let c = bg.unwrap();
findings.push(RuleHit::new(
"cream-palette",
format!(
"cream/beige page background rgb({}, {}, {})",
number_to_string(c.r),
number_to_string(c.g),
number_to_string(c.b)
),
));
return findings;
}
for el in [Some(body), html] {
let cls = el.and_then(|e| e.get_attribute("class")).unwrap_or("");
if let Some(tok) = cream_from_class_list(Some(cls)) {
findings.push(RuleHit::new(
"cream-palette",
format!("cream/beige page background (Tailwind {})", tok),
));
break;
}
}
findings
}
+130
View File
@@ -0,0 +1,130 @@
//! Port of the profiler hooks the static engine emits
//! (`cli/engine/profile/profiler.mjs`: `profileStep`, `profileFindings`,
//! `recordProfileEvent`). The engine records events into any
//! [`ProfileSink`] the caller passes; without one nothing is measured.
use std::time::Instant;
/// One normalized profile event (`recordProfileEvent`).
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileEvent {
pub engine: String,
pub phase: String,
pub rule_id: String,
pub target: String,
pub ms: f64,
pub findings: usize,
/// Present only when the caller supplied one.
pub detail: Option<String>,
/// De-duplicated finding ids of a `profileFindings` step (empty when none).
pub finding_ids: Vec<String>,
}
/// Where profile events go. `profile.record(event)` in JS.
pub trait ProfileSink {
fn record(&self, event: ProfileEvent);
}
impl<F: Fn(ProfileEvent)> ProfileSink for F {
fn record(&self, event: ProfileEvent) {
self(event)
}
}
/// A `Vec`-backed sink (`{ events: [] }`).
#[derive(Default)]
pub struct VecSink(pub std::cell::RefCell<Vec<ProfileEvent>>);
impl ProfileSink for VecSink {
fn record(&self, event: ProfileEvent) {
self.0.borrow_mut().push(event);
}
}
/// The identifying part of an event.
#[derive(Debug, Clone)]
pub struct Meta<'a> {
pub engine: &'a str,
pub phase: &'a str,
pub rule_id: &'a str,
pub target: &'a str,
pub detail: Option<&'a str>,
}
impl<'a> Meta<'a> {
pub fn new(phase: &'a str, rule_id: &'a str, target: &'a str) -> Self {
Meta {
engine: "static-html",
phase,
rule_id,
target,
detail: None,
}
}
pub fn with_detail(mut self, detail: &'a str) -> Self {
self.detail = Some(detail);
self
}
fn event(&self, ms: f64, findings: usize, finding_ids: Vec<String>) -> ProfileEvent {
ProfileEvent {
engine: self.engine.to_string(),
phase: self.phase.to_string(),
rule_id: self.rule_id.to_string(),
target: self.target.to_string(),
ms,
findings,
detail: self.detail.map(|d| d.to_string()),
finding_ids,
}
}
}
fn elapsed_ms(start: Instant) -> f64 {
start.elapsed().as_secs_f64() * 1000.0
}
/// JS `profileStep(profile, meta, callback)`.
pub fn step<T>(profile: Option<&dyn ProfileSink>, meta: Meta<'_>, f: impl FnOnce() -> T) -> T {
match profile {
None => f(),
Some(sink) => {
let started = Instant::now();
let out = f();
sink.record(meta.event(elapsed_ms(started), 0, Vec::new()));
out
}
}
}
/// JS `profileFindings(profile, meta, callback)` for callbacks that return
/// a list of findings with an id (`extractFindingIds`).
pub fn findings<T>(
profile: Option<&dyn ProfileSink>,
meta: Meta<'_>,
id_of: impl Fn(&T) -> &str,
f: impl FnOnce() -> Vec<T>,
) -> Vec<T> {
match profile {
None => f(),
Some(sink) => {
let started = Instant::now();
let out = f();
let mut ids: Vec<String> = Vec::new();
for item in &out {
let id = id_of(item);
if !id.is_empty() && !ids.iter().any(|i| i == id) {
ids.push(id.to_string());
}
}
sink.record(meta.event(elapsed_ms(started), out.len(), ids));
out
}
}
}
/// JS `recordProfileEvent(profile, { ...meta, ms: 0, findings: 0 })`.
pub fn record(profile: Option<&dyn ProfileSink>, meta: Meta<'_>) {
if let Some(sink) = profile {
sink.record(meta.event(0.0, 0, Vec::new()));
}
}
+573
View File
@@ -0,0 +1,573 @@
//! `checkQuality` and its static-DOM helpers from `checks.mjs` Section 5
//! (`resolveFontSizePx`, `hasVisibleBackgroundBoundary`, `isVisuallyHidden`,
//! `isNonRenderedText`, `checkElementQuality`, `checkPageQualityFromDoc`).
//! Only the branches reachable with `rect: null` (the static adapter) are
//! ported; the browser-only rules (line-length, the rect-gated
//! cramped-padding, body-text-viewport-edge) never fire here.
use crate::background::{sv, sv_opt};
use crate::cascade::StyleValues;
use crate::dom::{ChildNode, StaticElement};
use impeccable_core::checks::measures::{
colors_nearly_match, css_color_is_transparent, resolve_length_px,
};
use impeccable_core::checks::rules::RuleHit;
use impeccable_core::checks::text_rules::{NON_RENDERED_TAGS, SR_ONLY_SELECTOR};
use impeccable_core::js::{self, number_to_string, parse_float, to_fixed};
use impeccable_core::js_ext_a::num_truthy;
use impeccable_core::js_ext_b::{slice_utf16_prefix, utf16_len};
use once_cell::sync::Lazy;
use regex::Regex;
static WS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{}+", js::WS)).expect("WS_RE"));
// JS `/url\(/i` in checkQuality's buried-raster branch.
static RASTER_URL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"{}\(", impeccable_core::js::ci("url"))).expect("RASTER_URL_RE"));
static CLIP_RECT_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(&format!(r"rect\({}*0", js::WS)).expect("CLIP_RECT_RE"));
static CLIP_INSET_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(&format!(r"inset\({}*(?:50%|99|100%)", js::WS)).expect("CLIP_INSET_RE")
});
/// JS `s.replace(/\s+/g, ' ')`.
pub fn collapse_ws(s: &str) -> String {
WS_RE.replace_all(s, " ").into_owned()
}
/// JS `parseFloat(x) || 0`.
pub fn pf0(s: &str) -> f64 {
let n = parse_float(s);
if num_truthy(n) {
n
} else {
0.0
}
}
/// JS: checks.mjs#resolveFontSizePx(el, win)
pub fn resolve_font_size_px(el: &StaticElement<'_>) -> f64 {
let mut chain: Vec<String> = Vec::new();
let mut cur = Some(*el);
while let Some(e) = cur {
chain.push(sv(e.style(), "fontSize").to_string());
cur = e.parent_element();
}
let mut px = 16.0;
for v in chain.iter().rev() {
if v.is_empty() || v == "inherit" {
continue;
}
let num = parse_float(v);
if num.is_nan() {
continue;
}
if v.ends_with("px") {
px = num;
} else if v.ends_with("rem") {
px = num * 16.0;
} else if v.ends_with("em") {
px = num * px;
} else if v.ends_with('%') {
px = (num / 100.0) * px;
} else {
px = num;
}
}
px
}
/// JS: checks.mjs#hasVisibleBackgroundBoundary(style, el, win)
pub fn has_visible_background_boundary(style: &StyleValues, el: &StaticElement<'_>) -> bool {
let bg = sv(style, "backgroundColor");
if css_color_is_transparent(Some(bg)) {
return false;
}
let mut parent = el.parent_element();
while let Some(p) = parent {
let parent_bg = sv(p.style(), "backgroundColor");
if !css_color_is_transparent(Some(parent_bg)) {
return !colors_nearly_match(Some(bg), Some(parent_bg));
}
parent = p.parent_element();
}
true
}
/// JS: checks.mjs#isVisuallyHidden(el, style)
pub fn is_visually_hidden(el: &StaticElement<'_>, style: &StyleValues) -> bool {
// StaticElement has no `matches`; `closest` covers the element itself.
if el.closest(SR_ONLY_SELECTOR).is_some() {
return true;
}
let pos = sv(style, "position");
if pos == "absolute" || pos == "fixed" {
let clip = sv(style, "clip");
let clip_path = {
let a = sv(style, "clipPath");
if !a.is_empty() {
a
} else {
let b = sv(style, "webkitClipPath");
if !b.is_empty() {
b
} else {
sv(style, "clip-path")
}
}
};
if CLIP_RECT_RE.is_match(clip) || CLIP_INSET_RE.is_match(clip_path) {
return true;
}
let w = parse_float(sv(style, "width"));
let h = parse_float(sv(style, "height"));
let overflow = sv(style, "overflow");
if (w == 1.0 || h == 1.0) && (overflow == "hidden" || overflow == "clip") {
return true;
}
}
false
}
/// JS: checks.mjs#isNonRenderedText(el, tag, style)
pub fn is_non_rendered_text(
el: &StaticElement<'_>,
tag: &str,
style: Option<&StyleValues>,
) -> bool {
let t = js::to_lower_case(tag);
if NON_RENDERED_TAGS.contains(&t.as_str()) {
return true;
}
if el.closest("head").is_some() {
return true;
}
if let Some(style) = style {
if sv_opt(style, "display") == Some("none") {
return true;
}
let vis = sv_opt(style, "visibility");
if vis == Some("hidden") || vis == Some("collapse") {
return true;
}
}
false
}
/// Inputs of `checkQuality` as the static adapter builds them.
pub struct QualityInput<'a, 'b> {
pub el: &'b StaticElement<'a>,
pub tag: &'b str,
pub style: &'a StyleValues,
pub has_direct_text: bool,
pub text_len: usize,
pub font_size: f64,
pub line_height_px: Option<f64>,
pub letter_spacing_px: Option<f64>,
}
const FLUSH_SKIP_TAGS: &[&str] = &[
"HTML", "BODY", "MAIN", "HEADER", "FOOTER", "NAV", "ARTICLE", "ASIDE", "BUTTON", "A", "LABEL",
"SUMMARY", "CODE", "PRE", "INPUT", "TEXTAREA", "SELECT", "FORM", "FIGURE", "TABLE", "TBODY",
"THEAD", "TR", "TD", "TH",
];
const TINY_TEXT_UI_CONTEXT: &str = "button, a, label, summary, pre, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"option\"], nav, footer, [aria-hidden=\"true\"], [class*=\"badge\" i], [class*=\"caption\" i], [class*=\"chip\" i], [class*=\"code\" i], [class*=\"console\" i], [class*=\"diff\" i], [class*=\"label\" i], [class*=\"meta\" i], [class*=\"mock\" i], [class*=\"pill\" i], [class*=\"preview\" i], [class*=\"tag\" i], [class*=\"terminal\" i], [class*=\"writes\" i]";
const EXEMPT_CONTEXT: &str = "pre, code, kbd, samp, var, svg, [aria-hidden=\"true\"], [class*=\"terminal\" i], [class*=\"console\" i], [class*=\"code\" i], [class*=\"mock\" i], [class*=\"editor\" i], [class*=\"syntax\" i], [class*=\"diff\" i]";
const INTERACTIVE: &str = "a[href], button, summary, label, select, textarea, [role=\"button\"], [role=\"link\"], [role=\"tab\"], [role=\"menuitem\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"], [role=\"option\"], [role=\"checkbox\"], [role=\"radio\"], [role=\"switch\"], [role=\"treeitem\"], [tabindex]";
const FURNITURE: &str = "nav, [role=\"navigation\"], td, th, [role=\"gridcell\"], [role=\"cell\"], caption, figcaption, dt, dd, footer, [class*=\"meta\" i], [class*=\"label\" i], [class*=\"badge\" i], [class*=\"chip\" i], [class*=\"pill\" i], [class*=\"tag\" i], [class*=\"kicker\" i], [class*=\"eyebrow\" i], [class*=\"breadcrumb\" i], [class*=\"timestamp\" i], [class*=\"category\" i], [class*=\"caption\" i], [class*=\"nav\" i]";
const SMALLPRINT: &str = "small, footer, [class*=\"legal\" i], [class*=\"copyright\" i], [class*=\"fineprint\" i], [class*=\"fine-print\" i], [class*=\"smallprint\" i], [class*=\"small-print\" i], [class*=\"disclaimer\" i], [class*=\"disclosure\" i], [class*=\"footnote\" i]";
fn side_len(style: &StyleValues, key: &str, font_size: f64) -> f64 {
resolve_length_px(sv_opt(style, key), font_size).unwrap_or(0.0)
}
/// JS: checks.mjs#checkQuality(opts), static (`rect: null`) branches.
pub fn check_quality(q: &QualityInput<'_, '_>) -> Vec<RuleHit> {
let el = q.el;
let tag = q.tag;
let style = q.style;
let font_size = q.font_size;
let text_len = q.text_len;
let mut findings: Vec<RuleHit> = Vec::new();
let el_id = el.id_attr();
if el_id.starts_with("claude-") || el_id.starts_with("cic-") {
return findings;
}
// A raster (<img>, or an element with a background url) at near-zero
// opacity never reaches the screen: the produced material ships as a
// compliance token. The CSS-text scan catches the stylesheet form; this
// catches computed opacity on the element itself (both engines).
{
let op = parse_float(sv(style, "opacity"));
if op.is_finite() && op < 0.15 && op >= 0.0 {
let bg = sv(style, "backgroundImage");
if tag == "img" || RASTER_URL_RE.is_match(bg) {
let label = if tag == "img" {
el.get_attribute("alt").unwrap_or("").to_string()
} else {
slice_utf16_prefix(js::trim(&el.text_content()), 40)
};
findings.push(RuleHit::new(
"buried-raster",
format!(
"{} at opacity {}{}",
if tag == "img" { "<img>" } else { "raster background" },
number_to_string(op),
if label.is_empty() {
String::new()
} else {
format!(" \"{label}\"")
}
),
));
}
}
}
// --- Line length / cramped padding (rect-gated): never fire statically.
// --- Flush against a visible boundary ---
{
let upper_tag = js::to_upper_case(tag);
let el_position = sv(style, "position");
let children = el.children();
if !FLUSH_SKIP_TAGS.contains(&upper_tag.as_str())
&& !q.has_direct_text
&& el_position != "fixed"
&& el_position != "absolute"
&& !children.is_empty()
{
let bw = |k: &str| pf0(sv(style, k));
let border_w = [
bw("borderTopWidth"),
bw("borderRightWidth"),
bw("borderBottomWidth"),
bw("borderLeftWidth"),
];
let bc = |k: &str| css_color_is_transparent(Some(sv(style, k)));
let border_visible = [
border_w[0] > 0.0 && !bc("borderTopColor"),
border_w[1] > 0.0 && !bc("borderRightColor"),
border_w[2] > 0.0 && !bc("borderBottomColor"),
border_w[3] > 0.0 && !bc("borderLeftColor"),
];
let outline_w = pf0(sv(style, "outlineWidth"));
let outline_style_val = sv(style, "outlineStyle");
let outline_color_val = sv(style, "outlineColor");
// `style.outline` is never set on a static style: the shorthand
// fallback branch is unreachable here.
let outline_visible = outline_w > 0.0
&& !css_color_is_transparent(Some(outline_color_val))
&& !outline_style_val.is_empty()
&& outline_style_val != "none";
let bg_visible = has_visible_background_boundary(style, el);
let any_visible = border_visible.iter().any(|b| *b) || outline_visible || bg_visible;
if any_visible {
let pad = [
side_len(style, "paddingTop", font_size),
side_len(style, "paddingRight", font_size),
side_len(style, "paddingBottom", font_size),
side_len(style, "paddingLeft", font_size),
];
const PAD_THRESHOLD: f64 = 2.0;
const CHILD_INSULATE_THRESHOLD: f64 = 4.0;
let mut children_insulate = [false; 4];
for child in &children {
let cs = child.style();
let child_pad = [
side_len(cs, "paddingTop", font_size),
side_len(cs, "paddingRight", font_size),
side_len(cs, "paddingBottom", font_size),
side_len(cs, "paddingLeft", font_size),
];
let child_margin = [
side_len(cs, "marginTop", font_size),
side_len(cs, "marginRight", font_size),
side_len(cs, "marginBottom", font_size),
side_len(cs, "marginLeft", font_size),
];
for s in 0..4 {
if child_pad[s] >= CHILD_INSULATE_THRESHOLD
|| child_margin[s] >= CHILD_INSULATE_THRESHOLD
{
children_insulate[s] = true;
}
}
}
let side_names = ["top", "right", "bottom", "left"];
let mut flush_sides: Vec<&str> = Vec::new();
for s in 0..4 {
let bg_bounds_side = bg_visible;
let side_bounded = border_visible[s] || outline_visible || bg_bounds_side;
if side_bounded && pad[s] <= PAD_THRESHOLD && !children_insulate[s] {
flush_sides.push(side_names[s]);
}
}
if !flush_sides.is_empty() {
let has_text_child = children
.iter()
.any(|c| utf16_len(js::trim(&c.text_content())) > 4);
if has_text_child {
let cls_all = js::trim(el.class_name());
let cls = if cls_all.is_empty() {
""
} else {
WS_RE.split(cls_all).next().unwrap_or("")
};
let mut boundary_parts: Vec<String> = Vec::new();
let border_sides_visible: Vec<&str> = (0..4)
.filter(|i| border_visible[*i])
.map(|i| side_names[i])
.collect();
if border_sides_visible.len() == 4 {
boundary_parts.push("border".to_string());
} else if !border_sides_visible.is_empty() {
boundary_parts
.push(format!("border-{}", border_sides_visible.join("/")));
}
if outline_visible {
boundary_parts.push("outline".to_string());
}
if bg_visible {
boundary_parts.push("bg".to_string());
}
let sides_label = if flush_sides.len() == 4 {
"all sides".to_string()
} else {
flush_sides.join("/")
};
let tag_lower = js::to_lower_case(tag);
let ident = if !cls.is_empty() {
format!("<{}> \"{}\"", tag_lower, cls)
} else {
format!("<{}>", tag_lower)
};
findings.push(RuleHit::new(
"cramped-padding",
format!(
"{}: children flush against {} on {} (no inset)",
ident,
boundary_parts.join("+"),
sides_label
),
));
}
}
}
}
}
let is_heading = matches!(tag, "h1" | "h2" | "h3" | "h4" | "h5" | "h6");
// --- Tight line height ---
if q.has_direct_text && text_len > 50 && !is_heading {
if let Some(lh) = q.line_height_px {
if font_size > 0.0 {
let ratio = lh / font_size;
if ratio > 0.0 && ratio < 1.3 {
findings.push(RuleHit::new(
"tight-leading",
format!("line-height {}x (need >=1.3)", to_fixed(ratio, 2)),
));
}
}
}
}
// --- Justified text (without hyphens) ---
if q.has_direct_text && sv_opt(style, "textAlign") == Some("justify") {
let hyphens = {
let a = sv(style, "hyphens");
if !a.is_empty() {
a
} else {
sv(style, "webkitHyphens")
}
};
if hyphens != "auto" {
findings.push(RuleHit::new(
"justified-text",
"text-align: justify without hyphens: auto".to_string(),
));
}
}
// --- Tiny body text ---
if q.has_direct_text && text_len > 20 && font_size < 12.0 {
let skip_tags = [
"sub",
"sup",
"code",
"kbd",
"samp",
"var",
"caption",
"figcaption",
];
let in_ui_context = el.closest(TINY_TEXT_UI_CONTEXT).is_some();
let is_uppercase = sv_opt(style, "textTransform") == Some("uppercase");
if !skip_tags.contains(&tag)
&& !in_ui_context
&& !is_uppercase
&& !is_non_rendered_text(el, tag, Some(style))
{
findings.push(RuleHit::new(
"tiny-text",
format!("{}px body text", number_to_string(font_size)),
));
}
}
// --- Undersized functional / UI text ---
{
let direct_text = js::trim(&collapse_ws(&el.direct_text())).to_string();
let dt_len = utf16_len(&direct_text);
let ui_skip_tags = ["sub", "sup", "option"];
if font_size > 0.0
&& font_size < 11.0
&& dt_len >= 2
&& !ui_skip_tags.contains(&tag)
&& !is_non_rendered_text(el, tag, Some(style))
{
let is_exempt_context = el.closest(EXEMPT_CONTEXT).is_some();
if !is_exempt_context && !is_visually_hidden(el, style) {
let is_interactive = el.closest(INTERACTIVE).is_some();
let is_furniture = el.closest(FURNITURE).is_some();
let is_smallprint = el.closest(SMALLPRINT).is_some();
let floor = if !is_interactive && is_smallprint {
10.0
} else {
11.0
};
if font_size < floor && (is_interactive || is_furniture || dt_len <= 20) {
let excerpt = slice_utf16_prefix(&direct_text, 40);
findings.push(RuleHit::new(
"undersized-ui-text",
format!(
"{}px functional text \"{}\" (below {}px floor)",
number_to_string(font_size),
excerpt,
number_to_string(floor)
),
));
}
}
}
}
// --- All-caps body text ---
if q.has_direct_text
&& text_len > 30
&& sv_opt(style, "textTransform") == Some("uppercase")
&& !is_heading
{
findings.push(RuleHit::new(
"all-caps-body",
format!(
"text-transform: uppercase on {} chars of body text",
text_len
),
));
}
// --- Wide letter spacing on body text ---
if q.has_direct_text && text_len > 20 && sv_opt(style, "textTransform") != Some("uppercase") {
if let Some(ls) = q.letter_spacing_px {
if ls > 0.0 && font_size > 0.0 {
let tracking_em = ls / font_size;
if tracking_em > 0.05 {
findings.push(RuleHit::new(
"wide-tracking",
format!(
"letter-spacing: {}em on body text",
to_fixed(tracking_em, 2)
),
));
}
}
}
}
// --- Crushed letter spacing ---
if q.has_direct_text && text_len > 20 && font_size > 0.0 {
if let Some(ls) = q.letter_spacing_px {
if ls < 0.0 {
let tracking_em = ls / font_size;
if tracking_em <= -0.05 {
let excerpt =
slice_utf16_prefix(&collapse_ws(js::trim(&el.text_content())), 40);
findings.push(RuleHit::new(
"extreme-negative-tracking",
format!(
"letter-spacing: {}em — \"{}\"",
to_fixed(tracking_em, 2),
excerpt
),
));
}
}
}
}
findings
}
/// JS: checks.mjs#checkElementQuality(el, style, tag, window)
pub fn check_element_quality(
el: &StaticElement<'_>,
style: &StyleValues,
tag: &str,
) -> Vec<RuleHit> {
let has_direct_text = el.has_direct_text_longer_than(10);
let text_len = utf16_len(js::trim(&el.text_content()));
let font_size = resolve_font_size_px(el);
let line_height_px = resolve_length_px(sv_opt(style, "lineHeight"), font_size);
let letter_spacing_px = resolve_length_px(sv_opt(style, "letterSpacing"), font_size);
check_quality(&QualityInput {
el,
tag,
style,
has_direct_text,
text_len,
font_size,
line_height_px,
letter_spacing_px,
})
}
/// JS: checks.mjs#checkPageQualityFromDoc(doc)
pub fn check_page_quality_from_doc(doc: &crate::dom::StaticDocument) -> Vec<RuleHit> {
let mut findings = Vec::new();
let mut prev_level: i64 = 0;
let mut prev_text = String::new();
for h in doc.query_selector_all("h1, h2, h3, h4, h5, h6") {
let tag = h.tag_upper();
let level = tag[1..2].parse::<i64>().unwrap_or(0);
let text = slice_utf16_prefix(&collapse_ws(js::trim(&h.text_content())), 60);
if prev_level > 0 && level > prev_level + 1 {
findings.push(RuleHit::new(
"skipped-heading",
format!(
"<h{}> \"{}\" followed by <h{}> \"{}\" (missing h{})",
prev_level,
prev_text,
level,
text,
prev_level + 1
),
));
}
prev_level = level;
prev_text = text;
}
findings
}
/// The `childNodes`-based `hasText` used by `checkStaticPageTypography`.
pub fn has_nonblank_direct_text(el: &StaticElement<'_>) -> bool {
el.child_nodes()
.iter()
.any(|c| matches!(c, ChildNode::Text(t) if !js::trim(t).is_empty()))
}
+577
View File
@@ -0,0 +1,577 @@
//! Selector engine for the static DOM: the `selectors` crate parser and
//! matcher over the html5ever tree, configured to observe css-select's
//! pseudo-class surface (what the JS engine sees through `css-select`):
//!
//! - tree-structural pseudos (`:root`, `:empty`, `:first-child`, `:nth-*`,
//! `:not`, `:is`/`:where`/`:matches`, `:has`, `:scope`) parse natively;
//! - `:hover` / `:active` / `:visited` parse and never match (css-select's
//! `dynamicStatePseudo` with no adapter hook is `falseFunc`);
//! - css-select's alias pseudos (`:link`, `:any-link`, `:disabled`,
//! `:enabled`, `:checked`, `:required`, `:optional`, `:read-only`,
//! `:read-write`, `:selected`, `:checkbox`, `:file`, `:password`,
//! `:radio`, `:reset`, `:image`, `:submit`, `:parent`, `:header`,
//! `:button`, `:input`, `:text`) match through the alias selector text;
//! - `:contains()` / `:icontains()` / `:lang()` are supported;
//! - anything else (`:focus`, `:target`, `::before`, `::placeholder`, ...)
//! is a parse error, exactly where css-select throws, so the callers skip
//! the rule the same way `try { selectAll } catch {}` does in JS.
//!
//! Selector parse errors are recorded by the callers (see
//! `StaticDocument::unsupported_selectors`) for the parity report.
use cssparser::{match_ignore_ascii_case, CowRcStr, Parser as CssParser, SourceLocation, ToCss};
use ego_tree::NodeRef;
use html5ever::Namespace;
use scraper::selector::{CssLocalName, CssString};
use scraper::Node;
use selectors::attr::{AttrSelectorOperation, CaseSensitivity, NamespaceConstraint};
use selectors::bloom::BloomFilter;
use selectors::context::MatchingContext;
use selectors::parser::{
self, NonTSPseudoClass as NonTSPseudoClassTrait, ParseRelative,
PseudoElement as PseudoElementTrait, SelectorList, SelectorParseErrorKind,
};
use selectors::{matching, Element, OpaqueElement};
use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt;
/// The `SelectorImpl` of the static engine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Impl;
impl parser::SelectorImpl for Impl {
type AttrValue = CssString;
type Identifier = CssLocalName;
type LocalName = CssLocalName;
type NamespacePrefix = CssLocalName;
type NamespaceUrl = Namespace;
type BorrowedNamespaceUrl = Namespace;
type BorrowedLocalName = CssLocalName;
type NonTSPseudoClass = PseudoClass;
type PseudoElement = PseudoElement;
type ExtraMatchingData<'a> = ();
}
/// css-select's `textControl` alias fragment.
const TEXT_CONTROL: &str = "input:is([type=text i],[type=search i],[type=url i],[type=tel i],[type=email i],[type=password i],[type=date i],[type=month i],[type=week i],[type=time i],[type=datetime-local i],[type=number i])";
/// css-select `aliases` (pseudo-selectors/aliases.js), name -> selector.
/// `:text`'s `[type!='']` (a css-what extension) is spelled as the
/// equivalent `[type=""]`.
fn alias_selector(name: &str) -> Option<String> {
let s = match name {
"any-link" => ":is(a, area, link)[href]".to_string(),
"link" => ":any-link:not(:visited)".to_string(),
"disabled" => ":is(:is(button, input, select, textarea, optgroup, option)[disabled], optgroup[disabled] > option, fieldset[disabled]:not(fieldset[disabled] legend:first-of-type *))".to_string(),
"enabled" => ":is(button, input, select, textarea, optgroup, option, fieldset):not(:disabled)".to_string(),
"checked" => ":is(:is(input[type=radio], input[type=checkbox])[checked], :selected)".to_string(),
"required" => ":is(input, select, textarea)[required]".to_string(),
"optional" => ":is(input, select, textarea):not([required])".to_string(),
"read-only" => format!("[readonly]:is(textarea, {TEXT_CONTROL})"),
"read-write" => format!(":not([readonly]):is(textarea, {TEXT_CONTROL})"),
"selected" => "option:is([selected], select:not([multiple]):not(:has(> option[selected])) > :first-of-type)".to_string(),
"checkbox" => "[type=checkbox]".to_string(),
"file" => "[type=file]".to_string(),
"password" => "[type=password]".to_string(),
"radio" => "[type=radio]".to_string(),
"reset" => "[type=reset]".to_string(),
"image" => "[type=image]".to_string(),
"submit" => "[type=submit]".to_string(),
"parent" => ":not(:empty)".to_string(),
"header" => ":is(h1, h2, h3, h4, h5, h6)".to_string(),
"button" => ":is(button, input[type=button])".to_string(),
"input" => ":is(input, textarea, select, button)".to_string(),
"text" => "input:is([type=\"\"], [type=text])".to_string(),
_ => return None,
};
Some(s)
}
/// Non-tree-structural pseudo-classes css-select understands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PseudoClass {
/// `:hover` / `:active` / `:visited`: parse, never match.
Dynamic(&'static str),
/// A css-select alias, matched through its selector text.
Alias(&'static str),
Contains(String),
IContains(String),
Lang(String),
}
impl NonTSPseudoClassTrait for PseudoClass {
type Impl = Impl;
fn is_active_or_hover(&self) -> bool {
matches!(
self,
PseudoClass::Dynamic("hover") | PseudoClass::Dynamic("active")
)
}
fn is_user_action_state(&self) -> bool {
matches!(self, PseudoClass::Dynamic(_))
}
}
impl ToCss for PseudoClass {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
match self {
PseudoClass::Dynamic(n) | PseudoClass::Alias(n) => write!(dest, ":{}", n),
PseudoClass::Contains(s) => write!(dest, ":contains({})", s),
PseudoClass::IContains(s) => write!(dest, ":icontains({})", s),
PseudoClass::Lang(s) => write!(dest, ":lang({})", s),
}
}
}
/// Pseudo-elements are never parsed (css-select throws on them).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PseudoElement {}
impl PseudoElementTrait for PseudoElement {
type Impl = Impl;
}
impl ToCss for PseudoElement {
fn to_css<W: fmt::Write>(&self, dest: &mut W) -> fmt::Result {
dest.write_str("")
}
}
const ALIAS_NAMES: &[&str] = &[
"any-link",
"link",
"disabled",
"enabled",
"checked",
"required",
"optional",
"read-only",
"read-write",
"selected",
"checkbox",
"file",
"password",
"radio",
"reset",
"image",
"submit",
"parent",
"header",
"button",
"input",
"text",
];
struct SelParser;
impl<'i> parser::Parser<'i> for SelParser {
type Impl = Impl;
type Error = SelectorParseErrorKind<'i>;
fn parse_is_and_where(&self) -> bool {
true
}
fn parse_has(&self) -> bool {
true
}
fn is_is_alias(&self, name: &str) -> bool {
name.eq_ignore_ascii_case("matches")
}
/// css-select throws on any invalid selector inside `:is()` / `:has()`.
fn allow_forgiving_selectors(&self) -> bool {
false
}
fn parse_non_ts_pseudo_class(
&self,
location: SourceLocation,
name: CowRcStr<'i>,
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
let lower = name.to_ascii_lowercase();
match lower.as_str() {
"hover" => return Ok(PseudoClass::Dynamic("hover")),
"active" => return Ok(PseudoClass::Dynamic("active")),
"visited" => return Ok(PseudoClass::Dynamic("visited")),
_ => {}
}
if let Some(n) = ALIAS_NAMES.iter().find(|n| **n == lower) {
return Ok(PseudoClass::Alias(n));
}
Err(
location.new_custom_error(SelectorParseErrorKind::UnsupportedPseudoClassOrElement(
name,
)),
)
}
fn parse_non_ts_functional_pseudo_class<'t>(
&self,
name: CowRcStr<'i>,
parser: &mut CssParser<'i, 't>,
_after_part: bool,
) -> Result<PseudoClass, cssparser::ParseError<'i, Self::Error>> {
match_ignore_ascii_case! { &name,
"contains" | "icontains" => {
let start = parser.position();
while parser.next_including_whitespace().is_ok() {}
let raw = parser.slice_from(start).trim();
// css-what strips one layer of matching quotes.
let text = if raw.len() >= 2
&& ((raw.starts_with('"') && raw.ends_with('"'))
|| (raw.starts_with('\'') && raw.ends_with('\'')))
{
raw[1..raw.len() - 1].to_string()
} else {
raw.to_string()
};
if name.eq_ignore_ascii_case("contains") {
return Ok(PseudoClass::Contains(text));
}
return Ok(PseudoClass::IContains(text.to_lowercase()));
},
"lang" => {
let lang = parser.expect_ident_or_string()?.as_ref().to_owned();
return Ok(PseudoClass::Lang(lang));
},
_ => {}
}
Err(
parser.new_custom_error(SelectorParseErrorKind::UnsupportedPseudoClassOrElement(
name,
)),
)
}
}
/// A parsed selector list.
#[derive(Debug, Clone)]
pub struct Selector {
list: SelectorList<Impl>,
}
/// A selector that css-select would refuse to compile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SelectorError(pub String);
impl fmt::Display for SelectorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "unsupported selector: {}", self.0)
}
}
impl std::error::Error for SelectorError {}
impl Selector {
/// Parse a selector group; `Err` where css-select would throw.
pub fn parse(text: &str) -> Result<Selector, SelectorError> {
let mut input = cssparser::ParserInput::new(text);
let mut p = CssParser::new(&mut input);
SelectorList::parse(&SelParser, &mut p, ParseRelative::No)
.map(|list| Selector { list })
.map_err(|_| SelectorError(text.to_string()))
}
/// Whether `el` matches any selector in the group.
pub fn matches(&self, el: &El<'_>) -> bool {
let mut caches = matching::SelectorCaches::default();
let mut ctx = MatchingContext::new(
matching::MatchingMode::Normal,
None,
&mut caches,
matching::QuirksMode::NoQuirks,
matching::NeedsSelectorFlags::No,
matching::MatchingForInvalidation::No,
);
matching::matches_selector_list(&self.list, el, &mut ctx)
}
}
thread_local! {
static ALIAS_CACHE: RefCell<HashMap<&'static str, Option<Selector>>> = RefCell::new(HashMap::new());
}
fn with_alias<R>(name: &'static str, f: impl FnOnce(Option<&Selector>) -> R) -> R {
ALIAS_CACHE.with(|cache| {
let mut cache = cache.borrow_mut();
let entry = cache
.entry(name)
.or_insert_with(|| alias_selector(name).and_then(|s| Selector::parse(&s).ok()));
// Clone out so a nested alias lookup can borrow the cache again.
let sel = entry.clone();
drop(cache);
f(sel.as_ref())
})
}
/// An element handle for selector matching: any element node of the tree.
#[derive(Clone, Copy)]
pub struct El<'a>(pub NodeRef<'a, Node>);
impl fmt::Debug for El<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "El({:?})", self.0.value())
}
}
impl<'a> El<'a> {
fn elem(&self) -> &'a scraper::node::Element {
// Only constructed for element nodes.
self.0
.value()
.as_element()
.expect("El wraps an element node")
}
/// domutils `getText`: text of all descendants (`<br>` reads as `\n`).
fn get_text(&self) -> String {
let mut s = String::new();
for n in self.0.descendants().skip(1) {
match n.value() {
Node::Text(t) => s.push_str(&t.text),
Node::Element(e) if e.name.local.as_ref() == "br" => s.push('\n'),
_ => {}
}
}
s
}
}
impl<'a> Element for El<'a> {
type Impl = Impl;
fn opaque(&self) -> OpaqueElement {
OpaqueElement::new(self.0.value())
}
fn parent_element(&self) -> Option<Self> {
self.0.parent().filter(|p| p.value().is_element()).map(El)
}
fn parent_node_is_shadow_root(&self) -> bool {
false
}
fn containing_shadow_host(&self) -> Option<Self> {
None
}
fn is_pseudo_element(&self) -> bool {
false
}
fn prev_sibling_element(&self) -> Option<Self> {
self.0
.prev_siblings()
.find(|s| s.value().is_element())
.map(El)
}
fn next_sibling_element(&self) -> Option<Self> {
self.0
.next_siblings()
.find(|s| s.value().is_element())
.map(El)
}
fn first_element_child(&self) -> Option<Self> {
self.0.children().find(|c| c.value().is_element()).map(El)
}
/// Always true: css-select over htmlparser2 has one namespace and
/// lower-cased tag names, so type and attribute selectors compare in
/// lower case for SVG content too.
fn is_html_element_in_html_document(&self) -> bool {
true
}
/// ASCII case-insensitive: htmlparser2 lower-cases every tag name
/// (`lowerCaseTags`), html5ever keeps SVG names such as `linearGradient`.
fn has_local_name(&self, name: &CssLocalName) -> bool {
self.elem().name.local.eq_ignore_ascii_case(&name.0)
}
fn has_namespace(&self, namespace: &Namespace) -> bool {
&self.elem().name.ns == namespace
}
fn is_same_type(&self, other: &Self) -> bool {
self.elem().name == other.elem().name
}
fn attr_matches(
&self,
ns: &NamespaceConstraint<&Namespace>,
local_name: &CssLocalName,
operation: &AttrSelectorOperation<&CssString>,
) -> bool {
self.elem().attrs.iter().any(|(key, value)| {
!matches!(*ns, NamespaceConstraint::Specific(url) if *url != key.ns)
&& local_name.0 == key.local
&& operation.eval_str(value)
})
}
fn match_non_ts_pseudo_class(
&self,
pc: &PseudoClass,
_context: &mut MatchingContext<'_, Self::Impl>,
) -> bool {
match pc {
PseudoClass::Dynamic(_) => false,
PseudoClass::Alias(name) => with_alias(name, |sel| match sel {
Some(sel) => sel.matches(self),
None => false,
}),
PseudoClass::Contains(text) => self.get_text().contains(text.as_str()),
PseudoClass::IContains(text) => self.get_text().to_lowercase().contains(text.as_str()),
PseudoClass::Lang(code) => {
// css-select `lang`: walk up to the nearest `lang` attribute
// and compare language ranges (case-insensitive, `*`
// wildcards). Simplified to prefix matching on subtags.
let want = code.to_ascii_lowercase();
let mut cur = Some(*self);
while let Some(el) = cur {
if let Some(v) = el.elem().attr("lang") {
let have = v.to_ascii_lowercase();
if want.is_empty() {
return have.is_empty();
}
if want == "*" {
return !have.is_empty();
}
return have == want || have.starts_with(&format!("{}-", want));
}
cur = el.parent_element();
}
false
}
}
}
fn match_pseudo_element(
&self,
_pe: &PseudoElement,
_context: &mut MatchingContext<Self::Impl>,
) -> bool {
false
}
fn is_link(&self) -> bool {
false
}
fn is_html_slot_element(&self) -> bool {
false
}
fn has_id(&self, id: &CssLocalName, case_sensitivity: CaseSensitivity) -> bool {
match self.elem().id() {
Some(val) => case_sensitivity.eq(id.0.as_bytes(), val.as_bytes()),
None => false,
}
}
fn has_class(&self, name: &CssLocalName, case_sensitivity: CaseSensitivity) -> bool {
self.elem().has_class(&name.0, case_sensitivity)
}
fn has_custom_state(&self, _name: &CssLocalName) -> bool {
false
}
fn imported_part(&self, _: &CssLocalName) -> Option<CssLocalName> {
None
}
fn is_part(&self, _name: &CssLocalName) -> bool {
false
}
/// css-select `:empty`: no element children and only whitespace text
/// (` \t\r\n`).
fn is_empty(&self) -> bool {
self.0.children().all(|c| match c.value() {
Node::Element(_) => false,
Node::Text(t) => t
.text
.chars()
.all(|ch| matches!(ch, ' ' | '\t' | '\r' | '\n')),
_ => true,
})
}
/// css-select `:root`: the parent is not an element.
fn is_root(&self) -> bool {
self.0.parent().is_some_and(|p| !p.value().is_element())
}
fn apply_selector_flags(&self, _flags: matching::ElementSelectorFlags) {}
fn add_element_unique_hashes(&self, _filter: &mut BloomFilter) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use scraper::Html;
fn first<'a>(html: &'a Html, tag: &str) -> El<'a> {
let node = html
.tree
.root()
.descendants()
.find(|n| {
n.value()
.as_element()
.is_some_and(|e| e.name.local.as_ref() == tag)
})
.unwrap();
El(node)
}
#[test]
fn pseudo_surface() {
assert!(Selector::parse("a:hover").is_ok());
assert!(Selector::parse("a:focus").is_err());
assert!(Selector::parse("a::before").is_err());
assert!(Selector::parse("input:disabled").is_ok());
assert!(Selector::parse(":is(a, :focus)").is_err());
assert!(Selector::parse("[class*=\"badge\" i]").is_ok());
assert!(Selector::parse("li:not(:last-child)").is_ok());
assert!(Selector::parse(":matches(a, b)").is_ok());
assert!(Selector::parse("p:contains(hello world)").is_ok());
}
#[test]
fn matches_dom() {
let html = Html::parse_document(
"<html><body><input type=text disabled><a href=x>y</a><p>hello world</p></body></html>",
);
let input = first(&html, "input");
assert!(Selector::parse(":disabled").unwrap().matches(&input));
assert!(!Selector::parse(":enabled").unwrap().matches(&input));
assert!(Selector::parse("input:not(:hover)")
.unwrap()
.matches(&input));
assert!(!Selector::parse("input:hover").unwrap().matches(&input));
let a = first(&html, "a");
assert!(Selector::parse(":link").unwrap().matches(&a));
assert!(Selector::parse("a:any-link").unwrap().matches(&a));
let p = first(&html, "p");
assert!(Selector::parse("p:contains(\"lo wo\")")
.unwrap()
.matches(&p));
assert!(Selector::parse("p:icontains(HELLO)").unwrap().matches(&p));
assert!(Selector::parse("html:root")
.unwrap()
.matches(&first(&html, "html")));
assert!(!Selector::parse(":root").unwrap().matches(&p));
assert!(Selector::parse("body > p").unwrap().matches(&p));
assert!(Selector::parse("body:has(> p)")
.unwrap()
.matches(&first(&html, "body")));
}
}
+310
View File
@@ -0,0 +1,310 @@
//! The `impeccable detect` adapter for the static HTML engine: implements
//! `impeccable_detect::engines::HtmlEngine` over [`crate::engine::detect_html`],
//! wiring the three pieces `detectHtml` borrows from other JS modules:
//!
//! - the design-system trio (`checkSourceDesignSystem` and
//! `mergeDesignSystemFindings` from the `detect` crate, plus the DOM-backed
//! `collectStaticDesignSystemFindings` ported here, JS
//! `cli/engine/design-system.mjs`),
//! - `runTextContentAnalyzers` (regex engine, `detect` crate),
//! - the detector profile (`detect::profiler::DetectorProfile`) as a
//! [`ProfileSink`].
//!
//! Dependency direction: html depends on detect, never the reverse; the
//! `cli` binary registers [`StaticHtmlEngine`] in `Engines`.
use std::path::Path;
use impeccable_core::findings::Finding;
use impeccable_core::js;
use impeccable_core::js_ext_b::slice_utf16_prefix;
use impeccable_detect::design_system::{
check_source_design_system, css_color_label, extract_radius_tokens, is_allowed_color_raw,
is_allowed_font, is_allowed_radius_raw, is_transparent_css, make_design_finding,
merge_design_system_findings, primary_font, DesignSystem, STATIC_DESIGN_SKIP_TAGS,
};
use impeccable_detect::detect_text::run_text_content_analyzers;
use impeccable_detect::engines::{EngineError, HtmlEngine, ScanOptions};
use impeccable_detect::profiler::{DetectorProfile, ProfileEvent as DetectProfileEvent};
use once_cell::sync::Lazy;
use regex::Regex;
use crate::background::sv;
use crate::dom::{StaticDocument, StaticElement};
use crate::engine::{detect_html, DesignSystemHook, DetectHtmlOptions};
use crate::profile::{ProfileEvent, ProfileSink};
use crate::quality::pf0;
/// The static HTML engine as `impeccable detect` sees it.
///
/// `static_rule_pack` is the rule pack's static-document hook, set by
/// whichever binary builds `Engines`; the `impeccable` binary leaves it
/// `None`. The pack's engine-wide hooks travel on `ScanOptions` instead,
/// because `detect` owns those options and cannot name this crate's trait.
#[derive(Debug, Default, Clone, Copy)]
pub struct StaticHtmlEngine {
pub static_rule_pack: Option<&'static dyn crate::engine::StaticRulePack>,
}
impl HtmlEngine for StaticHtmlEngine {
fn detect_html(
&self,
path: &str,
options: &ScanOptions,
stderr: &mut dyn std::io::Write,
) -> Result<Vec<Finding>, EngineError> {
// The JS DEGRADED notice fires only when its parser modules fail to
// import; the port links them in. The stderr sink carries the
// unreadable-linked-stylesheet notices (issue #652).
let stderr_cell = std::cell::RefCell::new(stderr);
let warn = |msg: &str| {
let _ = stderr_cell.borrow_mut().write_all(msg.as_bytes());
};
let profile_sink = options
.profile
.as_deref()
.map(|p| DetectorProfileSink { profile: p });
let profile_ref: Option<&DetectorProfile> = options.profile.as_deref();
let analyzers = move |content: &str, file_path: &str| -> Vec<Finding> {
run_text_content_analyzers(content, file_path, profile_ref)
};
let hook = options
.design_system
.as_deref()
.map(|ds| DetectDesignSystemHook { design_system: ds });
let html_options = DetectHtmlOptions {
inline_ignores_disabled: !options.inline_ignores,
design_system: hook.as_ref().map(|h| h as &dyn DesignSystemHook),
text_content_analyzers: Some(&analyzers),
profile: profile_sink.as_ref().map(|s| s as &dyn ProfileSink),
warn: Some(&warn),
static_rule_pack: self.static_rule_pack,
rule_pack: options.rule_pack,
};
detect_html(Path::new(path), &html_options).map_err(|e| {
EngineError::new(match e {
// JS `fs.readFileSync` rejection surfaced by `detectCli`'s catch.
crate::engine::HtmlEngineError::Read { path, source } => match source.kind() {
std::io::ErrorKind::NotFound => {
format!("ENOENT: no such file or directory, open '{path}'")
}
std::io::ErrorKind::PermissionDenied => {
format!("EACCES: permission denied, open '{path}'")
}
_ => format!("{source}, open '{path}'"),
},
})
})
}
}
/// [`ProfileSink`] over the detect crate's `DetectorProfile`
/// (`recordProfileEvent` on the `{ events: [] }` shape).
pub struct DetectorProfileSink<'a> {
pub profile: &'a DetectorProfile,
}
impl ProfileSink for DetectorProfileSink<'_> {
fn record(&self, event: ProfileEvent) {
let normalized = DetectProfileEvent {
engine: or_unknown(event.engine),
phase: or_unknown(event.phase),
rule_id: or_unknown(event.rule_id),
target: event.target,
ms: if event.ms.is_finite() { event.ms } else { 0.0 },
findings: event.findings as f64,
detail: event.detail.filter(|d| !d.is_empty()),
finding_ids: if event.finding_ids.is_empty() {
None
} else {
Some(event.finding_ids)
},
};
self.profile.events.borrow_mut().push(normalized);
}
}
fn or_unknown(s: String) -> String {
if s.is_empty() {
"unknown".to_string()
} else {
s
}
}
/// [`DesignSystemHook`] over a loaded `DesignSystem`.
pub struct DetectDesignSystemHook<'a> {
pub design_system: &'a DesignSystem,
}
impl DesignSystemHook for DetectDesignSystemHook<'_> {
fn check_source(&self, html: &str, file_path: &str) -> Vec<Finding> {
check_source_design_system(html, file_path, Some(self.design_system))
}
fn collect_static(&self, doc: &StaticDocument, file_path: &str) -> Vec<Finding> {
collect_static_design_system_findings(doc, file_path, self.design_system)
}
fn merge(&self, static_findings: Vec<Finding>, source_findings: Vec<Finding>) -> Vec<Finding> {
merge_design_system_findings(vec![static_findings, source_findings])
}
}
static WS_RUN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{}+", js::WS)).unwrap());
/// JS: design-system.mjs#hasDirectText
fn has_direct_text(el: &StaticElement<'_>) -> bool {
el.has_direct_text_longer_than(0)
}
/// JS: design-system.mjs#sampleText
fn sample_text(el: &StaticElement<'_>) -> String {
let raw = el.text_content();
let collapsed = WS_RUN_RE.replace_all(&raw, " ");
let text = js::trim(&collapsed);
if text.is_empty() {
String::new()
} else {
format!(" \"{}\"", slice_utf16_prefix(text, 40))
}
}
/// JS: design-system.mjs#shouldSkipStaticDesignElement
fn should_skip_static_design_element(el: &StaticElement<'_>) -> bool {
let tag = el.tag_lower();
if STATIC_DESIGN_SKIP_TAGS.contains(&tag.as_str()) {
return true;
}
let mut current = Some(*el);
while let Some(cur) = current {
if cur.get_attribute("hidden").is_some() || cur.get_attribute("aria-hidden") == Some("true")
{
return true;
}
let style = cur.style();
let display = js::to_lower_case(sv(style, "display"));
let visibility = js::to_lower_case(sv(style, "visibility"));
if display == "none" || visibility == "hidden" || visibility == "collapse" {
return true;
}
current = cur.parent_element();
}
false
}
/// JS: design-system.mjs#collectStaticDesignSystemFindings
///
/// Font-size design-system checks are source-scan-only (see
/// `checkSourceDesignSystem`); computed font-size cascades and clamp() ramps
/// resolve to off-ramp px in the browser.
pub fn collect_static_design_system_findings(
doc: &StaticDocument,
file_path: &str,
ds: &DesignSystem,
) -> Vec<Finding> {
if !ds.present {
return vec![];
}
let mut findings = Vec::new();
let mut seen_fonts: Vec<String> = Vec::new();
let mut seen_colors: Vec<String> = Vec::new();
let mut seen_radii: Vec<String> = Vec::new();
for el in doc.query_selector_all("*") {
if should_skip_static_design_element(&el) {
continue;
}
let tag = el.tag_lower();
let style = el.style();
if ds.has_fonts && has_direct_text(&el) {
let font = primary_font(sv(style, "fontFamily"));
if !font.is_empty() && !seen_fonts.contains(&font) && !is_allowed_font(&font, Some(ds))
{
seen_fonts.push(font.clone());
findings.push(make_design_finding(
"design-system-font",
file_path,
&format!(
"{tag}{} uses {font}; not declared in DESIGN.md typography",
sample_text(&el)
),
0.0,
&font,
));
}
}
if ds.has_colors {
let mut color_checks: Vec<(String, &str)> = Vec::new();
if has_direct_text(&el) {
color_checks.push(("text color".to_string(), sv(style, "color")));
}
if !is_transparent_css(sv(style, "backgroundColor")) {
color_checks.push(("background".to_string(), sv(style, "backgroundColor")));
}
for side in ["Top", "Right", "Bottom", "Left"] {
if pf0(sv(style, &format!("border{side}Width"))) > 0.0 {
color_checks.push((
format!("border-{}", side.to_ascii_lowercase()),
sv(style, &format!("border{side}Color")),
));
}
}
if pf0(sv(style, "outlineWidth")) > 0.0 {
color_checks.push(("outline".to_string(), sv(style, "outlineColor")));
}
for (kind, raw) in color_checks {
let label = css_color_label(raw);
if is_allowed_color_raw(&label, Some(ds)) {
continue;
}
let key = format!("{kind}:{label}");
if seen_colors.contains(&key) {
continue;
}
seen_colors.push(key);
findings.push(make_design_finding(
"design-system-color",
file_path,
&format!(
"{kind} {label} on {tag}{} is outside DESIGN.md colors",
sample_text(&el)
),
0.0,
&label,
));
}
}
if ds.has_radii {
let raw_radius = js::trim(sv(style, "borderRadius"));
if raw_radius.is_empty() {
continue;
}
for token in extract_radius_tokens(raw_radius) {
if is_allowed_radius_raw(&token, Some(ds)) {
continue;
}
if seen_radii.contains(&token) {
continue;
}
seen_radii.push(token.clone());
findings.push(make_design_finding(
"design-system-radius",
file_path,
&format!(
"border-radius {token} on {tag}{} is outside the DESIGN.md rounded scale",
sample_text(&el)
),
0.0,
&token,
));
}
}
}
findings
}