diff --git a/.claude/plans/modernization.md b/.claude/plans/modernization.md new file mode 100644 index 00000000..8a32fd25 --- /dev/null +++ b/.claude/plans/modernization.md @@ -0,0 +1,253 @@ +# QueryPath modernization plan + +Status: proposed, 2026-08-21. Constraint: **no breaking changes** — this plan stays inside a 4.x minor. + +## What "no breaking changes" rules out + +Everything below obeys these, so they're stated once: + +- **No native param/return types added to public or protected methods.** `DOMQuery` is subclassable, `Query` is + implementable, and extensions are duck-typed through `__call()`. Adding `string $selector` to a public method + breaks every subclass that overrides it. PHPDoc types are free; native types are not. +- **No public method or class removals**, including the ones that are already broken. +- **No PHP floor bump.** `^7.1` stays. That rules out typed properties, `??=`, arrow functions, `str_contains`, + constructor promotion, and `match`. +- **No behaviour changes to selector matching**, unless covered by a test that proves the old behaviour was a bug. + +--- + +## Audit findings + +Measured on PHP 8.3.16, 2026-08-21. Baseline: **319 tests, 1073 assertions, green**, 2 skipped. `composer run lint` +passes clean. Line coverage 88.7%, method coverage 67.6%. + +### 1. There is no static analysis, and it would find real bugs + +PHPStan has never been run here. A throwaway run against `src/` finds: + +| Level | Errors | +|-------|--------| +| 0 | 8 | +| 1 | 21 | +| 2 | 69 | +| 3 | 78 | +| 5 | 146 | + +That is a small, tractable number for an 11.6k-line legacy library, and level 0 is almost entirely genuine defects: + +- `src/CSS/DOMTraverser.php:386` — `combineAnyDescendant()` documents `@return boolean` but falls off the end of the + `while` loop returning `null`. Callers compare loosely so it hasn't bitten yet. +- `src/CSS/Parser.php:363` — `pseudoClassValue()` same shape: documented `string`, returns `null` when the token + isn't `LPAREN`. +- `src/DOMQuery.php:405` — `dataURL()` has three exits, one of which (`return;`) yields `null` against a documented + `DOMQuery|string`. +- `src/DOM.php:247` and `:272` — the abstract `DOM` writes `$this->last` and `$this->length`, but both properties are + declared on the `DOMQuery` **subclass** (`src/DOMQuery.php:57`, `:66`). Anything that extends `DOM` directly creates + dynamic properties — deprecated in PHP 8.2, fatal in PHP 9. +- `src/Helpers/QueryFilters.php:98` and `:370` — `filterLambda()` and `eachLambda()` call `create_function()`, removed + in PHP 8.0. These are **fatal on every supported PHP 8**. Both tests for them are `markTestSkipped`'d + (`tests/QueryPath/DOMQueryTest.php:585`, `:727`), so the suite is green while the methods are dead. +- `src/Helpers/QueryFilters.php:246` — `@return Iterable` resolves as a class name, not the `iterable` keyword. + +Level 2 adds a large cluster of docblock types that don't resolve: `callback` instead of `callable` (5 sites in +`QueryFilters`/`DOMQuery`), `DOMNode` written unqualified inside `QueryPath\CSS` so it resolves to +`QueryPath\CSS\DOMNode` (6 sites in `DOMTraverser`), `char` as a return type (`InputStream::peek()`), `Traverser` +unqualified in `src/CSS/Traverser.php:27`. Plus ~25 `@throws mixed` / `@throws QueryPath\Exception|QueryPath\QueryPath` +tags that name non-throwables, and 4 `@property` tags that are outright parse errors +(`@property Traversable|array|SplObjectStorage matches` — missing the `$`). + +One genuine signature bug: `QueryMutators::removeClass($class = false): Query` documents `@param string $class` but +defaults to `false`. The body relies on `empty($class)` for the "remove the whole attribute" path, so the default +can't simply change to `''`… except `empty('')` is also true, so it can. Needs a test either way. + +### 2. Two CSS engines are live, and the legacy one is 1466 lines + +`CSS\DOMTraverser` (899 lines) is what `find()` uses. `CSS\QueryPathEventHandler` (1466 lines) is the 2009-era engine, +still reached by exactly two methods: `QueryMutators::remove()` and `QueryMutators::replaceAll()`. That means a +selector fix can land in one engine and silently not apply to the other, and every selector bug report needs a +"which path does this take?" triage step first. It is also the least-covered large file (66.7% of methods) and carries +its own defects (`src/CSS/QueryPathEventHandler.php:672`, `$item` used outside the `foreach` that defines it; +`:1039`, `count()` called as a method on an array). + +### 3. Docblocks are Doxygen, and Doxygen for PHP is abandoned + +`@code`/`@endcode`, `@ingroup`, `@file`, `@mainpage`, raw `` HTML — roughly 100 occurrences across 11 files, most +densely in `DOMQuery.php` (32) and `QueryMutators.php` (20). `config.doxy` and `src/documentation.php` (261 lines of +pure `@mainpage` prose, no runtime code) exist only to feed it. Nothing in CI generates docs, and the `Makefile` +`docs` target points at a `doxygen` binary nobody runs. IDEs and PHPStan both ignore this markup, so the library's +best documentation is invisible to every modern tool. + +### 4. Repo root is carrying PEAR-era dead weight + +Tracked in git, none of it referenced by anything current: + +- `package.xml`, `package_compatible.xml` — PEAR package descriptors +- `build.xml` — Phing +- `Makefile` — targets point at `test/Tests`, a directory that hasn't existed for years +- `INSTALL` — describes extracting `QueryPath-2.0-minimal.tgz` +- `clover.xml` — a coverage report generated **July 2018**, committed to the repo +- `config.doxy`, `src/documentation.php` — see above + +There is also no `.gitattributes`, so `composer require gravitypdf/querypath` ships `tests/`, `examples/`, all of the +above, and the fixtures to every consumer. + +### 5. Tooling is pinned to the past + +- `phpunit.xml` uses the pre-9.3 schema. PHPUnit warns `Your XML configuration validates against a deprecated schema` + on every single run. +- The **coding-standard workflow runs on PHP 7.1**. That's the language floor, not a sensible host for the linter, and + it forces old phpcs/phpcompat versions to stay resolvable. +- `dealerdirect/phpcodesniffer-composer-installer: ^0.7.0` (current is `^1.0`), `phpcompatibility/php-compatibility: *` + (unpinned — a transitive break lands silently). +- `phpcs.xml` targets **PSR-2**, which PHP-FIG marked deprecated in favour of PSR-12 in 2019. +- **CI bug:** `.github/workflows/unit-testing.yml` declares `matrix.php-versions` but its `include:` block sets + `php: '8.3'`. Since the key doesn't match, that `include` appends an 11th job with `php-versions` **unset** rather + than tagging the existing 8.3 job. The coverage upload runs on that phantom job. + +### 6. Coverage is uneven in a way that blocks refactoring + +Line coverage looks healthy at 88.7%, but method coverage tells the real story for the pieces this plan wants to touch: + +| Class | Methods covered | +|---|---| +| `CSS\SimpleSelector` | 20% (2% of lines) | +| `Extension\QPXML` | 22% | +| `QueryPath` | 29% | +| `CSS\Parser` | 38% | +| `CSS\DOMTraverser` | 59% | +| `CSS\QueryPathEventHandler` | 67% | + +`DOMTraverser` and `QueryPathEventHandler` are precisely the classes phase 6 wants to consolidate. That work is not +safe at 59%/67%. + +--- + +## The plan + +Seven phases, each a self-contained PR with its own `CHANGELOG.md` entry. Phases 1–5 are ordered by dependency; +6 depends on 5. + +### Phase 1 — Safety net (no `src/` changes) + +Get the instruments in place before touching anything. + +1. `vendor/bin/phpunit --migrate-configuration` — silences the schema warning, stays on PHPUnit 9. +2. Add `phpstan/phpstan` to `require-dev`, `phpstan.neon` at **level 5** with a generated baseline + (146 entries), `composer run analyse`, and a CI job on PHP 8.3. Level 5 with a baseline means *new* code is held to + level 5 from day one while the debt is paid down deliberately in phase 3. +3. Fix the `unit-testing.yml` matrix `include` key (`php` → `php-versions`) so coverage uploads from a real job. +4. Move the coding-standard workflow to PHP 8.3. + +**Verify:** suite green, phpcs green, `analyse` green against its own baseline, CI matrix shows 10 jobs not 11. + +### Phase 2 — Repo hygiene (no `src/` changes) + +1. Delete `package.xml`, `package_compatible.xml`, `build.xml`, `Makefile`, `INSTALL`, `clover.xml`. + Keep `CREDITS` — it's attribution for a fork of a fork. +2. Add `.gitattributes` with `export-ignore` for `tests/`, `examples/`, `.github/`, `.claude/`, and the dotfile + configs, so installs stop shipping them. +3. **Decision needed:** `config.doxy` + `src/documentation.php`. Either delete both and fold the prose into `README.md` + (recommended — nothing generates Doxygen today), or keep them and add a docs job. Don't leave them in limbo. + +**Verify:** `composer install` in a scratch project pulls a visibly smaller package; suite green. + +### Phase 3 — Pay down the static-analysis baseline + +Each fix ships with a regression test. This is where the level-0/1/2 findings from the audit get resolved. + +1. Declare `protected $last = [];` and `public $length = 0;` on `DOM`, remove the duplicates from `DOMQuery`. Kills + the PHP 9 dynamic-property hazard. +2. Add the missing `return false;` / `return '';` / `return null;` to `combineAnyDescendant()`, + `pseudoClassValue()`, and `dataURL()`, matching what callers already assume. Test each. +3. Make `filterLambda()` and `eachLambda()` throw `\QueryPath\Exception` with a message naming the replacement + (`filterCallback()` / `each()`) instead of fataling with `Call to undefined function create_function()`. Un-skip + both tests and assert the exception. **This cannot be a removal in 4.x** — it's a documented public method. +4. Fix the ~40 unresolvable docblock types: `Iterable`→`iterable`, `callback`→`callable`, `DOMNode`→`\DOMNode`, + `char`→`string`, `Traverser`→`\QueryPath\CSS\Traverser`, and the 4 malformed `@property` tags. +5. Replace the ~25 `@throws mixed` and `@throws QueryPath\Exception|QueryPath\QueryPath` tags with what the methods + actually throw. +6. Settle `removeClass($class = false)` — change the default to `''` with a test proving `removeClass()` still strips + the whole attribute. + +Shrink the baseline as each lands. Target: baseline file empty at level 5. + +**Verify:** suite green on 7.1 and 8.5 in CI; baseline strictly smaller each commit. + +### Phase 4 — Docblocks that tools can read + +Mechanical, file-by-file, no logic changes. + +1. Doxygen → phpDocumentor + Markdown: `@code`/`@endcode` → fenced blocks, `@ingroup`/`@file` dropped, `` → `**`. +2. Fill in real `@param`/`@return`/`@var` types on every method that has none, including array shapes + (`array` for the options array, which is currently untyped everywhere it's passed). +3. `Query` interface (`src/Query.php`) is 9 methods against a ~200-method public surface, with two `@method` tags + papering over the gap. Document what it is and isn't; **don't widen it** — adding methods to a published interface + breaks every implementor. + +This is what lets PHPStan go past level 5 later without touching a single signature. + +**Verify:** raise the PHPStan level one notch at a time and confirm the baseline doesn't need to grow. + +### Phase 5 — Close the coverage gaps that block phase 6 + +1. Characterization tests for `CSS\QueryPathEventHandler` driven through `remove()` and `replaceAll()` — the two + methods that actually reach it. Capture current behaviour, including the wrong bits. +2. Run the same selector corpus through `DOMTraverser` and record every divergence. That diff is the actual scope of + phase 6, and right now nobody knows how big it is. +3. Lift `CSS\SimpleSelector` (2% of lines), `Extension\QPXML` (22% of methods), and `QueryPath` (29%) to something + defensible. +4. Bump `dealerdirect/phpcodesniffer-composer-installer` to `^1.0` and pin `phpcompatibility/php-compatibility` + to `^9.3`. Leave `yoast/phpunit-polyfills` at `^1.0` — `^2.0` adds PHPUnit 10/11 but drops PHP < 7.4, so it's + blocked on the floor bump. + +**Verify:** method coverage above 80% overall; the divergence list exists and is reviewed. + +### Phase 6 — Retire the second selector engine (internal only) + +Only after phase 5 quantifies the risk. + +1. Point `QueryMutators::remove()` and `QueryMutators::replaceAll()` at `CSS\DOMTraverser`. +2. Mark `CSS\QueryPathEventHandler` `@deprecated` — **keep the class**, it's public API and something out there may + construct it directly. Removal is a 5.0 item. +3. Fix any divergence the phase-5 corpus exposed, one test per fix. + +Result: one engine on every code path, 1466 lines out of the maintenance surface, and selector bug triage collapses to +a single question. + +### Phase 7 — Structural cleanup (behaviour-preserving) + +1. `DOM::__construct()` is a 95-line polymorphic loader with an 8-branch `if`/`elseif` chain over + `SplObjectStorage`/`DOM`/`DOMDocument`/`DOMNode`/`HTML5`/`SimpleXMLElement`/array/string/filename. Extract each + branch to a named private method. One public constructor, same semantics, readable. +2. The `set_error_handler`/`restore_error_handler` + "emulate finally" try/catch appears at **8 sites** across + `DOM.php` and `DOMQuery.php` — with hand-rolled `finally` emulation that predates PHP 5.5. Extract one private + helper using a real `finally`. Watch the `$errTypes` argument, which differs between sites. +3. PSR-2 → PSR-12 in `phpcs.xml`, keeping the tab-indent and line-length overrides. Run `phpcbf`, then diff carefully + — PSR-12 adds rules PSR-2 doesn't have, and `beStrictAboutOutputDuringTests` will catch anything that moves. +4. Add explicit visibility to `Options`' `static $options` and its four `static function`s (currently implicit + public — adding the keyword is BC). + +--- + +## Explicitly deferred to 5.0 + +Tracked here so they don't get re-litigated every time someone opens `QueryFilters.php`: + +- **PHP floor.** 7.1 reached EOL in December 2019; 7.4 in November 2022. A 5.0 on `^8.0` unlocks native types + throughout, typed properties, `str_contains`, and `match` — and would let phases 3 and 4 finish the job properly + instead of half in docblocks. This is the single highest-leverage change available and it is a hard BC break. +- Deleting `filterLambda()`, `eachLambda()`, and the other 11 `@deprecated` methods. +- Deleting `CSS\QueryPathEventHandler`. +- Widening `Query` to reflect the real public surface. +- `yoast/phpunit-polyfills: ^2.0` for PHPUnit 10/11. + +--- + +## Suggested order + +Phases 1 and 2 are independent of everything and can land this week. 3 needs 1. 4 is parallelisable with 3 by file. +6 hard-blocks on 5. 7 can slot in anywhere after 3. + +The one thing worth doing before any of it: agree the 5.0 floor-bump question, because if `^8.0` is coming within a +release or two, phase 4 should write docblocks knowing they'll become native types rather than optimising to live +forever. diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml new file mode 100644 index 00000000..d978c7c9 --- /dev/null +++ b/.github/workflows/wiki-sync.yml @@ -0,0 +1,66 @@ +name: Wiki Sync + +# The wiki is generated from docs/ in this repository. Pages edited directly in +# the GitHub wiki UI are overwritten the next time this runs. + +on: + push: + branches: + - main + paths: + - 'docs/**' + - '.github/workflows/wiki-sync.yml' + workflow_dispatch: + +# Only one sync at a time — two concurrent pushes would race on the wiki remote. +concurrency: + group: wiki-sync + cancel-in-progress: false + +permissions: + contents: write + +jobs: + sync: + name: Push docs/ to the wiki + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Clone the wiki + run: | + git clone \ + "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.wiki.git" \ + wiki + + - name: Copy docs/ over the wiki + run: | + # Everything tracked in the wiki is replaced by docs/, so a page + # deleted from docs/ is deleted from the wiki too. .git is preserved. + find wiki -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp -R docs/. wiki/ + + - name: Rewrite internal links for the wiki + run: | + # In docs/ the cross-links carry a .md suffix so they resolve when the + # files are browsed in the repository. The wiki addresses the same + # pages without it, so strip the suffix on the way through. + find wiki -name '*.md' -exec \ + sed -i -E 's/\]\(([A-Za-z0-9_.-]+)\.md(#[A-Za-z0-9_-]+)?\)/](\1\2)/g' {} + + + - name: Commit and push + working-directory: wiki + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git diff --quiet && git diff --staged --quiet && [ -z "$(git status --porcelain)" ]; then + echo "Wiki already matches docs/ — nothing to push." + exit 0 + fi + + git add -A + git commit -m "Sync wiki from docs/ (${GITHUB_SHA::7})" + git push origin HEAD diff --git a/.gitignore b/.gitignore index ce1115a4..46cdae7f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ dist test/coverage test/reports test/db -docs/* doc/* test/fakepear vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e390a22..3afbbe92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ QueryPath Changelog # Unreleased changes +- Move the long-form documentation out of Doxygen DocBlocks and into Markdown pages under `docs/`: `Getting-Started`, `CSS-Selector-Reference`, `Parser-Options`, and `Writing-Extensions`. Every claim was verified against the current code, and the pages record the behaviour that differs from jQuery, from the CSS spec, and from the old documentation +- Add the `Wiki Sync` workflow, which publishes `docs/` to the GitHub wiki on push to `main`. `docs/` is the source of truth; wiki edits made in the browser are overwritten +- Delete `src/documentation.php`, which held 261 lines of Doxygen `@mainpage`/`@page` prose and no runtime code. Its content now lives in `docs/` +- Replace the Doxygen markup (`@code`/`@endcode`, `@ingroup`, `@addtogroup`, ``) in `qp_functions.php`, `QueryPath.php`, `Extension.php`, `Options.php` and `CSS/EventHandler.php` with standard PHPDoc and Markdown, and correct the examples that no longer ran +- Correct the documented CSS selector behaviour: the jQuery-style positional pseudo-classes (`:eq`, `:lt`, `:gt`, `:nth`) are 1-indexed rather than 0-indexed like jQuery; `:indeterminate` returns a random result; `:lang()` is implemented; `::before`/`::after` do not throw; `::first-line`/`::first-letter` return the whole element; and `:any-link`, `:local-link`, `:scope` and `:matches()` were undocumented. Also documented is that `remove()` and `replaceAll()` use the legacy selector engine and disagree with `find()` on `:lt()`, `:any-link` and `:scope` +- Correct the documented object semantics: `find()` returns a new object and leaves the original untouched, and `findInPlace()` is the mutating variant. Documentation predating QueryPath 3 stated the opposite +- Remove `docs/*` from `.gitignore`, which was there for the phpDocumentor HTML output committed years ago +- Document the full public API in `docs/`: `API-Reference` (an alphabetical index of all 99 public `DOMQuery` methods, with a known-issues summary), `Traversal-and-Filtering`, `Manipulation`, `Markup-and-Text`, and `Document-and-Utility`. Every method's return semantics were determined by running it, not by reading the DocBlock +- Replace the remaining Doxygen markup throughout `src/` — `@code`/`@endcode`, `@attention`, `@retval`, `@b`, and the inline ``/``/`` HTML — with Markdown and standard PHPDoc tags. No Doxygen-specific markup remains in the source +- Correct DocBlocks that no longer described the code: `has()` and `sort()` return a new object rather than mutating in place; `size()` claimed there is no `$length` property, but there is and it is kept current; `childrenText()` collects the whole subtree rather than just the children; `dataURL()` returns an array from its getter, not a string; `end()`'s example was wrong about rewinding past the start; and `branch()` referred to a `QPTPL` class that does not exist +- Document the behaviours that verification showed to be surprising or broken: `X > *` raises a `TypeError`; `not()` inverts its test when given an `SplObjectStorage`; `detach($selector)` ignores its argument; `firstChild()` returns at most one node regardless of how many elements are selected; `css()` pools style declarations across the whole match set and writes the union to every element; `writeHTML5()` returns `null` and so cannot be chained; `hasAttr()` returns `true` on an empty match set; and `filterLambda()`/`eachLambda()` raise an `Error` on PHP 8, having been built on the removed `create_function()` + - Reorganise, modernise, and repair the `examples/` directory. Each example now lives in its own subdirectory with an `index.php`, and the full set is indexed in `examples/quickstart-guide.md` - Convert the remaining legacy examples: `simple_example.php`, `techniques.php`, `svg.php`, `rss.php`, `odt.php`, `parse_php.php`, and `sparql.php` - Fix examples that no longer ran: send a `User-Agent` where remote hosts now require one, resolve paths relative to the example rather than the working directory, and stop relying on the removed `qp.php` autoloader and the PHP 8 incompatible `eachLambda()` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d33f905e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# 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`, …) +- `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. + +There are two `EventHandler` implementations, and both are live: + +- `Selector` — accumulates parsed selectors into `SimpleSelector` objects. This is what the **current** engine, + `CSS\DOMTraverser` (implements `CSS\Traverser`), consumes. `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`. **This is the engine `find()` uses.** +- `QueryPathEventHandler` — the **legacy** engine, still used by `QueryMutators::remove()` and + `QueryMutators::replaceAll()`. A selector bug can therefore reproduce in one engine and not the other; check which + path the failing method takes before fixing. + +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. diff --git a/README.md b/README.md index 18bccb92..c2f43664 100644 --- a/README.md +++ b/README.md @@ -113,11 +113,32 @@ try { See the [examples directory files](https://github.com/GravityPDF/querypath/tree/main/examples) for more usages. -## Online Manual - -The legacy QueryPath manual has been automatically generated from inline DocBlocks using phpDocumentor, and can be found at [http://querypath.org](http://querypath.org/). - -> ⚠️ querypath.org is not built or maintained by Gravity PDF, and we have no access to manage or change the website. [Help writing new documentation in the repo's Wiki is wanted](https://github.com/GravityPDF/querypath/wiki). +## Documentation + +| Page | What's in it | +|---|---| +| [Getting Started](https://github.com/GravityPDF/querypath/wiki/Getting-Started) | The three factories, chaining, and how object identity works | +| [CSS Selector Reference](https://github.com/GravityPDF/querypath/wiki/CSS-Selector-Reference) | Every supported selector, with the jQuery differences called out | +| [Parser Options](https://github.com/GravityPDF/querypath/wiki/Parser-Options) | Every option `qp()`, `htmlqp()` and `html5qp()` accept | +| [Writing Extensions](https://github.com/GravityPDF/querypath/wiki/Writing-Extensions) | Adding your own methods to the fluent API | + +The full API reference: + +| Page | What's in it | +|---|---| +| [API Reference](https://github.com/GravityPDF/querypath/wiki/API-Reference) | Every method, alphabetically, with a known-issues summary | +| [Traversal and Filtering](https://github.com/GravityPDF/querypath/wiki/Traversal-and-Filtering) | Choosing which elements are selected | +| [Manipulation](https://github.com/GravityPDF/querypath/wiki/Manipulation) | Changing the document | +| [Markup and Text](https://github.com/GravityPDF/querypath/wiki/Markup-and-Text) | Reading and writing content | +| [Document and Utility](https://github.com/GravityPDF/querypath/wiki/Document-and-Utility) | The match set, the DOM, options, and errors | + +These pages are the source of truth and live in [`docs/`](docs) in this repository — the wiki is +generated from them, so **edit `docs/` and open a pull request** rather than editing the wiki +directly. Contributions are very welcome. + +> ⚠️ The legacy manual at [querypath.org](http://querypath.org/) was generated from QueryPath 2.x +> DocBlocks by phpDocumentor. It is not built or maintained by Gravity PDF, we have no access to +> change it, and parts of it are now wrong — prefer the pages above. ## General Troubleshooting diff --git a/docs/API-Reference.md b/docs/API-Reference.md new file mode 100644 index 00000000..5a4eec39 --- /dev/null +++ b/docs/API-Reference.md @@ -0,0 +1,155 @@ +# API Reference + +Every public method on `QueryPath\DOMQuery`, alphabetically. Follow a link for the full entry. + +The detail pages group the same methods by task: + +| Page | Covers | +|---|---| +| [Traversal and Filtering](Traversal-and-Filtering.md) | Choosing which elements are selected | +| [Manipulation](Manipulation.md) | Changing the document | +| [Markup and Text](Markup-and-Text.md) | Reading and writing content | +| [Document and Utility](Document-and-Utility.md) | The match set, the DOM, options, errors | + +## Reading the "Returns" column + +| Value | Meaning | +|---|---| +| **new** | Returns a **new** `DOMQuery`. The object you called it on is unchanged. | +| **self** | Returns `$this`. Any change is made in place. | +| **self / value** | Setter form returns `$this`; getter form returns a value. | +| Anything else | A plain value — `string`, `int`, `bool`, `array`, `DOMDocument`. | + +This distinction is the most common source of surprise for people coming from jQuery. See +[Objects are not mutated in place](Getting-Started.md#objects-are-not-mutated-in-place). + +## All methods + +| Method | Returns | Summary | +|---|---|---| +| [`add()`](Manipulation.md#add) | self | Query from the document root and merge the results into the match set | +| [`addClass()`](Manipulation.md#addclass) | self | Append a class to every selected element | +| [`after()`](Manipulation.md#after) | self | Insert content as a following sibling | +| [`andSelf()`](Traversal-and-Filtering.md#andself) | self | Merge the previous match set into the current one | +| [`append()`](Manipulation.md#append) | self | Insert content as the last child | +| [`appendTo()`](Manipulation.md#appendto) | self | Append the selected elements into another object | +| [`attach()`](Manipulation.md#attach) | self | Re-insert the nodes remembered by the last `detach()` | +| [`attr()`](Manipulation.md#attr) | self / value | Get or set attributes | +| [`before()`](Manipulation.md#before) | self | Insert content as a preceding sibling | +| [`branch()`](Traversal-and-Filtering.md#branch) | new | Copy the query object, keeping the same document and nodes | +| [`children()`](Traversal-and-Filtering.md#children) | new | Immediate child elements | +| [`childrenText()`](Markup-and-Text.md#childrentext) | string | Concatenated text of the subtree | +| [`cloneAll()`](Manipulation.md#cloneall) | self | Deep-clone the selected nodes and select the copies | +| [`closest()`](Traversal-and-Filtering.md#closest) | new | Nearest match, testing the element itself then its ancestors | +| [`contents()`](Traversal-and-Filtering.md#contents) | new | All immediate child nodes, including text and comments | +| [`count()`](Document-and-Utility.md#count) | int | Number of selected nodes | +| [`css()`](Manipulation.md#css) | self / string | Get or set inline style declarations | +| [`dataURL()`](Markup-and-Text.md#dataurl) | self / array | Read or write an attribute as a data URL | +| [`deepest()`](Traversal-and-Filtering.md#deepest) | new | The furthest descendants of the selected elements | +| [`detach()`](Manipulation.md#detach) | new | Remove elements and remember them for `attach()` | +| [`document()`](Document-and-Utility.md#document) | DOMDocument | The underlying document | +| [`each()`](Traversal-and-Filtering.md#each) | self | Run a callback over each node | +| [`eachLambda()`](Traversal-and-Filtering.md#eachlambda) | — | **Broken on PHP 8.** Use `each()` | +| [`emptyElement()`](Manipulation.md#emptyelement) | self | Deprecated alias of `removeChildren()` | +| [`end()`](Traversal-and-Filtering.md#end) | self | Rewind to the previous match set | +| [`eq()`](Traversal-and-Filtering.md#eq) | new | Reduce to the element at a 0-based index | +| [`even()`](Traversal-and-Filtering.md#even) | new | Elements at odd indexes — the 2nd, 4th, … | +| [`filter()`](Traversal-and-Filtering.md#filter) | new | Keep the selected elements that match a selector | +| [`filterCallback()`](Traversal-and-Filtering.md#filtercallback) | new | Keep elements for which a callback does not return `false` | +| [`filterLambda()`](Traversal-and-Filtering.md#filterlambda) | — | **Broken on PHP 8.** Use `filterCallback()` | +| [`filterPreg()`](Traversal-and-Filtering.md#filterpreg) | new | Keep elements whose text matches a regex | +| [`find()`](Traversal-and-Filtering.md#find) | new | Search descendants with a CSS selector | +| [`findInPlace()`](Traversal-and-Filtering.md#findinplace) | self | `find()`, mutating the current object | +| [`first()`](Traversal-and-Filtering.md#first) | new | Reduce to the first element | +| [`firstChild()`](Traversal-and-Filtering.md#firstchild) | new | First child element (see the known issue) | +| [`get()`](Document-and-Utility.md#get) | array / node | The raw `DOMNode`s | +| [`getIterator()`](Document-and-Utility.md#getiterator) | Traversable | Yields `DOMQuery` objects to `foreach` | +| [`getOptions()`](Document-and-Utility.md#getoptions) | array | The effective options for this object | +| [`has()`](Traversal-and-Filtering.md#has) | new | Keep elements containing a match | +| [`hasAttr()`](Manipulation.md#hasattr) | bool | Whether **every** selected element has the attribute | +| [`hasClass()`](Manipulation.md#hasclass) | bool | Whether **any** selected element has the class | +| [`html()`](Markup-and-Text.md#html) | self / string | Get or set HTML 4.01 markup, element included | +| [`html5()`](Markup-and-Text.md#html5) | self / string | Get or set HTML5 markup, element included | +| [`index()`](Document-and-Utility.md#index) | int / false | Position of a node in the match set | +| [`innerHTML()`](Markup-and-Text.md#innerhtml) | string | Child markup — an alias of `innerXML()` | +| [`innerHTML5()`](Markup-and-Text.md#innerhtml5) | string | Child markup, HTML5-serialised | +| [`innerXHTML()`](Markup-and-Text.md#innerxhtml) | string | Child markup with closing tags everywhere | +| [`innerXML()`](Markup-and-Text.md#innerxml) | string | Child markup, XML-serialised | +| [`insertAfter()`](Manipulation.md#insertafter) | self | Insert the selected elements after another object's elements | +| [`insertBefore()`](Manipulation.md#insertbefore) | self | Insert the selected elements before another object's elements | +| [`is()`](Traversal-and-Filtering.md#is) | bool | Whether any selected element matches | +| [`last()`](Traversal-and-Filtering.md#last) | new | Reduce to the last element | +| [`lastChild()`](Traversal-and-Filtering.md#lastchild) | new | Last child element of each selected element | +| [`map()`](Traversal-and-Filtering.md#map) | new | Replace the match set with a callback's return values | +| [`next()`](Traversal-and-Filtering.md#next) | new | The next sibling element | +| [`nextAll()`](Traversal-and-Filtering.md#nextall) | new | All following siblings | +| [`nextUntil()`](Traversal-and-Filtering.md#nextuntil) | new | Following siblings, stopping before a match | +| [`not()`](Traversal-and-Filtering.md#not) | new | Drop the elements that match | +| [`ns()`](Document-and-Utility.md#ns) | string | Namespace URI of the first element | +| [`odd()`](Traversal-and-Filtering.md#odd) | new | Elements at even indexes — the 1st, 3rd, … | +| [`parent()`](Traversal-and-Filtering.md#parent) | new | Immediate parent, or nearest matching ancestor | +| [`parents()`](Traversal-and-Filtering.md#parents) | new | All ancestors | +| [`parentsUntil()`](Traversal-and-Filtering.md#parentsuntil) | new | Ancestors, stopping before a match | +| [`prepend()`](Manipulation.md#prepend) | self | Insert content as the first child | +| [`prependTo()`](Manipulation.md#prependto) | self | Prepend the selected elements into another object | +| [`prev()`](Traversal-and-Filtering.md#prev) | new | The previous sibling element | +| [`prevAll()`](Traversal-and-Filtering.md#prevall) | new | All preceding siblings, in reverse document order | +| [`prevUntil()`](Traversal-and-Filtering.md#prevuntil) | new | Preceding siblings, stopping before a match | +| [`remove()`](Manipulation.md#remove) | new | Remove elements; returns the removed nodes | +| [`removeAttr()`](Manipulation.md#removeattr) | self | Remove an attribute from every selected element | +| [`removeChildren()`](Manipulation.md#removechildren) | self | Remove all child nodes — jQuery's `empty()` | +| [`removeClass()`](Manipulation.md#removeclass) | self | Remove one class, or the whole attribute | +| [`replaceAll()`](Manipulation.md#replaceall) | new | Deprecated; replace matches in another document | +| [`replaceWith()`](Manipulation.md#replacewith) | new | Replace elements; returns the removed nodes | +| [`setMatches()`](Document-and-Utility.md#setmatches) | void | Expert-level: set the match set directly | +| [`siblings()`](Traversal-and-Filtering.md#siblings) | new | All siblings of each selected element | +| [`size()`](Document-and-Utility.md#size) | int | Deprecated alias of `count()` | +| [`slice()`](Traversal-and-Filtering.md#slice) | new | A contiguous run of the match set | +| [`sort()`](Document-and-Utility.md#sort) | new | Reorder the match set, and optionally the DOM | +| [`tag()`](Document-and-Utility.md#tag) | string | Tag name of the first element | +| [`text()`](Markup-and-Text.md#text) | self / string | Get or set text content | +| [`textAfter()`](Markup-and-Text.md#textafter) | self / string | Text immediately following each element | +| [`textBefore()`](Markup-and-Text.md#textbefore) | self / string | Text immediately preceding each element | +| [`textImplode()`](Markup-and-Text.md#textimplode) | string | Each element's text, joined by a separator | +| [`toArray()`](Document-and-Utility.md#toarray) | array | The raw `DOMNode`s | +| [`top()`](Traversal-and-Filtering.md#top) | new | Select the document element | +| [`unwrap()`](Manipulation.md#unwrap) | self | Remove each element's parent | +| [`val()`](Manipulation.md#val) | self / string | Deprecated shorthand for the `value` attribute | +| [`wrap()`](Manipulation.md#wrap) | self | Wrap each element individually | +| [`wrapAll()`](Manipulation.md#wrapall) | self | Wrap all elements in one wrapper | +| [`wrapInner()`](Manipulation.md#wrapinner) | self | Wrap the children of each element | +| [`writeHTML()`](Markup-and-Text.md#writehtml) | self | Write the document as HTML 4.01 | +| [`writeHTML5()`](Markup-and-Text.md#writehtml5) | **null** | Write the document as HTML5 — does not chain | +| [`writeXHTML()`](Markup-and-Text.md#writexhtml) | self | Write the document as XHTML | +| [`writeXML()`](Markup-and-Text.md#writexml) | self | Write the document as XML | +| [`xhtml()`](Markup-and-Text.md#xhtml) | self / string | Get or set XHTML markup | +| [`xinclude()`](Document-and-Utility.md#xinclude) | self | Process XInclude directives | +| [`xml()`](Markup-and-Text.md#xml) | self / string | Get or set XML markup | +| [`xpath()`](Traversal-and-Filtering.md#xpath) | new | Run an XPath query | + +## Beyond `DOMQuery` + +| Where | What | +|---|---| +| [`QueryPath::*`](Document-and-Utility.md#static-entry-points) | Static factories, extension registration, `encodeDataURL()` | +| [`QueryPath\Options`](Document-and-Utility.md#querypathoptions) | Global option defaults | +| [`QueryPath\ExtensionRegistry`](Writing-Extensions.md#managing-the-registry-directly) | Extension registration internals | +| [Bundled extensions](Writing-Extensions.md#the-bundled-extensions) | `QPXML`, `QPXSL`, `Format` | + +## Known issues at a glance + +Behaviours verified against the current release that are likely to surprise you: + +| Method / syntax | Issue | +|---|---| +| `X > *` | Raises a `TypeError`. Use [`children()`](Traversal-and-Filtering.md#children) | +| `filterLambda()`, `eachLambda()` | Raise an `Error` on PHP 8 — built on the removed `create_function()` | +| `not($splObjectStorage)` | Inverted: keeps those nodes instead of removing them. Pass an array | +| `detach($selector)` | The selector is ignored. Use `find($selector)->detach()` | +| `firstChild()` | Returns at most one node regardless of how many elements are selected | +| `css()` | Pools styles across the whole match set and writes the union to all of them | +| `writeHTML5()` | Returns `null`, so it cannot be chained | +| `remove($selector)`, `replaceAll()` | Use the [legacy selector engine](CSS-Selector-Reference.md#two-selector-engines) | +| `hasAttr()` | Returns `true` on an empty match set | +| `QueryPath::VERSION` | Still reads `3.2.2`; not the installed version | +| `odd()` / `even()` | Named by 1-based ordinal, so `odd()` returns even indexes | diff --git a/docs/CSS-Selector-Reference.md b/docs/CSS-Selector-Reference.md new file mode 100644 index 00000000..f0fcbaa6 --- /dev/null +++ b/docs/CSS-Selector-Reference.md @@ -0,0 +1,255 @@ +# CSS Selector Reference + +QueryPath implements CSS 3 selectors, plus parts of the CSS 4 selector draft and most of the jQuery +pseudo-class extensions. Selectors can be passed to `qp()`, `htmlqp()`, `html5qp()`, and to +`find()`, `top()`, `children()`, `filter()`, `not()`, `has()` and others. + +```php +$qp = html5qp($html, 'body'); // find the body +$another = $qp->branch('p'); // a second object searching body for p tags +$qp->find('strong > a'); // a elements directly inside strong elements +$qp->top('head'); // start over at the document root, find head +``` + +XPath is available too, via `xpath()`: + +```php +qp($xml)->xpath('//foo'); +``` + +> Everything on this page was verified against the current release. Where QueryPath differs from +> jQuery or from the CSS spec, the difference is called out — several of them are surprising. + +## Contents + +- [Basic selectors](#basic-selectors) +- [Combinators](#combinators) +- [Attribute selectors](#attribute-selectors) +- [Pseudo-classes](#pseudo-classes) + - [Position and counting](#position-and-counting) — **1-indexed, unlike jQuery** + - [Structural](#structural) + - [Content](#content) + - [Links](#links) + - [Form](#form) + - [Scope](#scope) + - [Always false](#always-false-user-agent-dependent) + - [Special cases](#special-cases) +- [Pseudo-elements](#pseudo-elements) +- [XML namespaces](#xml-namespaces) +- [Two selector engines](#two-selector-engines) + +## Basic selectors + +| Selector | Matches | +|---|---| +| `p` | All `p` elements | +| `*` | Any element | +| `#my-id` | The element with `id="my-id"` | +| `div.content` | `div` elements with `content` in their class list | +| `.a.b` | Elements carrying both classes | +| `h1, h2` | All `h1` **and** all `h2` (selector group) | + +## Combinators + +| Selector | Matches | +|---|---| +| `strong a` | `a` anywhere beneath a `strong` (descendant) | +| `strong > a` | `a` directly beneath a `strong` (child) | +| `h1 + p` | The `p` immediately following an `h1` (adjacent sibling) | +| `h1 ~ p` | Any `p` following an `h1` at the same level (general sibling) | +| `:root > head` | `head` directly beneath the document root | + +> **The child combinator crashes when its right-hand side matches the document element.** In +> practice this means `X > *` always raises a `TypeError` from the selector engine, whatever `X` is: +> +> ```php +> qp($xml, 'wrap > *'); // TypeError +> qp($xml, 'wrap > a'); // fine +> qp($xml, 'wrap *'); // fine — descendant combinator +> qp($xml, 'wrap')->children(); // fine — the intended replacement +> ``` +> +> `combineDirectDescendant()` hands the parent node straight to a method typed `DOMElement`, and the +> document element's parent is the `DOMDocument`. Use +> [`children()`](Traversal-and-Filtering.md#children) until this is fixed. + +## Attribute selectors + +| Selector | Matches | +|---|---| +| `[href]` | Has an `href` attribute | +| `[href="x"]` | `href` is exactly `x` | +| `[href~="x"]` | `href` is a space-separated list containing `x` | +| `[href\|="x"]` | `href` is `x`, or begins `x-` | +| `[href^="x"]` | `href` begins with `x` | +| `[href$="x"]` | `href` ends with `x` | +| `[href*="x"]` | `href` contains `x` | + +## Pseudo-classes + +### Position and counting + +> **These are 1-indexed. jQuery's equivalents are 0-indexed.** Porting a jQuery selector across +> without adjusting the number will select the wrong element — or nothing at all. + +Against `
  • a
  • b
  • c
  • d
  • e
