From bed5d2c9052592bee9f6d0b52152bc354f4d7aa7 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Fri, 21 Aug 2026 13:31:48 +1000 Subject: [PATCH 1/3] Add a CLAUDE.md file --- CLAUDE.md | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d33f905 --- /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. From f3c49e8200d33755b363975988f2cebd5ccc16b7 Mon Sep 17 00:00:00 2001 From: Jake Jackson Date: Mon, 24 Aug 2026 12:15:13 +1000 Subject: [PATCH 2/3] Move the long-form documentation out of Doxygen and into docs/ The prose that described QueryPath lived in Doxygen DocBlocks and in src/documentation.php, a file of 261 lines of @mainpage/@page markup and no runtime code. It documented QueryPath 2, and much of it had been wrong since QueryPath 3: find() was described as mutating in place when it returns a new object, and findInPlace() -- the method that actually mutates -- went unmentioned. Eleven Markdown pages now live in docs/, covering getting started, parser options, the CSS selector reference, writing extensions, and the full public API: all 99 public DOMQuery methods, indexed alphabetically and grouped by traversal, manipulation, markup, and utility. The Wiki Sync workflow publishes docs/ to the GitHub wiki on push to main, so docs/ is the source of truth and edits made in the browser are overwritten. Every claim was checked by running the code rather than by reading the DocBlock it came from, which turned up behaviour worth recording: the positional pseudo-classes are 1-indexed where jQuery's are 0-indexed, remove() and replaceAll() go through the legacy selector engine and disagree with find() on :lt(), :any-link and :scope, css() writes the union of the match set's style declarations to every element in it, and filterLambda()/eachLambda() raise an Error on PHP 8 because they were built on the removed create_function(). The DocBlocks that remain are PHPDoc and Markdown; no Doxygen-specific markup is left in src/. This commit changes comments only -- the diff over src/ contains no executable line -- and the suite is unchanged at 319 tests. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/wiki-sync.yml | 66 +++ .gitignore | 1 - CHANGELOG.md | 12 + README.md | 31 +- docs/API-Reference.md | 155 ++++++ docs/CSS-Selector-Reference.md | 255 ++++++++++ docs/Document-and-Utility.md | 236 +++++++++ docs/Getting-Started.md | 128 +++++ docs/Home.md | 64 +++ ...rse-HTML-in-PHP-using-querypath-library.md | 87 ++++ docs/Manipulation.md | 380 ++++++++++++++ docs/Markup-and-Text.md | 239 +++++++++ docs/Parser-Options.md | 189 +++++++ docs/Traversal-and-Filtering.md | 463 ++++++++++++++++++ docs/Writing-Extensions.md | 144 ++++++ src/CSS/DOMTraverser.php | 7 +- src/CSS/DOMTraverser/PseudoClass.php | 2 +- src/CSS/DOMTraverser/Util.php | 2 +- src/CSS/EventHandler.php | 14 +- src/CSS/NotImplementedException.php | 1 - src/CSS/ParseException.php | 1 - src/CSS/Parser.php | 1 - src/CSS/QueryPathEventHandler.php | 7 +- src/CSS/Scanner.php | 1 - src/CSS/Selector.php | 9 +- src/CSS/Token.php | 1 - src/DOMQuery.php | 147 +++--- src/Entities.php | 1 - src/Exception.php | 1 - src/Extension.php | 104 ++-- src/Extension/QPXML.php | 1 - src/Extension/QPXSL.php | 5 +- src/ExtensionRegistry.php | 1 - src/Helpers/QueryChecks.php | 19 +- src/Helpers/QueryFilters.php | 59 ++- src/Helpers/QueryMutators.php | 84 +++- src/IOException.php | 1 - src/Options.php | 27 +- src/ParseException.php | 1 - src/QueryPath.php | 192 +++----- src/QueryPathIterator.php | 1 - src/documentation.php | 261 ---------- src/qp_functions.php | 245 +++------ 43 files changed, 2812 insertions(+), 834 deletions(-) create mode 100644 .github/workflows/wiki-sync.yml create mode 100644 docs/API-Reference.md create mode 100644 docs/CSS-Selector-Reference.md create mode 100644 docs/Document-and-Utility.md create mode 100644 docs/Getting-Started.md create mode 100644 docs/Home.md create mode 100644 docs/How-to-parse-HTML-in-PHP-using-querypath-library.md create mode 100644 docs/Manipulation.md create mode 100644 docs/Markup-and-Text.md create mode 100644 docs/Parser-Options.md create mode 100644 docs/Traversal-and-Filtering.md create mode 100644 docs/Writing-Extensions.md delete mode 100644 src/documentation.php diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml new file mode 100644 index 0000000..d978c7c --- /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 ce1115a..46cdae7 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 8e390a2..3afbbe9 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/README.md b/README.md index 18bccb9..c2f4366 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 0000000..5a4eec3 --- /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 0000000..f0fcbaa --- /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 0000000..30313ca --- /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 0000000..2fd6c45 --- /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 0000000..6e42eda --- /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 0000000..a7178a5 --- /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 0000000..26f37e1 --- /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 0000000..c4505a0 --- /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 `