Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# CLAUDE.md

> **Cheat sheet only** — commands and layout. Keep it terse. Decisions, specs, research, findings, gotchas → `.claude/memory/` (see `MEMORY.md`). Plans → `.claude/plans/`.

@.claude/memory/MEMORY.md

## Overview

`gravitypdf/querypath` is a fork of the QueryPath library: a jQuery-like fluent API for querying and
manipulating XML/HTML(5) documents in PHP. It is a library published on Packagist and
`replace`s `querypath/querypath` and `arthurkushman/query-path`. Much of the code is legacy (2009–2012 era) with Doxygen-style DocBlocks.

## Commands

```bash
composer install
vendor/bin/phpunit # full suite (PHPUnit 9 via yoast/phpunit-polyfills)
vendor/bin/phpunit --filter testAppend # single test method
vendor/bin/phpunit tests/QueryPath/DOMQueryTest.php # single file
vendor/bin/phpunit --coverage-clover=./coverage/coverage1.xml

composer run lint # phpcs against phpcs.xml (src, tests, examples)
composer run lint:fix # phpcbf
composer run lint:min-php # PHPCompatibility check against phpcompat.xml
```

The `Makefile` targets are stale (they point at a `test/Tests` directory that no longer exists) — ignore them.

CI (`.github/workflows`) runs PHPUnit on PHP 7.1–8.5 for pull requests, and phpcs + PHPCompatibility on every push.

## Constraints that shape every change

- **PHP 7.1 through 8.5 must all pass.** `phpcompat.xml` pins `testVersion` to `7.1-`. No typed properties, no arrow
functions, no constructor promotion, no `match`, no union types. `??` and `?:` are fine.
- **Tabs, not spaces.** `phpcs.xml` is PSR-2 with `Generic.WhiteSpace.DisallowSpaceIndent` and tab indentation
(tab-width 4).
- **Tests are extended, not replaced.** Test classes extend `QueryPathTests\TestCase`, which extends
`Yoast\PHPUnitPolyfills\TestCases\TestCase` so the same tests run on the PHPUnit versions supported across PHP 7.1–8.5.
Use the polyfill lifecycle names (`set_up`, `tear_down`, `assertMatchesRegularExpression`, etc.), not the
version-specific PHPUnit ones.
- `phpunit.xml` sets `beStrictAboutOutputDuringTests`, and many QueryPath methods print (`writeHTML()`,
`writeHTML5()`, `writeXML()`) — wrap those in output buffering in tests.
- PRs are expected to add an entry under `# Unreleased changes` in `CHANGELOG.md` and a test for the fixed behaviour
(see `.github/CONTRIBUTING.md`).

## Architecture

### Entry points

`src/qp_functions.php` (autoloaded by Composer via `files`) defines the three global factories, each guarded by
`function_exists()` because the replaced packages defined the same names:

- `qp()` → `QueryPath::with()` — XML/XHTML via libxml
- `htmlqp()` → `QueryPath::withHTML()` — legacy HTML via libxml
- `html5qp()` → `QueryPath::withHTML5()` — HTML5 via `masterminds/html5` (recommended path)

All three return a `QueryPath\DOMQuery`.

### The query object

`QueryPath\DOM` (abstract) holds the `DOMDocument` and the `SplObjectStorage` of matched nodes, and its constructor is
the polymorphic loader — it accepts a file path, an XML/HTML string, `DOMDocument`, `DOMNode`, `SplObjectStorage`,
`SimpleXMLElement`, `Masterminds\HTML5`, another `DOM`, or an array of nodes. Option precedence is
`$options` passed in → `QueryPath\Options::get()` (global defaults) → the class defaults in `DOM::$options`.

`QueryPath\DOMQuery extends DOM implements Query` is the public surface. It is deliberately split so the file stays
navigable — most jQuery-equivalent methods live in traits under `src/Helpers/` and are composed into `DOMQuery`:

- `QueryFilters` — traversal/filtering (`filter`, `map`, `each`, `eq`, `not`, `closest`, `parent(s)`, `children`,
`next/prev(All|Until)`, `siblings`, …). The sibling/ancestor axes go through the private `walk()`; every method but
`next()`/`prev()` then orders its results as jQuery does, via `inDocumentOrder()` / `inReverseDocumentOrder()`.
- `QueryMutators` — mutation (`append`, `prepend`, `before`, `after`, `wrap*`, `replaceWith`, `attr`, `css`,
`addClass`, `remove`, …)
- `QueryChecks` — predicates (`is`, `has`, `hasClass`, `hasAttr`, `removeAttr`)