`: + +| Selector | QueryPath matches | jQuery would match | +|---|---|---| +| `li:eq(0)` | *nothing* | `a` | +| `li:eq(1)` | `a` | `b` | +| `li:eq(2)` | `b` | `c` | +| `li:nth(1)` | `a` | — | +| `li:first` | `a` | `a` | +| `li:last` | `e` | `e` | +| `li:lt(2)` | `a`, `b` | `a`, `b` | +| `li:gt(2)` | `c`, `d`, `e` | `d`, `e` | +| `li:even` | `b`, `d` | `a`, `c`, `e` | +| `li:odd` | `a`, `c`, `e` | `b`, `d` | + +`:even` and `:odd` count from 1, so **the first element is odd**. + +`:lt()` and `:gt()` are asymmetric: `:lt(n)` is "position ≤ n" (inclusive), `:gt(n)` is +"position > n" (exclusive). `li:lt(2)` and `li:gt(2)` therefore both include position 2 and +exclude it respectively — they are not complements. + +`:nth-child()` and friends follow the CSS spec and take `an+b`, `odd`, or `even`: + +| Selector | Matches | +|---|---| +| `li:nth-child(1)` | `a` | +| `li:nth-child(2n)` | `b`, `d` | +| `li:nth-child(odd)` | `a`, `c`, `e` | +| `:nth-last-child(n)` | As above, counting from the end | +| `:nth-of-type(n)` | Every nth element of that tag name | +| `:nth-last-of-type(n)` | As above, counting from the end | + +### Structural + +| Selector | Matches | +|---|---| +| `:root` | The document root element | +| `:first-child` / `:last-child` | First / last child of its parent | +| `:only-child` | Only if it has no siblings | +| `:first-of-type` / `:last-of-type` | First / last of its tag name | +| `:only-of-type` | Only element of its tag name among siblings | +| `:empty` | Has no child nodes | +| `:parent` | Has child nodes (the inverse of `:empty`) | + +### Content + +| Selector | Matches | +|---|---| +| `p:contains(Hello)` | Text content contains `Hello` (substring match) | +| `p:contains-exactly(Hello)` | Text content is exactly `Hello` (**not** a substring match) | +| `:has(strong > a)` | Has a descendant matching the given selector | +| `:matches(sel)` | Alias of `:has()` | +| `:not(.nav)` | Negation. Takes a full selector. Throws `ParseException` with no value | + +### Links + +| Selector | Matches | +|---|---| +| `:link` | Has an `href` attribute | +| `:any-link` | Has an `href`, `src`, or `link` attribute (CSS 4) | +| `:local-link` | A link pointing within the current document (CSS 4) | + +### Form + +`:enabled`, `:disabled` and `:checked` match on the presence of the attribute of that name. + +`:text`, `:radio`, `:checkbox`, `:file`, `:password`, `:submit`, `:image`, `:reset` and `:button` +match `input` elements by their `type` attribute: + +```php +html5qp($html)->find('form input:text'); // all text inputs in a form +``` + +`:header` matches `h1` through `h6`. + +### Scope + +`:scope` (CSS 4) matches the element that was passed into the QueryPath constructor, rather than +the document root. `:x-root` and `:x-reset` are QueryPath's older names for the same thing and +remain supported. + +### Always false (user-agent dependent) + +These parse without error and match nothing, because a server-side library has no user agent, +no viewport, no history, and no location: + +`:current`, `:past`, `:future`, `:visited`, `:hover`, `:active`, `:focus`, `:animated`, +`:visible`, `:hidden`, `:target` + +These also always return false, because QueryPath does not validate documents or resolve +text direction: + +`:valid`, `:invalid`, `:required`, `:optional`, `:read-only`, `:read-write`, `:dir()`, +`:nth-column()`, `:nth-last-column()` + +### Special cases + +**`:indeterminate` returns a random result.** It is implemented as a coin flip per element, so the +same selector against the same document returns a different match set each time it runs. Do not +use it. + +**`:lang()` is implemented**, but requires a value — `:lang()` with no argument throws +`NotImplementedException`. Note that it does not implement the full spec. + +**An unrecognised pseudo-class throws `ParseException`** (`Unknown Pseudo-Class: …`) rather than +matching nothing. + +## Pseudo-elements + +Pseudo-elements use the double-colon syntax and are only partially meaningful server-side: + +| Selector | Behaviour | +|---|---| +| `::first-line` | Matches the element if it has any text content | +| `::first-letter` | Matches the element if it has any text content | +| `::before` | Matches the element if it has any text content | +| `::after` | Matches the element if it has any text content | +| `::selection` | Throws `NotImplementedException` | + +> `::first-line` and `::first-letter` **do not extract a line or a letter**. All four of the +> supported pseudo-elements resolve to the same test — "does this element have text?" — and return +> the whole element. `qp($xml, 'p::first-letter')->text()` returns the entire paragraph text, not +> its first character. + +## XML namespaces + +CSS namespace syntax uses a vertical bar, **not** the colon used in the XML tag itself. To select +``, the selector is `atom|entry`: + +```php +qp($xml, 'atom|entry'); // all elements +qp($xml, 'atom|entry > xmedia|video'); // directly inside +qp($xml, '*|entry'); // any namespace, tag name "entry" +``` + +QueryPath resolves namespaces to short names where it can, but a malformed namespace declaration +can prevent namespace queries from resolving. + +## Two selector engines + +QueryPath contains two independent CSS engines. `find()` and most traversal methods use the +current one; **`remove()` and `replaceAll()` still use the legacy engine**, which does not support +the full selector set and does not always agree with `find()`. + +Confirmed differences against `
  • a
  • e
`: + +| Selector | `find()` | `remove()` | +|---|---|---| +| `li:lt(2)` | matches 2 elements | removes 1 element | +| `li:any-link` | matches 0 | throws `ParseException` | +| `li:scope` | matches 0 | throws `ParseException` | + +If a selector behaves differently than this page describes, check first whether you are calling it +through `remove()` or `replaceAll()`. Consolidating onto a single engine is tracked work. + +## See also + +- [Parser Options](Parser-Options.md) — every option accepted by `qp()`, `htmlqp()` and `html5qp()` +- [Getting Started](Getting-Started.md) +- The `examples/` directory in the repository +- [API Reference](API-Reference.md) — every method, with a known-issues summary diff --git a/docs/Document-and-Utility.md b/docs/Document-and-Utility.md new file mode 100644 index 00000000..30313ca5 --- /dev/null +++ b/docs/Document-and-Utility.md @@ -0,0 +1,236 @@ +# Document and Utility + +Inspecting the match set, reaching the underlying DOM, and the static entry points. + +## Contents + +- [Counting and indexing](#counting-and-indexing) — `count()`, `size()`, `length`, `index()`, `tag()` +- [Getting nodes out](#getting-nodes-out) — `get()`, `toArray()`, `getIterator()` +- [Reaching the DOM](#reaching-the-dom) — `document()`, `ns()`, `xinclude()`, `setMatches()` +- [Sorting](#sorting) — `sort()` +- [Options](#options) — `getOptions()`, `QueryPath\Options` +- [Static entry points](#static-entry-points) — `QueryPath::with()` and friends +- [Constants](#constants) — the document stubs +- [Errors](#errors) — the exception hierarchy + +## Counting and indexing + +### `count()` + +The number of selected nodes. `DOMQuery` implements `Countable`, so `count($qp)` works too. + +**Returns** `int`. + +### `size()` + +A deprecated alias of `count()`. + +### `length` + +A public property holding the same number, refreshed whenever the match set changes. + +```php +$qp->find('li')->length; // same as ->count() +``` + +> The docblock on `size()` claims there is no `length` property. There is, and it is kept current. + +### `index()` + +The 0-based position of a given node in the match set. + +```php +$i = $qp->index($someDomElement); +if ($i !== false) { … } +``` + +**Returns** `int`, or `false` when the node is not in the set. Because `0` is a valid answer, always +compare with `!==`. + +### `tag()` + +The tag name of the first selected element. + +**Returns** `string` — `''` when nothing is selected. + +## Getting nodes out + +### `get()` + +Reach the raw DOM nodes. + +```php +$qp->get(); // array of DOMNode +$qp->get(2); // the third node, or null if out of range +$qp->get(null, true); // the internal SplObjectStorage +``` + +**Returns** an `array`, a single `DOMNode`, `null`, or an `SplObjectStorage` when `$asObject` is +`true`. Non-destructive. The `SplObjectStorage` form is the one extensions should use. + +### `toArray()` + +Identical to `get()` with no arguments. Provided for jQuery 1.4 familiarity. + +**Returns** `array`. + +### `getIterator()` + +Called for you by `foreach`. Iterating a `DOMQuery` yields **`DOMQuery` objects**, one per node, not +raw `DOMNode`s — so the whole API is available on each item. + +```php +foreach ($qp->find('li') as $li) { + echo $li->attr('id'), ': ', $li->text(), "\n"; +} +``` + +Use `get()` when you want the `DOMNode`s themselves. + +## Reaching the DOM + +### `document()` + +The underlying `DOMDocument`. It is shared, not copied — changes made through the DOM API are +visible to QueryPath and vice versa. + +**Returns** `DOMDocument`. + +### `ns()` + +The namespace URI of the first selected element. + +**Returns** `string`, or `null` for an element in no namespace. Throws if nothing is selected. + +### `xinclude()` + +Process XInclude directives in the document, by calling `DOMDocument::xinclude()`. + +**Returns** `$this`. + +### `setMatches()` + +Replace the match set directly with an `SplObjectStorage`, array, or single node. + +**Returns** `void`. This is an expert-level hook used internally and by extensions; it bypasses +every selector and consistency check. It also updates the `end()`/`andSelf()` history and the +`length` property. + +## Sorting + +### `sort()` + +Reorder the match set with a comparator, optionally reordering the DOM to match. + +```php +$comparator = function (DOMNode $a, DOMNode $b) { + return strcmp($a->textContent, $b->textContent); +}; + +$sorted = $qp->find('li')->sort($comparator); // sorts the match set only +$qp->find('li')->sort($comparator, true); // also reorders the document +``` + +**Returns** a new `DOMQuery` — the object you called it on keeps its original order, despite the +docblock saying "This object". + +With `$modifyDOM = true`, the sorted nodes are reinserted at the position the **first** node of the +original set occupied. If the selected elements did not all share a parent, they all end up under +that first node's parent. + +## Options + +### `getOptions()` + +The effective options for this object, after merging the three sources. + +**Returns** `array`. See [Parser Options](Parser-Options.md) for every key and the precedence rules. + +### `QueryPath\Options` + +Global defaults, applied to every object created afterwards. + +| Method | Purpose | +|---|---| +| `Options::set(array $array)` | Replace the global defaults wholesale | +| `Options::merge(array $array)` | Merge into the existing defaults | +| `Options::get()` | The current global defaults | +| `Options::has(string $key)` | Whether a default is set for this key | + +```php +\QueryPath\Options::merge(['format_output' => false]); +``` + +Remember the leading backslash — inside a namespace, `QueryPath\Options` resolves relative to the +current namespace. + +## Static entry points + +`QueryPath\QueryPath` holds the static factories. The global functions in +[Getting Started](Getting-Started.md#the-three-factories) are thin wrappers over the first three. + +| Method | Notes | +|---|---| +| `QueryPath::with($document, $selector, $options)` | The general entry point; `qp()` calls this | +| `QueryPath::withXML($source, $selector, $options)` | Forces `use_parser: 'xml'` | +| `QueryPath::withHTML($source, $selector, $options)` | Legacy libxml HTML; `htmlqp()` calls this | +| `QueryPath::withHTML5($source, $selector, $options)` | `masterminds/html5`; `html5qp()` calls this | +| `QueryPath::enable($extensionNames)` | Register one extension class, or an array of them | +| `QueryPath::enabledExtensions()` | The registered class names | +| `QueryPath::encodeDataURL($data, $mime, $context)` | Build a data URL without a document | + +Extension registration is covered in [Writing Extensions](Writing-Extensions.md). + +## Constants + +`QueryPath::HTML_STUB`, `QueryPath::HTML5_STUB` and `QueryPath::XHTML_STUB` are minimal, valid +documents to build from: + +```php +html5qp(QueryPath::HTML5_STUB, 'body') + ->append('