When adding or fixing a method, edit the trait, not `DOMQuery`.

**Chaining semantics matter and are easy to break.** `DOMQuery::inst()` clones the object and swaps the match set —
that clone is what supports `end()` and `branch()`. `find()` returns a new instance via `inst()`; `findInPlace()`
mutates `$this`. Preserve whichever variant a method already uses.

`src/Query.php` is the (small, partial) interface `DOMQuery` implements; the bulk of the API is not declared there.

### CSS selector engine (`src/CSS/`)

Selector strings are parsed by an event-driven, SAX-like pipeline:

`InputStream` → `Scanner` (produces `Token`s) → `Parser` → calls into an `EventHandler` implementation.

`Selector` (the `EventHandler`) accumulates parsed selectors into `SimpleSelector` objects, which
`CSS\DOMTraverser` (implements `CSS\Traverser`) evaluates. It is the only engine QueryPath uses. `DOMTraverser` does an
initial match (by ID, class, element, or namespace), then walks combinators (`>`, `+`, `~`, descendant) and filters
attributes/pseudo-classes. Pseudo-class evaluation lives in `CSS\DOMTraverser\PseudoClass`; `an+b` parsing and other
shared helpers in `CSS\DOMTraverser\Util`.

Two entry points, and they answer different questions:

- `(new DOMTraverser($matches))->find($selector)` — search the **descendants** of the nodes in hand. Used by `find()`,
`remove($selector)` and `replaceAll()`.
- `DOMTraverser::filterNodes($nodes, $selector)` — test the **nodes in hand** themselves. Combinators still resolve
against the document; positional pseudo-classes (`:first`, `:eq(n)`, …) count within the given set.
`Helpers\NodeMatcher` wraps it for the trait methods (`filter()`, `not()`, `is()`, `closest()`, the `*Until()` and
sibling/ancestor traversals): one parse and one `PseudoClass` sibling index per call, with per-node answers cached.
Prefer its set methods (`filter()`, `matching()`, `matchesAny()`) over per-node `matches()` — a node tested alone is
a one-element set, so positional selectors can't be answered that way.

`QueryPathEventHandler` is the deprecated legacy engine. Nothing in QueryPath calls it any more; it stays only for BC.

Supported selectors are CSS 3 plus parts of CSS 4 and most jQuery pseudo-classes (`:eq`, `:lt`, `:gt`, `:first`,
`:odd`, …). UA-dependent pseudo-classes (`:hover`, `:visited`, …) parse but never match.

### Extensions (`src/Extension.php`, `src/ExtensionRegistry.php`)

An extension is a class implementing `QueryPath\Extension` (constructor takes a `Query`), registered with
`QueryPath::enable(...)`. `DOMQuery::__call()` lazily instantiates registered extensions on the first unknown method
call and dispatches via reflection — extensions are not loaded during construction, by design, so `qp()` stays cheap.
Bundled extensions live in `src/Extension/`: `QPXML`, `QPXSL`, `Format`.

### Other pieces

- `src/Entities.php` / `EntitiesContract.php` — HTML entity → numeric entity replacement, used when the
`replace_entities` option is on.
- `src/QueryPathIterator.php` — makes `foreach ($qp->find('li') as $li)` yield `DOMQuery` objects, not raw nodes.
- `src/Exception.php`, `ParseException.php`, `IOException.php`, `CSS/ParseException.php`,
`CSS/NotImplementedException.php` — all throwables descend from `QueryPath\Exception`, so catching it is the
documented user-facing pattern.
- `src/documentation.php` and `config.doxy` exist only to feed Doxygen; they contain no runtime code.
- `tests/*.xml` and `tests/data.html` are fixtures referenced by `TestCase::DATA_FILE_XML` / `DATA_FILE_HTML`
(paths are relative to the repo root, so PHPUnit must be run from there).
- `examples/` are runnable scripts demonstrating the API; they are linted but not tested.
Loading