Title

') + ->top() + ->writeHTML5(); +``` + +`QueryPath::VERSION` and `QueryPath::VERSION_MAJOR` also exist, but **do not reflect the installed +release** — they still read `3.2.2` / `3`. Read the version from Composer +(`composer show gravitypdf/querypath`) rather than from these constants. + +The CDATA-escaping constants (`DOM::JS_CSS_ESCAPE_*`) are documented under +[`escape_xhtml_js_css_sections`](Parser-Options.md#escape_xhtml_js_css_sections). + +## Errors + +Every throwable in the library descends from `QueryPath\Exception`, so one catch covers the library: + +```php +try { + $qp = html5qp($source); +} catch (\QueryPath\Exception $e) { + // parse errors, IO errors, selector errors +} +``` + +| Class | Raised when | +|---|---| +| `QueryPath\Exception` | The base; also thrown directly for unsupported input and bad callbacks | +| `QueryPath\ParseException` | A document fails to parse; also from `writeHTML()` on an unwritable path | +| `QueryPath\IOException` | `writeXML()` / `writeXHTML()` cannot write the file | +| `QueryPath\CSS\ParseException` | A selector cannot be parsed | +| `QueryPath\CSS\NotImplementedException` | A selector parses but the engine cannot evaluate it | + +Note that `QueryPath\ParseException` and `QueryPath\CSS\ParseException` are different classes. + +Two failures escape this hierarchy and surface as raw PHP errors: + +- `X > *` and other child-combinator selectors that match the document element raise a `TypeError` + (see [Traversal and Filtering](Traversal-and-Filtering.md#child-combinator-with-the-universal-selector)) +- `filterLambda()` and `eachLambda()` raise an `Error` on PHP 8, having been built on the removed + `create_function()` + +## See also + +- [Parser Options](Parser-Options.md) +- [Writing Extensions](Writing-Extensions.md) +- [API Reference](API-Reference.md) — every method, alphabetically diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md new file mode 100644 index 00000000..2fd6c456 --- /dev/null +++ b/docs/Getting-Started.md @@ -0,0 +1,128 @@ +# Getting Started + +QueryPath is a PHP library for working with XML and HTML documents, modelled on jQuery's traversal +and manipulation API. + +```bash +composer require gravitypdf/querypath +``` + +## The three factories + +QueryPath has three global functions, each backed by a static method. Which one you want depends on +what you are parsing. + +| Function | Equivalent to | Parser | Use for | +|---|---|---|---| +| `html5qp()` | `QueryPath::withHTML5()` | `masterminds/html5` | **HTML — recommended** | +| `htmlqp()` | `QueryPath::withHTML()` | libxml | Legacy HTML, when you need libxml's behaviour | +| `qp()` | `QueryPath::with()` | libxml | XML and XHTML | + +All three return a `QueryPath\DOMQuery`, and all three accept a file path, a URL, a markup string, +or an existing DOM object. + +```php +require_once __DIR__ . '/vendor/autoload.php'; + +try { + $qp = html5qp(__DIR__ . '/page.html'); // a file + $qp = html5qp('https://example.com/page.html'); // a URL + $qp = html5qp('
markup passed directly
'); // a string +} catch (\QueryPath\Exception $e) { + // every QueryPath throwable descends from this +} +``` + +`qp()` additionally accepts a `DOMDocument`, a `DOMNode`, a `SimpleXMLElement`, an array of +`DOMNode`s, or another `DOMQuery`. + +> Catch `\QueryPath\Exception`. Every exception the library throws — parse errors, IO errors, +> selector errors — descends from it. + +## A first query + +```php +$html = '
  • Foo
  • Bar
  • FooBar
'; + +foreach (html5qp($html)->find('li') as $li) { + echo $li->text(), "\n"; +} +``` + +Iterating a `DOMQuery` yields `DOMQuery` objects, not raw `DOMNode`s, so the full API is available +on each item. + +## Chaining + +Most methods return a `DOMQuery`, so calls chain: + +```php +echo html5qp(QueryPath::HTML5_STUB, 'body') + ->append('

Title

') + ->addClass('body-class') + ->top() + ->html5(); +``` + +## Objects are not mutated in place + +This is the most important thing to understand, and it is where older QueryPath documentation is +wrong. + +**`find()` returns a new object. The object you called it on is left alone.** + +```php +$qp = html5qp('

one

two

'); +$found = $qp->find('p'); + +$found->count(); // 2 — the paragraphs +$qp->count(); // 1 — still the original match set, unchanged +$found === $qp; // false +``` + +If you want the mutating behaviour, use `findInPlace()`: + +```php +$qp = html5qp('

one

two

'); +$qp->findInPlace('p'); + +$qp->count(); // 2 — $qp itself now holds the paragraphs +``` + +> Documentation predating QueryPath 3 states that "QueryPath does not return a new object for each +> call… the same object is mutated from call to call." That has not been true for a long time. +> `find()` is the non-mutating variant; `findInPlace()` is the mutating one. + +### Stepping back with `end()` + +Because each call produces a new object, `end()` can return you to the previous match set: + +```php +$qp->find('p')->addClass('para')->end(); // back to the match set before find('p') +``` + +### Working with two sets at once via `branch()` + +`branch()` clones the object so you can hold on to two positions in the same document: + +```php +$body = html5qp($html, 'body'); +$paras = $body->branch('p'); // a second object, searching body for p tags +``` + +## Where QueryPath differs from jQuery + +- **The jQuery-style positional pseudo-classes are 1-indexed**, where jQuery's are 0-indexed. + `:eq(1)` is the first element, and `:eq(0)` matches nothing. See the + [CSS Selector Reference](CSS-Selector-Reference.md#position-and-counting). +- QueryPath adds methods jQuery has no need for — `top()`, `dataURL()`, `writeXML()`, + `filterPreg()`, `xpath()` — and omits everything to do with events, effects, and Ajax. +- Some CSS pseudo-classes cannot mean anything without a browser (`:hover`, `:visited`, + `:target`, …). They parse, and match nothing. + +## Next steps + +- [CSS Selector Reference](CSS-Selector-Reference.md) — every supported selector, with the gotchas +- [Parser Options](Parser-Options.md) — every option the factories accept +- [Writing Extensions](Writing-Extensions.md) — adding your own methods to the fluent API +- The `examples/` directory in the repository holds runnable scripts for each of the above diff --git a/docs/Home.md b/docs/Home.md new file mode 100644 index 00000000..6e42eda9 --- /dev/null +++ b/docs/Home.md @@ -0,0 +1,64 @@ +# QueryPath + +A jQuery-like library for working with XML and HTML(5) documents in PHP. + +```bash +composer require gravitypdf/querypath +``` + +## Documentation + +### Guides + +| Page | What's in it | +|---|---| +| [Getting Started](Getting-Started.md) | The three factories, chaining, and how object identity works | +| [CSS Selector Reference](CSS-Selector-Reference.md) | Every supported selector, with the jQuery differences called out | +| [Parser Options](Parser-Options.md) | Every option `qp()`, `htmlqp()` and `html5qp()` accept | +| [Writing Extensions](Writing-Extensions.md) | Adding your own methods to the fluent API | + +### API reference + +| Page | What's in it | +|---|---| +| [API Reference](API-Reference.md) | Every method, alphabetically, with a known-issues summary | +| [Traversal and Filtering](Traversal-and-Filtering.md) | Choosing which elements are selected | +| [Manipulation](Manipulation.md) | Changing the document | +| [Markup and Text](Markup-and-Text.md) | Reading and writing content | +| [Document and Utility](Document-and-Utility.md) | The match set, the DOM, options, and errors | + +### Community guides + +- [How to parse HTML in PHP using querypath](How-to-parse-HTML-in-PHP-using-querypath-library.md) — a + web-scraping oriented walkthrough + +## Quick example + +```php +require_once __DIR__ . '/vendor/autoload.php'; + +$html = '
  • Foo
  • Bar
  • FooBar
'; + +foreach (html5qp($html)->find('li') as $li) { + echo $li->text(), "\n"; +} +``` + +## Editing these pages + +**This wiki is generated.** The pages live in [`docs/`](https://github.com/GravityPDF/querypath/tree/main/docs) +in the main repository and are pushed here automatically when `main` changes. + +Edit the files in `docs/` and open a pull request — **edits made directly in the wiki UI will be +overwritten** on the next sync. + +## Elsewhere + +- [Repository](https://github.com/GravityPDF/querypath) +- [Issues](https://github.com/GravityPDF/querypath/issues) +- [Discussions](https://github.com/GravityPDF/querypath/discussions) +- [Packagist](https://packagist.org/packages/gravitypdf/querypath) +- `examples/` in the repository — runnable scripts covering each part of the API + +> The legacy manual at [querypath.org](http://querypath.org/) documents QueryPath 2.x and is not +> maintained by Gravity PDF. Prefer the pages above. diff --git a/docs/How-to-parse-HTML-in-PHP-using-querypath-library.md b/docs/How-to-parse-HTML-in-PHP-using-querypath-library.md new file mode 100644 index 00000000..a7178a57 --- /dev/null +++ b/docs/How-to-parse-HTML-in-PHP-using-querypath-library.md @@ -0,0 +1,87 @@ +# How to parse HTML in PHP using querypath library +## Intro: +Querypath - HTML DOM parsing and manipulation PHP library +Original is abandoned. Fork alive here: https://github.com/GravityPDF/querypath + + +> This is a guide that explains how to parse html,xml documents using querypath. Written from the point of view of web-scraping + +##### Sources +Article on ibm.com: [Archive.org link](https://web.archive.org/web/20160723193833/http://www.ibm.com/developerworks/opensource/library/os-php-querypath/index.html?S_TACT=105AGX01&S_CMP=HP) + That link is now dead and un-googleable. So its content now can be freely stolen without guilt. + + +API docs relevant to parsing: http://querypath.org/classes/QueryPath.DOMQuery.html + + +## Guide: +Quick example +```php +//Create a new QueryPath object and supply it with source $html page +$qp = QueryPath::withHTML($html); +// find desired html nodes +$linkNodes = $qp->find('a') +//Loop through all the links in the page +foreach ($linkNodes as $li) { + echo $li->text() ; +} +// Quickly get title text +$titleText = $qp->find('title')->text(); +``` +Generally this is the flow: +- We create a querypath object and supply it with the html source. +- Then Various traversing functions can be used to find matching html nodes. +- We can then optionally loop through the nodes +- Finally we can use `attr()` or `text()` or other functions to extract from individual nodes + + ## Common traversing methods + +| Method | Description | Takes CSS selector? | +|--------|:-----------:|--------------------:| +| find() | Select any element (beneath the currently selected nodes) that matches the selector | Yes | +| xpath() | Select any elements matching the given XPath query | No (XPath query instead) | +| top() | Select the document element (the root element) | No | +| parents() | Select any ancestor element | Yes | +| parent() | Select the direct parent element | Yes | +| siblings() | Select all siblings (both previous and next) | Yes | +| next() | Select the next sibling element | Yes | +| nextAll() | Select all siblings after the present element | Yes | +| prev() | Select the previous sibling | Yes | +| prevAll() | Select all previous siblings | Yes | +| children() | Select elements immediately beneath this one | Yes | +| deepest() | Select the deepest node or nodes beneath this one | No | + +![stolen_image_querypath.jpg](stolen_image_querypath.jpg) + +> Observe: the traversing functions can accept css/xpath selectors to narrow down the search. + +## Common functions to extract data from nodes +```php +text() // Get combined text contents of each element in the set of matched elements, including their descendants. +attr('src') // Get value of an attribute with a given name. +html() // Get HTML contents of matching node +innerHtml() // Get the HTML contents INSIDE the node. +``` + +> IMPORTANT: If traversing functions match multiple nodes. The above functions will return data from first node. + +Example: `find('a')` matches multiple links. `text()` will return text from first link. +## Advanced usage examples: + +###### Convert encoding of html page to utf-8 +```php +htmlqp($html, 'body', array('convert_to_encoding' => 'utf-8'))->children('p.a'); +``` + +###### Use chain of traversing functions to find nodes +```php +$tr = $this->qp->top('body')->find('table[id="main"]')->find('tr:nth-child(3)'); +``` +Here `top('body')` gets the top most ancestor matching the selector. +The next find commands use css selectors. +Same can be written using an xpath +```php +$tr = $this->qp->xpath('//body/table[@id="main"]/tr[3]'); +``` + +TODO : add more examples as we find them diff --git a/docs/Manipulation.md b/docs/Manipulation.md new file mode 100644 index 00000000..26f37e1e --- /dev/null +++ b/docs/Manipulation.md @@ -0,0 +1,380 @@ +# Manipulation + +Methods that **change the document**: inserting, moving, removing, wrapping, and editing attributes. + +Most methods here return `$this`, so they chain. The exceptions — noted per method — are +`replaceWith()`, `detach()` and `remove()`, which return a new `DOMQuery` wrapping the nodes that +were taken out. + +## Contents + +- [Inserting content](#inserting-content) — `append()`, `prepend()`, `before()`, `after()` +- [Inserting into another object](#inserting-into-another-object) — `appendTo()`, `prependTo()`, `insertBefore()`, `insertAfter()` +- [Removing](#removing) — `remove()`, `detach()`, `attach()`, `removeChildren()`, `emptyElement()` +- [Replacing](#replacing) — `replaceWith()`, `replaceAll()` +- [Wrapping](#wrapping) — `wrap()`, `wrapAll()`, `wrapInner()`, `unwrap()` +- [Attributes](#attributes) — `attr()`, `removeAttr()`, `hasAttr()`, `val()` +- [Classes](#classes) — `addClass()`, `removeClass()`, `hasClass()` +- [Inline styles](#inline-styles) — `css()` +- [Changing the match set](#changing-the-match-set) — `add()`, `cloneAll()` + +## What counts as content + +Everywhere a method below takes `$data` or `$markup`, you may pass: + +- a markup string (`'

hi

'`) — the usual case +- a `DOMNode` or `DOMDocumentFragment` +- a `SimpleXMLElement` +- another `DOMQuery` + +**Inserted nodes are always cloned.** A DOM node can only live at one place in a document, so +QueryPath copies it on the way in. Once inserted, the node you passed and the node in the document +are two separate objects — modifying the original will not change the document. Re-select the +inserted node if you need to work on it further. + +## Inserting content + +### `append()` + +Insert as the **last child** of each selected element. + +```php +$qp->find('ul')->append('
  • Last
  • '); +``` + +**Returns** `$this`. + +If the document is empty and nothing is selected, `append()` treats the content as the new document +element. + +### `prepend()` + +Insert as the **first child** of each selected element. + +**Returns** `$this`. + +### `before()` + +Insert as a **preceding sibling** of each selected element. + +**Returns** `$this`. The match set is unchanged — you still have the original elements selected, not +the inserted ones. + +### `after()` + +Insert as a **following sibling** of each selected element. + +**Returns** `$this`. Passing empty content is a no-op. + +## Inserting into another object + +These are the reverse-direction forms: instead of "put this content into my elements", they mean +"put my elements into that object". + +All four **return `$this`** — the *original* object, unaltered. Only `$dest` is modified. + +### `appendTo()` + +Append the selected elements as the last children of `$dest`'s elements. + +### `prependTo()` + +Insert the selected elements as the first children of `$dest`'s elements. + +### `insertBefore()` + +Insert the selected elements as preceding siblings of `$dest`'s elements. + +### `insertAfter()` + +Insert the selected elements as following siblings of `$dest`'s elements. + +```php +$src = qp($xmlA, 'item'); +$dest = qp($xmlB, 'list'); + +$src->appendTo($dest); // $dest now contains copies of the items +``` + +`appendTo()` and `attach()` require a `DOMQuery`; `prependTo()`, `insertBefore()` and +`insertAfter()` accept any `QueryPath\Query`. + +## Removing + +### `remove()` + +Remove elements from the document. + +```php +$qp->find('.advert')->remove(); // remove the selected elements +$qp->remove('.advert'); // find, then remove, in one call +``` + +**Returns** a new `DOMQuery` wrapping the removed nodes. They are detached but not destroyed, so +they can be re-inserted elsewhere. + +> **`remove($selector)` uses the legacy selector engine**, not the one `find()` uses. The two do not +> always agree — `li:lt(2)` matches two elements through `find()` but removes only one, and +> selectors such as `:any-link` and `:scope` throw `ParseException` here while working fine in +> `find()`. When the selector is anything beyond simple CSS, prefer `find($selector)->remove()`, +> which routes through the current engine. See +> [Two selector engines](CSS-Selector-Reference.md#two-selector-engines). + +### `detach()` + +Remove elements and remember them, so `attach()` can put them back. + +```php +$removed = $qp->find('li')->detach(); +``` + +**Returns** a new `DOMQuery` wrapping the removed nodes. + +> **Known issue.** The `$selector` argument has no effect: `detach($selector)` runs the query but +> discards the result, then detaches whatever was already selected. Call +> `find($selector)->detach()` instead. + +### `attach()` + +Append the nodes remembered by the last `detach()` (or `add()`) into the destination object. + +```php +$qp->find('li')->detach(); +$qp->attach($otherQuery); +``` + +**Returns** `$this`. This reads from an internal "last match set" buffer, so it is only meaningful +directly after a `detach()` on the same object. + +### `removeChildren()` + +Remove all child nodes of each selected element, leaving the elements themselves in place. + +**Returns** `$this`. This is jQuery's `empty()`, renamed because `empty` is a reserved word in PHP. + +### `emptyElement()` + +A deprecated alias for `removeChildren()`. + +**Returns** `$this`. + +## Replacing + +### `replaceWith()` + +Replace each selected element with new content. + +```php +$old = $qp->find('h1')->replaceWith('

    Title

    '); +``` + +**Returns** a new `DOMQuery` wrapping **the elements that were removed**, matching jQuery's +behaviour. The replacement content is not selected. + +### `replaceAll()` + +Replace everything matching `$selector` in `$document` with the first element of the current set. + +```php +$qp->find('template')->replaceAll('.placeholder', $otherDocument); +``` + +**Returns** a new `DOMQuery` wrapping `$document`. + +> Deprecated, and it uses the legacy selector engine (see [`remove()`](#remove)). `replaceWith()` +> does the same job more predictably. + +## Wrapping + +All three wrapping methods accept the same content types as `append()`. If the markup nests, the +selected elements are placed inside its **deepest** node, so `wrap('
    ')` puts the +element inside the ``. + +### `wrap()` + +Wrap **each** selected element individually. + +```php +// 1212 +qp($xml, 'a')->wrap(''); +``` + +**Returns** `$this`. Empty markup is a no-op. + +### `wrapAll()` + +Wrap **all** selected elements together in a single wrapper, inserted at the position of the first +one. + +```php +// 1212 +qp($xml, 'a, b')->wrapAll(''); +``` + +**Returns** `$this`. + +### `wrapInner()` + +Wrap the **children** of each selected element. + +```php +// 1212 +qp($xml, 'wrap')->wrapInner(''); +``` + +**Returns** `$this`. + +### `unwrap()` + +Remove each selected element's parent, promoting the element in its place. The inverse of `wrap()`. + +```php +// +qp($xml, 'content')->unwrap(); +``` + +**Returns** `$this`, with the same elements selected. + +Throws `QueryPath\Exception: Cannot unwrap the root element.` if any selected element is the +document element. Unwrapping a direct child of the root replaces the root — so do it to only one +element, or the document ends up with multiple root elements. + +## Attributes + +### `attr()` + +Get or set attributes. + +```php +$qp->attr(); // all attributes of the first element, as an array +$qp->attr('href'); // the first element's href +$qp->attr('href', '/new'); // set href on every selected element +$qp->attr(['id' => 'x', 'lang' => 'en']); // set several at once +``` + +**Returns** `$this` when setting; when getting, a `string`, an `array`, or `null`. + +Getter details worth knowing: + +- Only the **first** selected element is read. +- An element that exists but lacks the attribute gives `''`, not `null`. +- An **empty match set** gives `null`. +- `attr('nodeType')` is special-cased and returns the first element's DOM node type as an integer, + not an attribute value. + +### `removeAttr()` + +Remove an attribute from every selected element. + +**Returns** `$this`. + +### `hasAttr()` + +Whether **every** selected element has the attribute. + +**Returns** `bool`. + +> On an empty match set this returns `true`, since there is no element that fails the test. Check +> `count()` first if that matters. + +### `val()` + +Shorthand for the `value` attribute: `val()` reads it from the first element, `val($v)` sets it on +all of them. + +**Returns** `$this` when setting, otherwise the attribute value or `null`. + +> Deprecated — `attr('value')` does the same thing. It exists for jQuery familiarity and has little +> use server-side. + +## Classes + +### `addClass()` + +Append a class to the `class` attribute of every selected element, creating the attribute if +needed. + +**Returns** `$this`. + +> No de-duplication is performed: calling `addClass('p')` twice produces `class="p p"`. + +### `removeClass()` + +Remove one class, or with no argument the whole `class` attribute. + +```php +// +$qp->removeClass('first'); // class="second" +$qp->removeClass(); // the class attribute is gone +``` + +If removing the named class would leave the attribute empty, the attribute is removed entirely. + +**Returns** `$this`. + +### `hasClass()` + +Whether **any** selected element carries the class. + +**Returns** `bool`. + +## Inline styles + +### `css()` + +Get or set declarations in the `style` attribute. + +```php +$qp->css('background-color', 'red'); +$qp->css(['color' => 'blue', 'margin' => '0']); +$qp->css(); // the raw style attribute of the first element +``` + +**Returns** `$this` when setting; when getting, the raw `style` attribute string. + +Since QueryPath 2.1 new declarations are merged into the existing `style` attribute rather than +replacing it. Output is written as `name: value;` pairs, including a trailing semicolon. + +> **Styles are pooled across the whole match set.** `css()` reads the `style` attribute of *every* +> selected element into one map, merges your declarations into it, and writes the combined result +> back to *all* of them. If two selected elements start with different styles, both end up with the +> union of the two: +> +> ```php +> $qp = html5qp('

    ', 'p'); +> $qp->css('margin', '0'); +> // BOTH paragraphs are now style="color: red;font-size: 2px;margin: 0;" +> ``` +> +> Apply `css()` to one element at a time when the elements do not already share a style. + +## Changing the match set + +These two change what is selected rather than what is in the document, but both mutate the object +in place, which is why they live here rather than in +[Traversal and Filtering](Traversal-and-Filtering.md). + +### `add()` + +Run a fresh query from the top of the document and merge the results into the current match set. + +```php +$qp->find('p')->add('div'); // paragraphs and divs +``` + +**Returns** `$this`, mutated. The previous match set is saved, so `end()` undoes it. + +### `cloneAll()` + +Deep-clone every selected node and select the clones instead. The clones are detached from the +document, so subsequent edits do not affect the original. + +**Returns** `$this`, mutated. This is jQuery's `clone()`. Contrast with +[`branch()`](Traversal-and-Filtering.md#branch), which copies the *query object* and keeps pointing +at the same nodes. + +## See also + +- [Traversal and Filtering](Traversal-and-Filtering.md) — selecting what to change +- [Markup and Text](Markup-and-Text.md) — `html()`, `xml()`, `text()` also act as setters +- [CSS Selector Reference](CSS-Selector-Reference.md) diff --git a/docs/Markup-and-Text.md b/docs/Markup-and-Text.md new file mode 100644 index 00000000..c4505a03 --- /dev/null +++ b/docs/Markup-and-Text.md @@ -0,0 +1,239 @@ +# Markup and Text + +Reading and writing the content of selected elements, and serialising the document. + +Every method on this page that takes an optional argument is both a **getter** (called with no +argument) and a **setter** (called with one). As a setter each returns `$this` and chains; as a +getter each returns a string, or `null` when nothing is selected (with the exception of `text()`, +which returns `''`). + +## Contents + +- [Which method should I use?](#which-method-should-i-use) +- [Whole-element markup](#whole-element-markup) — `html()`, `html5()`, `xml()`, `xhtml()` +- [Inner markup](#inner-markup) — `innerHTML()`, `innerHTML5()`, `innerXML()`, `innerXHTML()` +- [Text](#text) — `text()`, `textImplode()`, `childrenText()`, `textBefore()`, `textAfter()` +- [Writing to output or a file](#writing-to-output-or-a-file) — `writeHTML()`, `writeHTML5()`, `writeXML()`, `writeXHTML()` +- [Data URLs](#data-urls) — `dataURL()` + +## Which method should I use? + +| You want | Method | +|---|---| +| The element **and** its children, as HTML5 | `html5()` | +| Only the children, as HTML5 | `innerHTML5()` | +| The element and its children, as XML | `xml()` | +| Only the children, as XML | `innerXML()` | +| Just the text, markup stripped | `text()` | +| To print the whole document | `writeHTML5()` / `writeXML()` | + +> **`html()` is not jQuery's `html()`.** QueryPath's `html()` includes the element itself; +> jQuery's returns only the children. `innerHTML()` is the one that matches jQuery. + +## Whole-element markup + +All four getters read only the **first** selected element. + +### `html()` + +Legacy HTML 4.01, via libxml. + +```php +$qp->find('#d')->html(); // '
    test

    foo

    tail
    ' +$qp->find('#d')->html('

    new

    '); // replaces all children +``` + +As a setter the markup **must be well formed** — it is parsed as an XML document fragment, so +unclosed tags fail. If the `replace_entities` option is on, named entities are converted first. + +When the first selected element is the document element, the whole document is serialised +(including the doctype). + +### `html5()` + +The same, parsed and serialised by `masterminds/html5`. **This is the one to use for HTML.** As a +setter it accepts real-world HTML fragments, not just well-formed XML. + +### `xml()` + +XML. As a setter, the markup is parsed as a document fragment and replaces the children of each +selected element — an XML declaration is not needed. + +Passing `true` instead of markup is a getter that omits the XML declaration: + +```php +$qp->xml(true); // serialise without +``` + +The same effect is available document-wide through the +[`omit_xml_declaration`](Parser-Options.md#omit_xml_declaration) option. + +### `xhtml()` + +Like `xml()`, but always writes closing tags (``, never `@endcode, - * never @code`, + * never `