From 674b96401dfd003dc82ac70eb27e686514c4aefc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:04:43 +0000 Subject: [PATCH] Phase 05 M2: block-range locator and opt-in citation write routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit includes/block-locator.php finds each bibliography block's exact byte range in post_content with WP_Block_Parser's delimiter grammar (same document-order indexing as the read routes), refuses malformed structure, and splices one block in place, re-locating afterwards to prove no other byte moved. Tested against a vendored copy of core's real parser (tests/phpunit/wp-block-parser/). includes/write-routes.php adds, when a site opts in with the bibliography_builder_enable_write_routes filter: POST …/bibliographies//citations add (skips duplicates) PATCH …/bibliographies//citations/ change fields DELETE …/bibliographies//citations/ remove, returns entry PUT …/bibliographies//citations/order reorder (numeric) All need edit_post, are dry runs unless dry_run=false, and need If-Match with the post's content-hash ETag (428 missing, 412 stale). A write rebuilds the markup with the save() port, splices only that block, and saves through wp_update_post() as a normal revision. The read routes send the ETag while writes are enabled. Docs: docs/rest-write-routes.md; design memo, CLAUDE.md, README, CHANGELOG, metrics updated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KbVuMPN7YTcV27vwsG3LZ2 --- .../05-DESIGN-MEMO.md | 20 +- CHANGELOG.md | 8 + CLAUDE.md | 1 + README.md | 4 +- bibliography-builder.php | 25 +- docs/current-metrics.md | 10 +- docs/rest-write-routes.md | 99 +++ includes/block-locator.php | 205 +++++ includes/write-routes.php | 745 ++++++++++++++++++ tests/phpunit/BlockLocatorTest.php | 188 +++++ tests/phpunit/WriteRoutesTest.php | 348 ++++++++ tests/phpunit/bootstrap.php | 103 ++- tests/phpunit/wp-block-parser/README.md | 13 + .../class-wp-block-parser-block.php | 90 +++ .../class-wp-block-parser-frame.php | 79 ++ .../wp-block-parser/class-wp-block-parser.php | 410 ++++++++++ 16 files changed, 2332 insertions(+), 16 deletions(-) create mode 100644 docs/rest-write-routes.md create mode 100644 includes/block-locator.php create mode 100644 includes/write-routes.php create mode 100644 tests/phpunit/BlockLocatorTest.php create mode 100644 tests/phpunit/WriteRoutesTest.php create mode 100644 tests/phpunit/wp-block-parser/README.md create mode 100644 tests/phpunit/wp-block-parser/class-wp-block-parser-block.php create mode 100644 tests/phpunit/wp-block-parser/class-wp-block-parser-frame.php create mode 100644 tests/phpunit/wp-block-parser/class-wp-block-parser.php diff --git a/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md b/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md index 5315d50..8771b4e 100644 --- a/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md +++ b/.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md @@ -187,7 +187,23 @@ This splice approach is fragile for posts with many blocks. A safer alternative **Splicing decision.** Re-serializing the whole post with `serialize_blocks( parse_blocks() )` is semantically lossless but can rewrite other blocks' comment JSON byte-for-byte (escaping), producing noisy revisions. Tier 2 should instead locate the target block's byte range with the block-delimiter grammar `WP_Block_Parser` uses (document order, matching `bibliography_builder_collect_blocks()` indexing), replace only that range with `serialize_block()` of the updated block, and verify by re-parsing that exactly one block changed before calling `wp_update_post()`. -**Next (M2 proper):** add the block-range locator with tests against real `parse_blocks()` in the runtime matrix; then the Tier 2 routes behind a companion-plugin flag, dry-run by default, with `If-Match`. +**M2 (2026-09-26): implemented, unreleased.** See `docs/rest-write-routes.md`. + +- **Block-range locator** (`includes/block-locator.php`): + - uses core's delimiter grammar and document-order indexing; + - refuses malformed structure; + - re-locates after every splice to prove only the target changed; + - is tested against a vendored copy of the real `WP_Block_Parser` (`tests/phpunit/wp-block-parser/`), not the runtime matrix. Unit tests run it on every push. +- **Tier 2 routes** (`includes/write-routes.php`): + - add, patch, delete, and reorder (numeric styles only); + - off unless the `bibliography_builder_enable_write_routes` filter is true, which is the companion-plugin flag; + - dry run unless `dry_run=false`; + - `If-Match` required (428 without it, 412 when stale). +- **Decisions made on the way:** + - **ETag.** It is a hash of `post_content`, not `post_modified_gmt`, because two saves in one second share a modified time. + - **Add skips duplicates.** It skips entries that are duplicates by the editor's rules, and reports them. + - **Patch mirrors the editor's field editor.** It clears manual display text and stale export strings. + - **Where the routes ship.** They ship in the main plugin behind the filter, not as a separate distribution. Revisit if review asks for the code itself to be split out. --- @@ -245,7 +261,7 @@ This decision should be revisited once Tier 2 is prototyped and the static-save |---|---|---| | M0 | Stable IDs (no routes) | None — implement in next feature sprint | | M1 | Validate + diff read extensions (Tier 1) — **done (unreleased)** | M0 complete | -| M2 | Prototype Tier 2 add/update/delete (companion plugin) — static-save spike **done**; locale-independent save markup is the next prerequisite | M1 + static-save spike | +| M2 | Tier 2 add/update/delete/reorder behind an opt-in filter — **done (unreleased)**; see `docs/rest-write-routes.md` | M1 + static-save spike | | M3 | Reformat, reorder, ETag (Tier 2 complete) | M2 validated | | M4 | Bulk routes (Tier 4) | M3 + rate-limiting design | | M5 | Abilities registration (Tier 5) | WP Abilities API stable | diff --git a/CHANGELOG.md b/CHANGELOG.md index bde57bb..6af7e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Citation write routes (Phase 05, M2), **off by default**. A site opts in with the `bibliography_builder_enable_write_routes` filter. Under `/wp-json/bibliography/v1/posts//bibliographies//citations`: + - `POST` adds CSL-JSON items, skipping duplicates; + - `PATCH …/` changes fields; + - `DELETE …/` removes one citation and returns it; + - `PUT …/order` reorders a numeric-style list. + + Each route needs `edit_post` and is a dry run unless `dry_run=false`. A write needs `If-Match` with the post's ETag, which dry runs and the read routes return; it gets `428` without one and `412` when the ETag is stale. A write rebuilds the block's markup with the PHP port of `save()`, replaces only that block's bytes in the post, and saves a normal revision. See `docs/rest-write-routes.md`. + - Review routes for editors (Phase 05, Tier 1), all read-only and requiring `edit_post`: `GET …/bibliographies//validate` reports per-entry CSL-JSON problems (invalid or missing data, missing title, malformed DOI, and warnings for missing author, date, or container title, or an ISBN with no valid checksum); `…/duplicates` lists likely duplicate pairs using the editor's own duplicate rules; `…/preview?style=` shows each entry reformatted in another citation style next to its current text, without saving. `` is the block index or its stable `bibliographyId`. The routes live in `includes/review.php`. - Three matching read-only abilities on WordPress 6.9+, with the same `edit_post` requirement: `borges/validate-bibliography`, `borges/find-duplicate-citations`, and `borges/preview-bibliography-style`. Each takes `post_id` and either `index` or `bibliography_id`. - Stable IDs (Phase 05, Tier 0). The read-only REST collection and single-bibliography routes, and the `borges/get-bibliographies` ability, now report each block's `bibliographyId`, a UUID that stays put when blocks before it are added or removed, as the future write routes will need. It is `null` for a block saved before IDs were assigned (or with an unusable value) until the post is next edited. Every citation already carried an `id`; the editor now guarantees one, unique within its block. diff --git a/CLAUDE.md b/CLAUDE.md index 317a73f..7692e06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,7 @@ A Gutenberg block plugin that accepts DOI identifiers, PubMed/PMID records, BibT - `GET /isbn/{isbn}` — resolves checksum-valid ISBNs through Open Library's fixed ISBN edition endpoint (author names from its search endpoint), falling back to Google Books, mapping to a CSL book in PHP; requires `edit_posts` - `GET /posts/{post_id}/bibliographies` — list all bibliography blocks in post - `GET /posts/{post_id}/bibliographies/{index}` — single bibliography; supports `?format=json|text|csl-json` +- Citation write routes (Phase 05 M2, `includes/write-routes.php`): POST/PATCH/DELETE/PUT under `/posts/{post_id}/bibliographies/{ref}/citations`. They are **off unless** the `bibliography_builder_enable_write_routes` filter returns true. They need `edit_post`, are dry runs unless `dry_run=false`, and need `If-Match` (the post's ETag, a hash of its content). A write rebuilds markup with the `save()` port and splices only that block via `includes/block-locator.php`. See `docs/rest-write-routes.md`. - The PMID, PMCID, arXiv, and ISBN route callbacks, their provider constants (fixed upstream URLs, timeout, cache TTLs), and the NCBI cache helpers live in `includes/resolvers.php`, loaded by `require_once` from the main file. Route registration and the `edit_posts` permission callbacks stay in `bibliography-builder.php`. - Read-only Abilities API integration (WordPress 6.9+) lives in `includes/abilities.php`: `borges/get-bibliographies`, `borges/export-bibliography`, `borges/validate-citations` in a `bibliography` category. They reuse the REST read/permission helpers; on older WordPress the `wp_abilities_api_*` hooks never fire. - Payload limits: 1 MB max body, 50 items max per `/format` request diff --git a/README.md b/README.md index 9814406..dab8471 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Borges is a static-output block: formatted bibliography HTML, JSON-LD, and COinS | Metric | Value | |---|---| -| First-party PHP | ~2,064 LOC main plugin file; ~6,514 LOC total with `includes/` | +| First-party PHP | ~2,073 LOC main plugin file; ~7,473 LOC total with `includes/` | | JS source (`src/`) | ~9,956 LOC | | Frontend runtime shipped to visitors | `view.js` ~1.4 KB + `style-index.css` ~2.9 KB, no script dependencies, enqueued only when the block is present | | Installed footprint | ~1.9 MB (`vendor/` ~792 KB, translations 724 KB, build assets ~324 KB) | @@ -223,6 +223,8 @@ GET /wp-json/bibliography/v1/posts//bibliographies//preview?style= - Missing posts, forbidden posts, and missing bibliography indexes return explicit REST errors. - The public bibliography data routes are read-only. They do not add, update, delete, reorder, or persist citations. +Sites that opt in can also write citations over REST. The routes add, change, remove, and reorder citations. They require `edit_post`, are dry runs by default, and need `If-Match`. They are off unless the `bibliography_builder_enable_write_routes` filter returns true. See [docs/rest-write-routes.md](./docs/rest-write-routes.md). + The separate editor-only formatter endpoint accepts `POST /wp-json/bibliography/v1/format`, requires `edit_posts`, and returns formatted citation text for submitted CSL-JSON. It does not save changes. The editor-only PubMed resolver accepts `GET /wp-json/bibliography/v1/pmid/`, requires `edit_posts`, validates the PMID as numeric input, and returns normalized CSL-JSON from the fixed NCBI/PMC citation exporter endpoint. It is used for pasted `PMID:` input and does not persist citations by itself. diff --git a/bibliography-builder.php b/bibliography-builder.php index c617a8a..8df8d73 100644 --- a/bibliography-builder.php +++ b/bibliography-builder.php @@ -1619,11 +1619,14 @@ function bibliography_builder_rest_get_bibliographies( WP_REST_Request $request $post = get_post( $post_id ); $bibliographies = bibliography_builder_get_bibliographies_for_post( $post ); - return rest_ensure_response( - array( - 'postId' => $post_id, - 'bibliographies' => $bibliographies, - ) + return bibliography_builder_with_write_etag( + rest_ensure_response( + array( + 'postId' => $post_id, + 'bibliographies' => $bibliographies, + ) + ), + $post ); } @@ -1647,17 +1650,20 @@ function bibliography_builder_rest_get_bibliography( WP_REST_Request $request ) $response = new WP_REST_Response( bibliography_builder_build_plain_text( $bibliography ) ); $response->header( 'Content-Type', 'text/plain; charset=utf-8' ); - return $response; + return bibliography_builder_with_write_etag( $response, get_post( absint( $request['post_id'] ) ) ); } if ( 'csl-json' === $format ) { $response = rest_ensure_response( bibliography_builder_build_csl_json( $bibliography ) ); $response->header( 'Content-Type', 'application/vnd.citationstyles.csl+json; charset=utf-8' ); - return $response; + return bibliography_builder_with_write_etag( $response, get_post( absint( $request['post_id'] ) ) ); } - return rest_ensure_response( $bibliography ); + return bibliography_builder_with_write_etag( + rest_ensure_response( $bibliography ), + get_post( absint( $request['post_id'] ) ) + ); } /** @@ -1838,6 +1844,7 @@ function bibliography_builder_register_rest_routes() { ); bibliography_builder_register_review_routes(); + bibliography_builder_register_write_routes(); // The same read by stable ID, registered as its own route so numeric // requests keep the `index` parameter they have always had. @@ -1931,6 +1938,8 @@ function bibliography_builder_block_init() { require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/review.php'; require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/save-markup.php'; require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/frontend-labels.php'; +require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/block-locator.php'; +require_once BIBLIOGRAPHY_BUILDER_PLUGIN_DIR . 'includes/write-routes.php'; add_filter( 'rest_pre_serve_request', 'bibliography_builder_rest_pre_serve_request', 10, 4 ); /** diff --git a/docs/current-metrics.md b/docs/current-metrics.md index 7675248..79e038b 100644 --- a/docs/current-metrics.md +++ b/docs/current-metrics.md @@ -11,17 +11,19 @@ Source and LOC figures last verified: **2026-09-25** against the locale-independ | Metric | Value | Re-derivation command | |---|---|---| -| Main plugin file (`bibliography-builder.php`) | **2,064** | `wc -l bibliography-builder.php` | -| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **6,514** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` | +| Main plugin file (`bibliography-builder.php`) | **2,073** | `wc -l bibliography-builder.php` | +| All first-party PHP (excl. vendor, tests, scripts, playground, packages, output, node_modules, generated `build/`) | **7,473** | `find . -name '*.php' -not -path './vendor/*' -not -path './node_modules/*' -not -path './tests/*' -not -path './packages/*' -not -path './scripts/*' -not -path './playground/*' -not -path './output/*' -not -path './build/*' -print0 \| xargs -0 wc -l \| tail -1` | | JS source (`src/`, excl. `*.test.js`) | **9,956** | `find ./src -name '*.js' -not -name '*.test.js' -print0 \| xargs -0 wc -l \| tail -1` | | Shipped frontend runtime (`build/view.js`, minified) | **1,449 bytes** | `npm run build` then `wc -c < build/view.js` | The only PHP that executes at runtime on a visitor request path is `bibliography-builder.php` -(REST registration + block registration) and the five `includes/` files it loads: +(REST registration + block registration) and the seven `includes/` files it loads: `includes/resolvers.php`, which only defines constants and functions (its remote requests run only inside editor-time REST callbacks); `includes/review.php`, which only defines constants, functions, and editor-only review routes registered with the others on `rest_api_init`; -`includes/save-markup.php`, which only defines constants and functions (not yet called); +`includes/save-markup.php` and `includes/block-locator.php`, which only define constants and functions +(called only by the opt-in write routes); `includes/write-routes.php`, which only defines functions and +registers its routes on `rest_api_init` when a site opts in (off by default); `includes/frontend-labels.php`, whose `render_block` filter translates the saved Cite / Export labels with a few string replacements per bibliography block, only on non-English sites, and does no I/O; and diff --git a/docs/rest-write-routes.md b/docs/rest-write-routes.md new file mode 100644 index 0000000..115c17d --- /dev/null +++ b/docs/rest-write-routes.md @@ -0,0 +1,99 @@ +# Citation write routes + +Phase 05, M2 (Tier 2 of the [design memo](../.planning/phases/05-writable-bibliography-rest/05-DESIGN-MEMO.md)). Scripts, integrations, and agents can use these routes to add, change, remove, and reorder citations in a saved post, without opening the block editor. + +## Enabling + +The routes are **off by default**. Most sites never need to write bibliographies over REST, so a site has to opt in. Use a small companion plugin, or an mu-plugin such as `wp-content/mu-plugins/borges-write-api.php`: + +```php +/bibliographies/`. `` is the block's zero-based index, or its stable `bibliographyId`, as in the read routes. + +| Method | Path | Body | Change | +| --- | --- | --- | --- | +| `POST` | `…/citations` | `{ "items": [ …CSL-JSON… ] }` (1–50) | Add citations | +| `PATCH` | `…/citations/` | Partial CSL-JSON object | Change fields on one citation | +| `DELETE` | `…/citations/` | none | Remove one citation | +| `PUT` | `…/citations/order` | `{ "ids": [ … ] }` | Reorder a numeric-style bibliography | + +Every route requires `edit_post` on the post. + +### Dry run by default + +Every request is a **dry run** unless you pass `dry_run=false`. A dry run: + +- builds the change exactly as a write would, including formatting, rebuilding the markup, and splicing it into the post; +- returns the result; +- saves nothing. + +Preview with a dry run first, then send the same request again with `dry_run=false`. + +### If-Match + +A real write needs an `If-Match` header carrying the post's current ETag. Dry runs return it, and so do the read routes while the write routes are enabled. + +- **No header:** `428 Precondition Required`. +- **ETag no longer current:** `412 Precondition Failed`, because someone changed the post since your read. The error carries the current ETag. Read the post again, then retry. +- **Success:** the response carries the new ETag, so writes can be chained. + +The ETag is a hash of the post's content. Any change invalidates it, including a change to a different block. + +### Responses + +A successful response has this shape: + +```json +{ + "postId": 42, + "dryRun": false, + "changes": { "added": ["…id…"], "skipped": [] }, + "bibliography": { "index": 0, "bibliographyId": "…", "citations": [ … ], "entryCount": 3, "…": "…" }, + "etag": "\"…\"" +} +``` + +The `changes` object depends on the route: + +| Route | `changes` | +| --- | --- | +| add | `added` (new citation IDs); `skipped` (`{ item, duplicateOf }` for items that duplicate an existing citation or an earlier item, by the editor's duplicate rules) | +| patch | `updated` | +| delete | `removed`: the whole entry, so a client can put it back | +| reorder | `order` | + +## What a write does + +1. Reads `post_content` and locates the bibliography block by its byte range (`includes/block-locator.php`), using the same block grammar as `WP_Block_Parser`. Malformed block delimiters are refused with `409`. +2. Applies the change to the block's attributes, like this: + - new and changed CSL-JSON is sanitized with the same rules as `/format`; + - it is formatted in the block's style; + - new citations get UUIDs; + - the list is stored in display order, the way the editor stores it. + + A patch works like the editor's field editor. It clears the entry's manual display text and its stale BibTeX and BibLaTeX export strings. `id` and `type` cannot be patched, and a field set to `null` is removed. +3. Rebuilds the block's saved markup with the PHP port of `save()` (`includes/save-markup.php`). The port matches the editor's output byte for byte, so the block opens as valid. +4. Replaces only that block's bytes. It then locates the blocks again to prove that every other byte of the post is unchanged. +5. Saves with `wp_update_post()`, so the change is an ordinary revision and can be restored like any other. + +## Limits and caveats + +- **Size:** a request adds at most 50 items, and a bibliography holds at most 200 citations, the editor's limit. +- **Reordering:** only numeric styles (IEEE, Vancouver) can be reordered. Other styles sort their entries themselves, so they return `409`. +- **Export strings:** BibTeX and BibLaTeX export strings for new or changed entries are computed only in the editor, because they need citation-js. With per-entry Cite / Export on, those entries show RIS and CSL-JSON links until someone next saves the post in the editor. The editor then adds the missing strings. +- **HTML filtering:** WordPress filters saved HTML for users without `unfiltered_html`, in exactly the same way as it does for the editor. +- **intl:** the PHP `intl` extension is required. Without it, writes return `501`. + +## Still to come + +Later milestones are Tier 3 (block settings and reformatting), bulk routes, and write abilities. See the design memo's sequencing table. diff --git a/includes/block-locator.php b/includes/block-locator.php new file mode 100644 index 0000000..c91414a --- /dev/null +++ b/includes/block-locator.php @@ -0,0 +1,205 @@ +\/)?wp:(?P[a-z][a-z0-9_-]*\/)?(?P[a-z][a-z0-9_-]*)\s+(?P{(?:(?:[^}]+|}+(?=})|(?!}\s+\/?-->).)*+)?}\s+)?(?P\/)?-->/s'; + +/** + * Locate every bibliography block in post content. + * + * Each range covers the whole serialized block: from its opening delimiter to + * the end of its closing delimiter, or the self-closing delimiter of a block + * with no citations. Content the editor itself would not produce (a closer + * with no opener, a closer for a different block, a block left open at the + * end) is refused, because a write must never guess where a block ends. + * + * @param string $content Post content. + * @return array|WP_Error List of `{ start, end, attrs }` in index order; byte + * offsets, `end` exclusive, `attrs` the decoded + * attributes (see + * bibliography_builder_decode_save_attributes()). + */ +function bibliography_builder_locate_bibliography_blocks( $content ) { + $content = (string) $content; + $target = 'bibliography-builder/bibliography'; + $offset = 0; + $stack = array(); + $ranges = array(); + + $pattern = BIBLIOGRAPHY_BUILDER_BLOCK_DELIMITER_PATTERN; + + while ( 1 === preg_match( $pattern, $content, $match, PREG_OFFSET_CAPTURE, $offset ) ) { + $start = $match[0][1]; + $length = strlen( $match[0][0] ); + $offset = $start + $length; + + $namespace = isset( $match['namespace'] ) && -1 !== $match['namespace'][1] ? $match['namespace'][0] : 'core/'; + $name = $namespace . $match['name'][0]; + $is_closer = isset( $match['closer'] ) && -1 !== $match['closer'][1]; + $is_void = isset( $match['void'] ) && -1 !== $match['void'][1]; + $has_attrs = isset( $match['attrs'] ) && -1 !== $match['attrs'][1]; + + if ( $is_closer ) { + $open = array_pop( $stack ); + + if ( null === $open || $open['name'] !== $name ) { + return new WP_Error( + 'bibliography_builder_block_structure', + sprintf( + /* translators: %s: block name. */ + __( + 'The post content has an unmatched closing delimiter for %s.', + 'borges-bibliography-builder' + ), + $name + ), + array( 'status' => 409 ) + ); + } + + if ( null !== $open['range'] ) { + $ranges[ $open['range'] ]['end'] = $offset; + } + + continue; + } + + $range = null; + + if ( $target === $name ) { + $range = count( $ranges ); + $attrs_json = $has_attrs ? trim( $match['attrs'][0] ) : '{}'; + $ranges[ $range ] = array( + 'start' => $start, + 'end' => $is_void ? $offset : null, + 'attrs' => bibliography_builder_decode_save_attributes( $attrs_json ), + ); + } + + if ( ! $is_void ) { + $stack[] = array( + 'name' => $name, + 'range' => $range, + ); + } + } + + if ( ! empty( $stack ) ) { + return new WP_Error( + 'bibliography_builder_block_structure', + __( 'The post content has a block that is never closed.', 'borges-bibliography-builder' ), + array( 'status' => 409 ) + ); + } + + return $ranges; +} + +/** + * Serialize one bibliography block with the given attributes. + * + * @param array $attrs Block attributes. + * @param string $inner_html Saved markup (`''` for a block with no citations). + * @return string + */ +function bibliography_builder_serialize_bibliography_block( $attrs, $inner_html ) { + $comment_attrs = empty( $attrs ) ? '' : serialize_block_attributes( $attrs ) . ' '; + + if ( '' === $inner_html ) { + return ''; + } + + return '' + . $inner_html + . ''; +} + +/** + * Replace one bibliography block in post content, touching nothing else. + * + * After splicing, the content is located again to prove the result is sound: + * the same number of bibliography blocks, every other block's range shifted + * but its bytes unchanged, and the target block holding exactly the + * replacement with the attributes it was built from. + * + * @param string $content Post content. + * @param int $index Block index (see the file comment). + * @param string $replacement Serialized block + * (bibliography_builder_serialize_bibliography_block()). + * @return string|WP_Error New post content. + */ +function bibliography_builder_splice_bibliography_block( $content, $index, $replacement ) { + $content = (string) $content; + $ranges = bibliography_builder_locate_bibliography_blocks( $content ); + + if ( is_wp_error( $ranges ) ) { + return $ranges; + } + + if ( ! isset( $ranges[ $index ] ) ) { + return new WP_Error( + 'bibliography_builder_bibliography_not_found', + __( 'Bibliography not found.', 'borges-bibliography-builder' ), + array( 'status' => 404 ) + ); + } + + $target = $ranges[ $index ]; + $updated = substr( $content, 0, $target['start'] ) . $replacement . substr( $content, $target['end'] ); + $after = bibliography_builder_locate_bibliography_blocks( $updated ); + $delta = strlen( $replacement ) - ( $target['end'] - $target['start'] ); + + $failure = new WP_Error( + 'bibliography_builder_splice_failed', + __( + 'The updated bibliography could not be written without changing other content.', + 'borges-bibliography-builder' + ), + array( 'status' => 500 ) + ); + + if ( is_wp_error( $after ) || count( $after ) !== count( $ranges ) ) { + return $failure; + } + + foreach ( $ranges as $position => $range ) { + $moved = $position === $index + ? array( + 'start' => $range['start'], + 'end' => $range['start'] + strlen( $replacement ), + ) + : array( + 'start' => $range['start'] + ( $range['start'] > $target['start'] ? $delta : 0 ), + 'end' => $range['end'] + ( $range['end'] > $target['start'] ? $delta : 0 ), + ); + + if ( $after[ $position ]['start'] !== $moved['start'] || $after[ $position ]['end'] !== $moved['end'] ) { + return $failure; + } + } + + return $updated; +} diff --git a/includes/write-routes.php b/includes/write-routes.php new file mode 100644 index 0000000..e2269a8 --- /dev/null +++ b/includes/write-routes.php @@ -0,0 +1,745 @@ +post_content ) . '"'; +} + +/** + * Add the post's ETag to a read response when the write routes are on, so a + * client can send it back as If-Match without a dry run first. + * + * @param WP_REST_Response $response Response. + * @param object|null $post Post object. + * @return WP_REST_Response + */ +function bibliography_builder_with_write_etag( $response, $post ) { + if ( is_object( $post ) && bibliography_builder_write_routes_enabled() ) { + $response->header( 'ETag', bibliography_builder_post_etag( $post ) ); + } + + return $response; +} + +/** + * Permission callback: `edit_post` on the post. + * + * @param WP_REST_Request $request REST request. + * @return true|WP_Error + */ +function bibliography_builder_rest_write_permissions_check( WP_REST_Request $request ) { + $post_id = absint( $request['post_id'] ); + $post = 0 < $post_id ? get_post( $post_id ) : null; + + if ( ! is_object( $post ) ) { + return new WP_Error( + 'bibliography_builder_post_not_found', + __( 'Post not found.', 'borges-bibliography-builder' ), + array( 'status' => 404 ) + ); + } + + if ( current_user_can( 'edit_post', $post->ID ) ) { + return true; + } + + return new WP_Error( + 'bibliography_builder_write_forbidden', + __( 'Sorry, you are not allowed to edit this bibliography.', 'borges-bibliography-builder' ), + array( 'status' => 403 ) + ); +} + +/** + * Attributes as plain arrays, for mutation code that reads them. + * + * The block's own attributes stay in their decoded form for rendering; this + * copy only reads citation IDs and styles. + * + * @param mixed $value Decoded attribute value. + * @return mixed + */ +function bibliography_builder_write_to_arrays( $value ) { + return json_decode( wp_json_encode( $value ), true ); +} + +/** + * A readable record of one block's attributes, as the read routes report it. + * + * @param array $attrs Block attributes. + * @param int $index Block index. + * @return array + */ +function bibliography_builder_write_record( $attrs, $index ) { + $records = bibliography_builder_prepare_bibliographies( + bibliography_builder_collect_blocks( + array( + array( + 'blockName' => 'bibliography-builder/bibliography', + 'attrs' => bibliography_builder_write_to_arrays( $attrs ), + ), + ) + ) + ); + + $record = $records[0]; + $record['index'] = $index; + + return $record; +} + +/** + * Find a block's index from a `{ref}`: an index, or a stable bibliographyId. + * + * @param array $ranges Located blocks. + * @param string $ref Reference. + * @return int|WP_Error + */ +function bibliography_builder_write_resolve_index( $ranges, $ref ) { + $ref = (string) $ref; + + if ( ctype_digit( $ref ) && isset( $ranges[ (int) $ref ] ) ) { + return (int) $ref; + } + + if ( bibliography_builder_is_block_id( $ref ) ) { + foreach ( $ranges as $index => $range ) { + $attrs = bibliography_builder_write_to_arrays( $range['attrs'] ); + + if ( isset( $attrs['bibliographyId'] ) && $attrs['bibliographyId'] === $ref ) { + return $index; + } + } + } + + return new WP_Error( + 'bibliography_builder_not_found', + __( 'Bibliography block not found for the requested index or ID.', 'borges-bibliography-builder' ), + array( 'status' => 404 ) + ); +} + +/** + * A block's citation style key. + * + * @param array $attrs Block attributes (arrays). + * @return string + */ +function bibliography_builder_write_style( $attrs ) { + return isset( $attrs['citationStyle'] ) ? (string) $attrs['citationStyle'] : 'chicago-notes-bibliography'; +} + +/** + * A block's citations as a list. + * + * @param array $attrs Block attributes (arrays). + * @return array + */ +function bibliography_builder_write_citations( $attrs ) { + return isset( $attrs['citations'] ) && is_array( $attrs['citations'] ) + ? array_values( $attrs['citations'] ) + : array(); +} + +/** + * Find a citation's position by ID. + * + * @param array $citations Citations (arrays). + * @param string $citation_id Citation ID. + * @return int|WP_Error + */ +function bibliography_builder_write_find_citation( $citations, $citation_id ) { + foreach ( $citations as $position => $citation ) { + if ( bibliography_builder_get_citation_id( $citation ) === (string) $citation_id ) { + return $position; + } + } + + return new WP_Error( + 'bibliography_builder_citation_not_found', + __( 'Citation not found in this bibliography.', 'borges-bibliography-builder' ), + array( 'status' => 404 ) + ); +} + +/** + * Sanitize and format CSL items for storage. + * + * @param array $items CSL-JSON items. + * @param string $style_key Citation style. + * @return array|WP_Error List of `{ csl, formattedText }`. + */ +function bibliography_builder_write_prepare_entries( $items, $style_key ) { + $sanitized = bibliography_builder_validate_and_sanitize_csl_items( $items ); + + if ( is_wp_error( $sanitized ) ) { + return $sanitized; + } + + $formatted = bibliography_builder_format_csl_items( $sanitized, $style_key ); + + if ( is_wp_error( $formatted ) ) { + return $formatted; + } + + $entries = array(); + + foreach ( array_values( $sanitized ) as $position => $csl ) { + $entries[] = array( + 'csl' => $csl, + 'formattedText' => $formatted[ $position ], + ); + } + + return $entries; +} + +/** + * A new citation ID, unique within the bibliography. + * + * @param array $citations Existing citations. + * @return string + */ +function bibliography_builder_write_new_citation_id( $citations ) { + $taken = array_flip( array_filter( array_map( 'bibliography_builder_get_citation_id', $citations ) ) ); + + do { + $id = wp_generate_uuid4(); + } while ( isset( $taken[ $id ] ) ); + + return $id; +} + +/** + * Read one bibliography, apply a change, and either preview or save it. + * + * @param WP_REST_Request $request REST request (post_id, ref, dry_run). + * @param callable $mutate function ( array $attrs ): array|WP_Error, + * returning `{ attrs, changes }` where attrs + * are plain arrays. + * @return WP_REST_Response|WP_Error + */ +function bibliography_builder_write_bibliography( WP_REST_Request $request, $mutate ) { + if ( ! bibliography_builder_can_render_save_markup() ) { + return new WP_Error( + 'bibliography_builder_write_unavailable', + __( + 'This server cannot rebuild bibliography markup: the PHP intl extension is required.', + 'borges-bibliography-builder' + ), + array( 'status' => 501 ) + ); + } + + $post = get_post( absint( $request['post_id'] ) ); + $content = (string) $post->post_content; + $etag = bibliography_builder_post_etag( $post ); + $dry_run = false !== rest_sanitize_boolean( $request['dry_run'] ?? true ); + $ranges = bibliography_builder_locate_bibliography_blocks( $content ); + + if ( is_wp_error( $ranges ) ) { + return $ranges; + } + + $index = bibliography_builder_write_resolve_index( $ranges, $request['ref'] ); + + if ( is_wp_error( $index ) ) { + return $index; + } + + $before = bibliography_builder_write_to_arrays( $ranges[ $index ]['attrs'] ); + $result = call_user_func( $mutate, $before ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Render from the attributes exactly as the editor will parse them back. + $attrs = bibliography_builder_decode_save_attributes( wp_json_encode( $result['attrs'] ) ); + $html = bibliography_builder_render_save_markup( $attrs ); + $block = bibliography_builder_serialize_bibliography_block( $result['attrs'], $html ); + + $updated = bibliography_builder_splice_bibliography_block( $content, $index, $block ); + + if ( is_wp_error( $updated ) ) { + return $updated; + } + + $body = array( + 'postId' => $post->ID, + 'dryRun' => $dry_run, + 'changes' => $result['changes'], + 'bibliography' => bibliography_builder_write_record( $result['attrs'], $index ), + ); + + if ( $dry_run ) { + $body['etag'] = $etag; + $response = rest_ensure_response( $body ); + $response->header( 'ETag', $etag ); + + return $response; + } + + $if_match = trim( (string) $request->get_header( 'if_match' ) ); + + if ( '' === $if_match ) { + return new WP_Error( + 'bibliography_builder_precondition_required', + __( 'Send an If-Match header with the ETag from a read or dry run.', 'borges-bibliography-builder' ), + array( 'status' => 428 ) + ); + } + + if ( $if_match !== $etag && '*' !== $if_match ) { + return new WP_Error( + 'bibliography_builder_precondition_failed', + __( + 'The post has changed since that ETag was issued. Read it again and retry.', + 'borges-bibliography-builder' + ), + array( + 'status' => 412, + 'etag' => $etag, + ) + ); + } + + $saved = wp_update_post( + array( + 'ID' => $post->ID, + 'post_content' => wp_slash( $updated ), + ), + true + ); + + if ( is_wp_error( $saved ) ) { + return $saved; + } + + $new_etag = bibliography_builder_post_etag( get_post( $post->ID ) ); + $body['etag'] = $new_etag; + $response = rest_ensure_response( $body ); + $response->header( 'ETag', $new_etag ); + + return $response; +} + +/** + * POST …/citations: add CSL-JSON items. + * + * Body: `{ "items": [ …CSL-JSON… ] }`. Items that duplicate an existing + * citation, or an earlier item, by the editor's duplicate rules are skipped + * and reported, as the editor skips them on paste. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ +function bibliography_builder_rest_add_citations( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $items = isset( $params['items'] ) && is_array( $params['items'] ) ? array_values( $params['items'] ) : null; + + if ( null === $items || array() === $items || count( $items ) > BIBLIOGRAPHY_BUILDER_MAX_ITEMS_PER_WRITE ) { + return new WP_Error( + 'bibliography_builder_invalid_items', + sprintf( + /* translators: %d: maximum item count. */ + __( + 'Send "items": a list of 1 to %d CSL-JSON objects.', + 'borges-bibliography-builder' + ), + BIBLIOGRAPHY_BUILDER_MAX_ITEMS_PER_WRITE + ), + array( 'status' => 400 ) + ); + } + + return bibliography_builder_write_bibliography( + $request, + static function ( $attrs ) use ( $items ) { + $style = bibliography_builder_write_style( $attrs ); + $citations = bibliography_builder_write_citations( $attrs ); + $entries = bibliography_builder_write_prepare_entries( $items, $style ); + + if ( is_wp_error( $entries ) ) { + return $entries; + } + + $added = array(); + $skipped = array(); + + foreach ( $entries as $position => $entry ) { + $duplicate_of = null; + + $keys = bibliography_builder_get_duplicate_keys( $entry ); + + foreach ( $citations as $existing ) { + $existing_keys = bibliography_builder_get_duplicate_keys( $existing ); + + if ( null !== bibliography_builder_get_duplicate_reason( $existing_keys, $keys ) ) { + $duplicate_of = bibliography_builder_get_citation_id( $existing ); + break; + } + } + + if ( null !== $duplicate_of ) { + $skipped[] = array( + 'item' => $position, + 'duplicateOf' => $duplicate_of, + ); + continue; + } + + $citation = array_merge( + array( 'id' => bibliography_builder_write_new_citation_id( $citations ) ), + $entry + ); + $citations[] = $citation; + $added[] = $citation['id']; + } + + if ( count( $citations ) > BIBLIOGRAPHY_BUILDER_MAX_CITATIONS_PER_BIBLIOGRAPHY ) { + return new WP_Error( + 'bibliography_builder_too_many_citations', + sprintf( + /* translators: %d: maximum citation count. */ + __( + 'A bibliography can hold at most %d citations.', + 'borges-bibliography-builder' + ), + BIBLIOGRAPHY_BUILDER_MAX_CITATIONS_PER_BIBLIOGRAPHY + ), + array( 'status' => 400 ) + ); + } + + $attrs['citations'] = bibliography_builder_sort_citations_for_save( $citations, $style ); + + return array( + 'attrs' => $attrs, + 'changes' => array( + 'added' => $added, + 'skipped' => $skipped, + ), + ); + } + ); +} + +/** + * PATCH …/citations/{citation_id}: change fields on one citation. + * + * Body: a partial CSL-JSON object. A field set to null is removed. `id` and + * `type` cannot be changed. As in the editor's field editor, the entry is + * reformatted and loses its manual display text and stale export strings. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ +function bibliography_builder_rest_update_citation( WP_REST_Request $request ) { + $patch = $request->get_json_params(); + + if ( ! is_array( $patch ) || array() === $patch || isset( $patch[0] ) ) { + return new WP_Error( + 'bibliography_builder_invalid_patch', + __( 'Send a JSON object of CSL-JSON fields to change.', 'borges-bibliography-builder' ), + array( 'status' => 400 ) + ); + } + + foreach ( array( 'id', 'type' ) as $locked ) { + if ( array_key_exists( $locked, $patch ) ) { + return new WP_Error( + 'bibliography_builder_locked_field', + /* translators: %s: CSL field name. */ + sprintf( __( 'The "%s" field cannot be changed.', 'borges-bibliography-builder' ), $locked ), + array( 'status' => 400 ) + ); + } + } + + $citation_id = (string) $request['citation_id']; + + return bibliography_builder_write_bibliography( + $request, + static function ( $attrs ) use ( $patch, $citation_id ) { + $style = bibliography_builder_write_style( $attrs ); + $citations = bibliography_builder_write_citations( $attrs ); + $position = bibliography_builder_write_find_citation( $citations, $citation_id ); + + if ( is_wp_error( $position ) ) { + return $position; + } + + $csl = isset( $citations[ $position ]['csl'] ) && is_array( $citations[ $position ]['csl'] ) + ? $citations[ $position ]['csl'] + : array(); + + foreach ( $patch as $field => $value ) { + if ( null === $value ) { + unset( $csl[ $field ] ); + } else { + $csl[ $field ] = $value; + } + } + + $entries = bibliography_builder_write_prepare_entries( array( $csl ), $style ); + + if ( is_wp_error( $entries ) ) { + return $entries; + } + + $citation = $citations[ $position ]; + unset( + $citation['displayOverride'], + $citation['parseWarnings'], + $citation['exportBibtex'], + $citation['exportBiblatex'] + ); + $citations[ $position ] = array_merge( $citation, $entries[0] ); + + $attrs['citations'] = bibliography_builder_sort_citations_for_save( $citations, $style ); + + return array( + 'attrs' => $attrs, + 'changes' => array( 'updated' => array( $citation_id ) ), + ); + } + ); +} + +/** + * DELETE …/citations/{citation_id}: remove one citation. + * + * The response's `changes.removed` carries the whole removed entry, so a + * client can put it back. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ +function bibliography_builder_rest_delete_citation( WP_REST_Request $request ) { + $citation_id = (string) $request['citation_id']; + + return bibliography_builder_write_bibliography( + $request, + static function ( $attrs ) use ( $citation_id ) { + $citations = bibliography_builder_write_citations( $attrs ); + $position = bibliography_builder_write_find_citation( $citations, $citation_id ); + + if ( is_wp_error( $position ) ) { + return $position; + } + + $removed = array_splice( $citations, $position, 1 ); + + $attrs['citations'] = $citations; + + return array( + 'attrs' => $attrs, + 'changes' => array( 'removed' => $removed[0] ), + ); + } + ); +} + +/** + * PUT …/citations/order: reorder a numeric-style bibliography. + * + * Body: `{ "ids": [ … ] }`, every citation ID exactly once. Other style + * families sort themselves, so a new order would not show; they get a 409. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ +function bibliography_builder_rest_reorder_citations( WP_REST_Request $request ) { + $params = $request->get_json_params(); + $ids = isset( $params['ids'] ) && is_array( $params['ids'] ) ? array_values( $params['ids'] ) : null; + + if ( null === $ids ) { + return new WP_Error( + 'bibliography_builder_invalid_order', + __( 'Send "ids": every citation ID, in the new order.', 'borges-bibliography-builder' ), + array( 'status' => 400 ) + ); + } + + return bibliography_builder_write_bibliography( + $request, + static function ( $attrs ) use ( $ids ) { + $style = bibliography_builder_write_style( $attrs ); + $definition = bibliography_builder_get_formatter_style_definition( $style ); + + if ( 'numeric' !== $definition['family'] ) { + return new WP_Error( + 'bibliography_builder_order_is_automatic', + __( + 'This bibliography\'s style sorts its entries itself; only numeric styles can be reordered.', + 'borges-bibliography-builder' + ), + array( 'status' => 409 ) + ); + } + + $citations = bibliography_builder_write_citations( $attrs ); + $by_id = array(); + + foreach ( $citations as $citation ) { + $by_id[ (string) bibliography_builder_get_citation_id( $citation ) ] = $citation; + } + + $strings = array_map( 'strval', array_filter( $ids, 'is_scalar' ) ); + + if ( count( $strings ) !== count( $citations ) || count( array_unique( $strings ) ) !== count( $strings ) + || array_diff( $strings, array_keys( $by_id ) ) ) { + return new WP_Error( + 'bibliography_builder_invalid_order', + __( 'The order must list every citation ID exactly once.', 'borges-bibliography-builder' ), + array( 'status' => 400 ) + ); + } + + $attrs['citations'] = array_map( + static function ( $id ) use ( $by_id ) { + return $by_id[ $id ]; + }, + $strings + ); + + return array( + 'attrs' => $attrs, + 'changes' => array( 'order' => $strings ), + ); + } + ); +} + +/** + * Register the write routes when the site has enabled them. + */ +function bibliography_builder_register_write_routes() { + if ( ! bibliography_builder_write_routes_enabled() ) { + return; + } + + $base = '/posts/(?P\d+)/bibliographies/(?P' . BIBLIOGRAPHY_BUILDER_BLOCK_REF_PATTERN . ')/citations'; + $args = array( + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'ref' => array( + 'type' => array( 'string', 'integer' ), + 'validate_callback' => 'bibliography_builder_is_block_ref', + ), + 'dry_run' => array( + 'description' => __( + 'Preview the change without saving. Defaults to true; pass false to write.', + 'borges-bibliography-builder' + ), + 'type' => 'boolean', + 'default' => true, + ), + ); + + register_rest_route( + 'bibliography/v1', + $base . '/order', + array( + 'methods' => 'PUT', + 'callback' => 'bibliography_builder_rest_reorder_citations', + 'permission_callback' => 'bibliography_builder_rest_write_permissions_check', + 'args' => $args, + ) + ); + + register_rest_route( + 'bibliography/v1', + $base, + array( + 'methods' => 'POST', + 'callback' => 'bibliography_builder_rest_add_citations', + 'permission_callback' => 'bibliography_builder_rest_write_permissions_check', + 'args' => $args, + ) + ); + + register_rest_route( + 'bibliography/v1', + $base . '/(?P[^/\s]{1,128})', + array( + array( + 'methods' => 'PATCH', + 'callback' => 'bibliography_builder_rest_update_citation', + 'permission_callback' => 'bibliography_builder_rest_write_permissions_check', + 'args' => $args, + ), + array( + 'methods' => 'DELETE', + 'callback' => 'bibliography_builder_rest_delete_citation', + 'permission_callback' => 'bibliography_builder_rest_write_permissions_check', + 'args' => $args, + ), + ) + ); +} diff --git a/tests/phpunit/BlockLocatorTest.php b/tests/phpunit/BlockLocatorTest.php new file mode 100644 index 0000000..92a25ff --- /dev/null +++ b/tests/phpunit/BlockLocatorTest.php @@ -0,0 +1,188 @@ +parse( $content ); + } + + private static function bib( $attrs, $inner = '' ) { + return bibliography_builder_serialize_bibliography_block( $attrs, $inner ); + } + + /** + * A post with freeform text, multibyte characters, a nested bibliography, + * an empty (self-closing) one, and attribute strings that look like + * delimiters. + */ + private static function sample_post() { + return "\n

Zoë’s notes — “quoted” ✓

\n\n\n" + . self::bib( + array( + 'bibliographyId' => 'first-block', + 'headingText' => 'Tricky } --> {"x"} ', + 'citations' => array( + array( + 'id' => 'a1', + 'csl' => array( + 'type' => 'book', + 'title' => 'Alpha', + ), + ), + ), + ), + '
  1. Alpha
' + ) + . "\n\n\n
" + . self::bib( array( 'bibliographyId' => 'nested-block' ), '' ) + . "
\n\n\nloose freeform text\n\n" + . self::bib( + array( + 'bibliographyId' => 'last-block', + 'citationStyle' => 'apa-7', + 'citations' => array( + array( + 'id' => 'z9', + 'csl' => array( + 'type' => 'book', + 'title' => 'Zulu', + ), + ), + ), + ), + '
  1. Zulu
' + ) + . "\n"; + } + + public function test_indexes_and_attributes_match_the_real_parser() { + $content = self::sample_post(); + $ranges = bibliography_builder_locate_bibliography_blocks( $content ); + $records = bibliography_builder_collect_blocks( self::real_parse( $content ) ); + + $this->assertIsArray( $ranges ); + $this->assertCount( 3, $ranges ); + $this->assertCount( 3, $records ); + + foreach ( $records as $index => $record ) { + $this->assertSame( $record['bibliographyId'], $ranges[ $index ]['attrs']['bibliographyId'] ); + } + + $this->assertSame( 'Tricky } --> {"x"} ', $ranges[0]['attrs']['headingText'] ); + } + + public function test_each_range_is_exactly_one_whole_block() { + $content = self::sample_post(); + + foreach ( bibliography_builder_locate_bibliography_blocks( $content ) as $range ) { + $slice = substr( $content, $range['start'], $range['end'] - $range['start'] ); + $blocks = self::real_parse( $slice ); + + $this->assertStringStartsWith( '|)$#', $slice ); + $this->assertCount( 1, $blocks ); + $this->assertSame( 'bibliography-builder/bibliography', $blocks[0]['blockName'] ); + $this->assertSame( $range['attrs']['bibliographyId'], $blocks[0]['attrs']['bibliographyId'] ); + } + } + + public function test_content_without_bibliographies_has_no_ranges() { + $this->assertSame( array(), bibliography_builder_locate_bibliography_blocks( "\n

x

\n" ) ); + $this->assertSame( array(), bibliography_builder_locate_bibliography_blocks( '' ) ); + } + + public function test_malformed_structure_is_refused() { + $stray = "

x

\n" . self::bib( array( 'bibliographyId' => 'b' ) ); + $crossed = "

x

"; + $unclosed = "
"; + + foreach ( array( $stray, $crossed, $unclosed ) as $content ) { + $result = bibliography_builder_locate_bibliography_blocks( $content ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'bibliography_builder_block_structure', $result->get_error_code() ); + } + } + + public function test_splice_replaces_only_the_target_block() { + $content = self::sample_post(); + $ranges = bibliography_builder_locate_bibliography_blocks( $content ); + $replacement = self::bib( + array( + 'bibliographyId' => 'nested-block', + 'headingText' => 'Now with --> content', + 'citations' => array( + array( + 'id' => 'n1', + 'csl' => array( + 'type' => 'book', + 'title' => 'New', + ), + ), + ), + ), + '
  1. New
' + ); + + $updated = bibliography_builder_splice_bibliography_block( $content, 1, $replacement ); + + $this->assertIsString( $updated ); + $this->assertSame( substr( $content, 0, $ranges[1]['start'] ), substr( $updated, 0, $ranges[1]['start'] ) ); + $this->assertSame( substr( $content, $ranges[1]['end'] ), substr( $updated, $ranges[1]['start'] + strlen( $replacement ) ) ); + + $records = bibliography_builder_collect_blocks( self::real_parse( $updated ) ); + $this->assertSame( array( 'first-block', 'nested-block', 'last-block' ), array_column( $records, 'bibliographyId' ) ); + $this->assertSame( 'Now with --> content', $records[1]['headingText'] ); + $this->assertSame( 'New', $records[1]['citations'][0]['csl']['title'] ); + + $before = bibliography_builder_collect_blocks( self::real_parse( $content ) ); + $this->assertSame( $before[0], $records[0] ); + $this->assertSame( $before[2], $records[2] ); + } + + public function test_splice_can_empty_and_refill_a_block() { + $content = self::sample_post(); + $emptied = bibliography_builder_splice_bibliography_block( $content, 2, self::bib( array( 'bibliographyId' => 'last-block' ), '' ) ); + + $this->assertIsString( $emptied ); + $this->assertStringContainsString( '', $emptied ); + $this->assertCount( 3, bibliography_builder_locate_bibliography_blocks( $emptied ) ); + } + + public function test_splice_refuses_a_replacement_that_changes_the_structure() { + $content = self::sample_post(); + $two = self::bib( array( 'bibliographyId' => 'x' ) ) . self::bib( array( 'bibliographyId' => 'y' ) ); + $broken = '
'; + + foreach ( array( $two, $broken, '' ) as $replacement ) { + $result = bibliography_builder_splice_bibliography_block( $content, 0, $replacement ); + $this->assertInstanceOf( WP_Error::class, $result ); + } + } + + public function test_splice_reports_a_missing_block() { + $result = bibliography_builder_splice_bibliography_block( self::sample_post(), 7, self::bib( array() ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'bibliography_builder_bibliography_not_found', $result->get_error_code() ); + } + + public function test_serialized_attributes_round_trip_through_the_real_parser() { + $attrs = array( + 'bibliographyId' => 'round-trip', + 'headingText' => 'A & B < C > D -- "E" \\ F', + 'outputCoins' => true, + ); + $block = self::real_parse( self::bib( $attrs, '
' ) ); + + $this->assertSame( $attrs, $block[0]['attrs'] ); + $this->assertSame( '', self::bib( array() ) ); + } +} diff --git a/tests/phpunit/WriteRoutesTest.php b/tests/phpunit/WriteRoutesTest.php new file mode 100644 index 0000000..711720f --- /dev/null +++ b/tests/phpunit/WriteRoutesTest.php @@ -0,0 +1,348 @@ +markTestSkipped( 'intl and citeproc-php are required.' ); + } + + $this->content = "\n

Intro — ✓

\n\n\n" + . self::block( + array( + 'bibliographyId' => 'notes-block', + 'citationStyle' => 'chicago-notes-bibliography', + 'headingText' => 'Works Cited', + 'citations' => array( + self::citation( 'c-knuth', 'Literate Programming', 'Knuth', 'Donald E.', 1984, '10.1093/comjnl/27.2.97' ), + self::citation( 'c-turing', 'Computing Machinery and Intelligence', 'Turing', 'A. M.', 1950 ), + ), + ) + ) + . "\n\n" + . self::block( + array( + 'bibliographyId' => 'ieee-block', + 'citationStyle' => 'ieee', + 'citations' => array( + self::citation( 'i-one', 'First Paper', 'Zed', 'Ann', 2001 ), + self::citation( 'i-two', 'Second Paper', 'Abel', 'Bo', 2002 ), + ), + ) + ) + . "\n"; + + bibliography_builder_test_set_post( self::POST_ID, 'publish', $this->content ); + bibliography_builder_test_set_parsed_blocks( $this->content, ( new WP_Block_Parser() )->parse( $this->content ) ); + bibliography_builder_test_set_current_user( self::USER_ID ); + bibliography_builder_test_grant_cap( self::USER_ID, 'edit_post', self::POST_ID ); + } + + private static function citation( $id, $title, $family, $given, $year, $doi = null ) { + $csl = array( + 'type' => 'article-journal', + 'title' => $title, + 'author' => array( + array( + 'family' => $family, + 'given' => $given, + ), + ), + 'issued' => array( 'date-parts' => array( array( $year ) ) ), + ); + + if ( null !== $doi ) { + $csl['DOI'] = $doi; + } + + return array( + 'id' => $id, + 'csl' => $csl, + 'formattedText' => $family . '. ' . $title . '.', + ); + } + + private static function block( $attrs ) { + $html = bibliography_builder_render_save_markup( bibliography_builder_decode_save_attributes( wp_json_encode( $attrs ) ) ); + + return bibliography_builder_serialize_bibliography_block( $attrs, $html ); + } + + private static function request( $method, $ref, $body = array(), $params = array() ) { + $request = new WP_REST_Request( $method, '/bibliography/v1/posts/' . self::POST_ID . '/bibliographies/' . $ref . '/citations' ); + $request->set_query_params( + array_merge( + array( + 'post_id' => self::POST_ID, + 'ref' => (string) $ref, + 'dry_run' => true, + ), + $params + ) + ); + + if ( array() !== $body ) { + $request->set_body_params( $body ); + } + + return $request; + } + + private static function commit( WP_REST_Request $request, $etag = null ) { + $request['dry_run'] = false; + $request->set_header( 'If-Match', null === $etag ? bibliography_builder_post_etag( get_post( self::POST_ID ) ) : $etag ); + + return $request; + } + + private function saved_blocks() { + return bibliography_builder_locate_bibliography_blocks( get_post( self::POST_ID )->post_content ); + } + + /** + * Every saved bibliography block must hold exactly the markup save() + * renders for its attributes, or the editor would call it invalid. + */ + private function assert_blocks_are_valid() { + $content = get_post( self::POST_ID )->post_content; + + foreach ( ( new WP_Block_Parser() )->parse( $content ) as $block ) { + if ( 'bibliography-builder/bibliography' !== $block['blockName'] ) { + continue; + } + + $attrs = bibliography_builder_decode_save_attributes( wp_json_encode( $block['attrs'] ) ); + $this->assertSame( bibliography_builder_render_save_markup( $attrs ), $block['innerHTML'] ); + } + } + + public function test_routes_are_off_unless_enabled() { + bibliography_builder_register_write_routes(); + $this->assertSame( array(), $GLOBALS['bibliography_builder_test_rest_routes'] ); + + add_filter( 'bibliography_builder_enable_write_routes', '__return_true' ); + bibliography_builder_register_write_routes(); + + $routes = array_column( $GLOBALS['bibliography_builder_test_rest_routes'], 'route' ); + $this->assertCount( 3, $routes ); + $this->assertStringEndsWith( '/citations/order', $routes[0] ); + $this->assertStringEndsWith( '/citations', $routes[1] ); + $this->assertStringContainsString( '(?P', $routes[2] ); + } + + public function test_permission_needs_edit_post() { + $request = self::request( 'POST', 0 ); + $this->assertTrue( bibliography_builder_rest_write_permissions_check( $request ) ); + + bibliography_builder_test_set_current_user( 99 ); + $this->assertSame( 'bibliography_builder_write_forbidden', bibliography_builder_rest_write_permissions_check( $request )->get_error_code() ); + + $missing = self::request( 'POST', 0 ); + $missing['post_id'] = 404; + $this->assertSame( 'bibliography_builder_post_not_found', bibliography_builder_rest_write_permissions_check( $missing )->get_error_code() ); + } + + public function test_add_is_a_dry_run_by_default() { + $response = bibliography_builder_rest_add_citations( + self::request( 'POST', 'notes-block', array( 'items' => array( self::citation( 'x', 'Beta Study', 'Beta', 'Bea', 2010 )['csl'] ) ) ) + ); + + $data = $response->get_data(); + $this->assertTrue( $data['dryRun'] ); + $this->assertCount( 1, $data['changes']['added'] ); + $this->assertSame( 3, $data['bibliography']['entryCount'] ); + $this->assertSame( bibliography_builder_post_etag( get_post( self::POST_ID ) ), $response->get_headers()['ETag'] ); + $this->assertSame( $this->content, get_post( self::POST_ID )->post_content ); + $this->assertSame( array(), $GLOBALS['bibliography_builder_test_post_updates'] ); + } + + public function test_a_write_needs_a_current_etag() { + $request = self::request( 'POST', 0, array( 'items' => array( self::citation( 'x', 'Beta Study', 'Beta', 'Bea', 2010 )['csl'] ) ) ); + + $missing = self::commit( $request, '' ); + $this->assertSame( 428, bibliography_builder_rest_add_citations( $missing )->get_error_data()['status'] ); + + $stale = self::commit( $request, '"stale"' ); + $error = bibliography_builder_rest_add_citations( $stale ); + $this->assertSame( 412, $error->get_error_data()['status'] ); + $this->assertSame( bibliography_builder_post_etag( get_post( self::POST_ID ) ), $error->get_error_data()['etag'] ); + + $this->assertSame( $this->content, get_post( self::POST_ID )->post_content ); + } + + public function test_add_writes_only_the_target_block() { + $before = $this->saved_blocks(); + $response = bibliography_builder_rest_add_citations( + self::commit( self::request( 'POST', 'notes-block', array( 'items' => array( self::citation( 'x', 'Beta Study', 'Beta', 'Bea', 2010 )['csl'] ) ) ) ) + ); + + $data = $response->get_data(); + $content = get_post( self::POST_ID )->post_content; + $after = $this->saved_blocks(); + + $this->assertFalse( $data['dryRun'] ); + $this->assertCount( 1, $GLOBALS['bibliography_builder_test_post_updates'] ); + $this->assertSame( bibliography_builder_post_etag( get_post( self::POST_ID ) ), $data['etag'] ); + $this->assertNotSame( bibliography_builder_post_etag( (object) array( 'post_content' => $this->content ) ), $data['etag'] ); + + // Everything before the first block and the whole second block are untouched. + $this->assertSame( substr( $this->content, 0, $before[0]['start'] ), substr( $content, 0, $after[0]['start'] ) ); + $this->assertSame( + substr( $this->content, $before[1]['start'] ), + substr( $content, $after[1]['start'] ) + ); + + // Stored in display order (Chicago sorts by author), and valid. + $citations = bibliography_builder_write_to_arrays( $after[0]['attrs'] )['citations']; + $this->assertSame( array( 'Beta', 'Knuth', 'Turing' ), array_map( static fn( $c ) => $c['csl']['author'][0]['family'], $citations ) ); + $this->assertStringContainsString( 'Beta Study', $citations[0]['formattedText'] ); + $this->assertSame( $data['changes']['added'][0], $citations[0]['id'] ); + $this->assert_blocks_are_valid(); + } + + public function test_add_skips_duplicates() { + $dupe_doi = self::citation( 'x', 'Other Title', 'Other', 'O', 1999, '10.1093/comjnl/27.2.97' )['csl']; + $new = self::citation( 'y', 'Fresh', 'Fresh', 'F', 2020 )['csl']; + $data = bibliography_builder_rest_add_citations( + self::request( 'POST', 0, array( 'items' => array( $dupe_doi, $new, $new ) ) ) + )->get_data(); + + $this->assertCount( 1, $data['changes']['added'] ); + $this->assertSame( + array( + array( + 'item' => 0, + 'duplicateOf' => 'c-knuth', + ), + array( + 'item' => 2, + 'duplicateOf' => $data['changes']['added'][0], + ), + ), + $data['changes']['skipped'] + ); + } + + public function test_add_rejects_bad_items() { + $this->assertSame( 400, bibliography_builder_rest_add_citations( self::request( 'POST', 0, array( 'items' => array() ) ) )->get_error_data()['status'] ); + $this->assertSame( 400, bibliography_builder_rest_add_citations( self::request( 'POST', 0, array( 'other' => 1 ) ) )->get_error_data()['status'] ); + $this->assertSame( 400, bibliography_builder_rest_add_citations( self::request( 'POST', 0, array( 'items' => array_fill( 0, 51, array( 'type' => 'book', 'title' => 'x' ) ) ) ) )->get_error_data()['status'] ); + $this->assertInstanceOf( WP_Error::class, bibliography_builder_rest_add_citations( self::request( 'POST', 0, array( 'items' => array( 'not an object' ) ) ) ) ); + } + + public function test_patch_updates_one_citation_like_the_field_editor() { + $request = self::request( 'PATCH', 'notes-block', array( 'title' => 'Literate Programming, Revisited' ) ); + $request['citation_id'] = 'c-knuth'; + + $data = bibliography_builder_rest_update_citation( self::commit( $request ) )->get_data(); + $citations = bibliography_builder_write_to_arrays( $this->saved_blocks()[0]['attrs'] )['citations']; + $knuth = $citations[0]; + + $this->assertSame( array( 'c-knuth' ), $data['changes']['updated'] ); + $this->assertSame( 'Literate Programming, Revisited', $knuth['csl']['title'] ); + $this->assertStringContainsString( 'Revisited', $knuth['formattedText'] ); + $this->assertSame( '10.1093/comjnl/27.2.97', $knuth['csl']['DOI'] ); + $this->assert_blocks_are_valid(); + } + + public function test_patch_null_removes_a_field_and_locked_fields_are_refused() { + $request = self::request( 'PATCH', 0, array( 'DOI' => null ) ); + $request['citation_id'] = 'c-knuth'; + $citation = bibliography_builder_rest_update_citation( $request )->get_data()['bibliography']['citations']; + $this->assertArrayNotHasKey( 'DOI', $citation[0]['csl'] ); + + foreach ( array( 'id', 'type' ) as $field ) { + $locked = self::request( 'PATCH', 0, array( $field => 'x' ) ); + $locked['citation_id'] = 'c-knuth'; + $this->assertSame( 'bibliography_builder_locked_field', bibliography_builder_rest_update_citation( $locked )->get_error_code() ); + } + + $unknown = self::request( 'PATCH', 0, array( 'title' => 'x' ) ); + $unknown['citation_id'] = 'nope'; + $this->assertSame( 404, bibliography_builder_rest_update_citation( $unknown )->get_error_data()['status'] ); + } + + public function test_delete_returns_the_removed_entry_and_can_empty_a_block() { + foreach ( array( 'c-knuth', 'c-turing' ) as $id ) { + $request = self::request( 'DELETE', 'notes-block' ); + $request['citation_id'] = $id; + $data = bibliography_builder_rest_delete_citation( self::commit( $request ) )->get_data(); + $this->assertSame( $id, $data['changes']['removed']['id'] ); + } + + $content = get_post( self::POST_ID )->post_content; + $this->assertStringContainsString( '"bibliographyId":"notes-block","citationStyle":"chicago-notes-bibliography","headingText":"Works Cited","citations":[]} /-->', $content ); + $this->assertCount( 2, $this->saved_blocks() ); + $this->assert_blocks_are_valid(); + } + + public function test_reorder_applies_to_numeric_styles_only() { + $request = self::request( 'PUT', 'ieee-block', array( 'ids' => array( 'i-two', 'i-one' ) ) ); + $data = bibliography_builder_rest_reorder_citations( self::commit( $request ) )->get_data(); + + $this->assertSame( array( 'i-two', 'i-one' ), $data['changes']['order'] ); + $this->assertSame( array( 'i-two', 'i-one' ), array_column( bibliography_builder_write_to_arrays( $this->saved_blocks()[1]['attrs'] )['citations'], 'id' ) ); + $this->assert_blocks_are_valid(); + + $bad = self::request( 'PUT', 'ieee-block', array( 'ids' => array( 'i-two' ) ) ); + $this->assertSame( 400, bibliography_builder_rest_reorder_citations( $bad )->get_error_data()['status'] ); + + $dupe = self::request( 'PUT', 'ieee-block', array( 'ids' => array( 'i-two', 'i-two' ) ) ); + $this->assertSame( 400, bibliography_builder_rest_reorder_citations( $dupe )->get_error_data()['status'] ); + + $alpha = self::request( 'PUT', 'notes-block', array( 'ids' => array( 'c-turing', 'c-knuth' ) ) ); + $this->assertSame( 409, bibliography_builder_rest_reorder_citations( $alpha )->get_error_data()['status'] ); + } + + public function test_unknown_refs_and_malformed_posts_are_refused() { + $this->assertSame( 404, bibliography_builder_rest_add_citations( self::request( 'POST', 'no-such-block', array( 'items' => array( array( 'type' => 'book', 'title' => 'x' ) ) ) ) )->get_error_data()['status'] ); + $this->assertSame( 404, bibliography_builder_rest_add_citations( self::request( 'POST', 5, array( 'items' => array( array( 'type' => 'book', 'title' => 'x' ) ) ) ) )->get_error_data()['status'] ); + + bibliography_builder_test_set_post( self::POST_ID, 'publish', $this->content . '
' ); + $this->assertSame( 409, bibliography_builder_rest_add_citations( self::request( 'POST', 0, array( 'items' => array( array( 'type' => 'book', 'title' => 'x' ) ) ) ) )->get_error_data()['status'] ); + } + + public function test_read_routes_carry_the_etag_only_when_writes_are_on() { + $request = new WP_REST_Request( 'GET', '/bibliography/v1/posts/' . self::POST_ID . '/bibliographies' ); + $request->set_query_params( + array( + 'post_id' => self::POST_ID, + 'index' => 0, + 'id' => null, + ) + ); + + $this->assertArrayNotHasKey( 'ETag', bibliography_builder_rest_get_bibliographies( $request )->get_headers() ); + + add_filter( 'bibliography_builder_enable_write_routes', '__return_true' ); + $etag = bibliography_builder_post_etag( get_post( self::POST_ID ) ); + + $this->assertSame( $etag, bibliography_builder_rest_get_bibliographies( $request )->get_headers()['ETag'] ); + $this->assertSame( $etag, bibliography_builder_rest_get_bibliography( $request )->get_headers()['ETag'] ); + } + + public function test_a_failed_save_is_reported() { + $GLOBALS['bibliography_builder_test_update_error'] = new WP_Error( 'db_update_error', 'Could not update post in the database.', array( 'status' => 500 ) ); + + $result = bibliography_builder_rest_add_citations( + self::commit( self::request( 'POST', 0, array( 'items' => array( array( 'type' => 'book', 'title' => 'x' ) ) ) ) ) + ); + + $this->assertSame( 'db_update_error', $result->get_error_code() ); + $this->assertSame( $this->content, get_post( self::POST_ID )->post_content ); + } +} diff --git a/tests/phpunit/bootstrap.php b/tests/phpunit/bootstrap.php index c24193f..12fe063 100644 --- a/tests/phpunit/bootstrap.php +++ b/tests/phpunit/bootstrap.php @@ -46,6 +46,9 @@ function bibliography_builder_test_reset_state() { $GLOBALS['bibliography_builder_test_ability_categories'] = array(); $GLOBALS['bibliography_builder_test_http_responses_for'] = array(); $GLOBALS['bibliography_builder_test_translations'] = array(); + $GLOBALS['bibliography_builder_test_filters'] = array(); + $GLOBALS['bibliography_builder_test_post_updates'] = array(); + $GLOBALS['bibliography_builder_test_update_error'] = null; } /** @@ -157,7 +160,73 @@ function add_action( $hook_name = '', $callback = null, $priority = 10, $accepte return true; } -function add_filter() {} +function add_filter( $hook_name = '', $callback = null ) { + if ( '' !== $hook_name && null !== $callback ) { + $GLOBALS['bibliography_builder_test_filters'][ $hook_name ][] = $callback; + } +} + +function remove_all_filters( $hook_name ) { + unset( $GLOBALS['bibliography_builder_test_filters'][ $hook_name ] ); +} + +function apply_filters( $hook_name, $value, ...$args ) { + foreach ( $GLOBALS['bibliography_builder_test_filters'][ $hook_name ] ?? array() as $callback ) { + $value = $callback( $value, ...$args ); + } + + return $value; +} + +function __return_true() { + return true; +} + +function wp_generate_uuid4() { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand( 0, 0xffff ), + mt_rand( 0, 0xffff ), + mt_rand( 0, 0xffff ), + mt_rand( 0, 0x0fff ) | 0x4000, + mt_rand( 0, 0x3fff ) | 0x8000, + mt_rand( 0, 0xffff ), + mt_rand( 0, 0xffff ), + mt_rand( 0, 0xffff ) + ); +} + +function wp_slash( $value ) { + return is_string( $value ) ? addslashes( $value ) : $value; +} + +function rest_sanitize_boolean( $value ) { + if ( is_string( $value ) ) { + $value = strtolower( $value ); + if ( in_array( $value, array( 'false', '0' ), true ) ) { + return false; + } + } + + return (bool) $value; +} + +/** + * Stand-in for wp_update_post(): stores the unslashed content, as core does, + * and records the call. Set $GLOBALS['bibliography_builder_test_update_error'] + * to make it fail. + */ +function wp_update_post( $postarr, $wp_error = false ) { + if ( ! empty( $GLOBALS['bibliography_builder_test_update_error'] ) ) { + return $GLOBALS['bibliography_builder_test_update_error']; + } + + $post_id = (int) $postarr['ID']; + $GLOBALS['bibliography_builder_test_posts'][ $post_id ]->post_content = stripslashes( $postarr['post_content'] ); + $GLOBALS['bibliography_builder_test_post_updates'][] = $postarr; + + return $post_id; +} function ba11yc_register_block_check( $block_type, $args ) { $GLOBALS['bibliography_builder_test_bac_register_calls'][] = array( @@ -195,6 +264,28 @@ function register_rest_route( $namespace, $route, $args ) { ); } +// WordPress core's serialize_block_attributes(), verbatim in behavior. +function serialize_block_attributes( $block_attributes ) { + $encoded_attributes = wp_json_encode( $block_attributes, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + + return strtr( + $encoded_attributes, + array( + '\\\\' => '\\u005c', + '--' => '\\u002d\\u002d', + '<' => '\\u003c', + '>' => '\\u003e', + '&' => '\\u0026', + '\\"' => '\\u0022', + ) + ); +} + +// WordPress core's block parser, for tests that need the real parse_blocks(). +require_once __DIR__ . '/wp-block-parser/class-wp-block-parser-block.php'; +require_once __DIR__ . '/wp-block-parser/class-wp-block-parser-frame.php'; +require_once __DIR__ . '/wp-block-parser/class-wp-block-parser.php'; + function parse_blocks( $content ) { return $GLOBALS['bibliography_builder_test_parsed_blocks'][ $content ] ?? array(); } @@ -414,6 +505,16 @@ public function get_route() { return $this->route; } + private $headers = array(); + + public function set_header( $key, $value ) { + $this->headers[ strtolower( str_replace( '-', '_', $key ) ) ] = $value; + } + + public function get_header( $key ) { + return $this->headers[ strtolower( str_replace( '-', '_', $key ) ) ] ?? null; + } + public function offsetExists( $offset ): bool { return isset( $this->params[ $offset ] ); } diff --git a/tests/phpunit/wp-block-parser/README.md b/tests/phpunit/wp-block-parser/README.md new file mode 100644 index 0000000..dbbfe04 --- /dev/null +++ b/tests/phpunit/wp-block-parser/README.md @@ -0,0 +1,13 @@ +# WordPress block parser (test copy) + +Unmodified copies of WordPress core's block parser, so the PHPUnit suite can +check `includes/block-locator.php` against the real `parse_blocks()` without a +WordPress install: + +- `class-wp-block-parser.php` +- `class-wp-block-parser-block.php` +- `class-wp-block-parser-frame.php` + +Source: WordPress/WordPress `wp-includes/` at 8e52502 (7.2-alpha-63945). +WordPress is GPL-2.0-or-later, like this plugin. These files are test-only and +never ship (`tests/` is in `.distignore`). diff --git a/tests/phpunit/wp-block-parser/class-wp-block-parser-block.php b/tests/phpunit/wp-block-parser/class-wp-block-parser-block.php new file mode 100644 index 0000000..97dd687 --- /dev/null +++ b/tests/phpunit/wp-block-parser/class-wp-block-parser-block.php @@ -0,0 +1,90 @@ + 3 ) + * + * @since 5.0.0 + * @var array|null + */ + public $attrs; + + /** + * List of inner blocks (of this same class) + * + * @since 5.0.0 + * @var WP_Block_Parser_Block[] + */ + public $innerBlocks; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * Resultant HTML from inside block comment delimiters + * after removing inner blocks + * + * @example "...Just testing..." -> "Just testing..." + * + * @since 5.0.0 + * @var string + */ + public $innerHTML; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * List of string fragments and null markers where inner blocks were found + * + * @example array( + * 'innerHTML' => 'BeforeInnerAfter', + * 'innerBlocks' => array( block, block ), + * 'innerContent' => array( 'Before', null, 'Inner', null, 'After' ), + * ) + * + * @since 5.0.0 + * @var array + */ + public $innerContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + + /** + * Constructor. + * + * Will populate object properties from the provided arguments. + * + * @since 5.0.0 + * + * @param string $name Name of block. + * @param array $attrs Optional set of attributes from block comment delimiters. + * @param array $inner_blocks List of inner blocks (of this same class). + * @param string $inner_html Resultant HTML from inside block comment delimiters after removing inner blocks. + * @param array $inner_content List of string fragments and null markers where inner blocks were found. + */ + public function __construct( $name, $attrs, $inner_blocks, $inner_html, $inner_content ) { + $this->blockName = $name; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->attrs = $attrs; + $this->innerBlocks = $inner_blocks; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->innerHTML = $inner_html; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + $this->innerContent = $inner_content; // phpcs:ignore WordPress.NamingConventions.ValidVariableName + } +} diff --git a/tests/phpunit/wp-block-parser/class-wp-block-parser-frame.php b/tests/phpunit/wp-block-parser/class-wp-block-parser-frame.php new file mode 100644 index 0000000..cc766d6 --- /dev/null +++ b/tests/phpunit/wp-block-parser/class-wp-block-parser-frame.php @@ -0,0 +1,79 @@ +block = $block; + $this->token_start = $token_start; + $this->token_length = $token_length; + $this->prev_offset = $prev_offset ?? $token_start + $token_length; + $this->leading_html_start = $leading_html_start; + } +} diff --git a/tests/phpunit/wp-block-parser/class-wp-block-parser.php b/tests/phpunit/wp-block-parser/class-wp-block-parser.php new file mode 100644 index 0000000..3090c23 --- /dev/null +++ b/tests/phpunit/wp-block-parser/class-wp-block-parser.php @@ -0,0 +1,410 @@ +This is inside a block!" + * + * @since 5.0.0 + * @var string + */ + public $document; + + /** + * Tracks parsing progress through document + * + * @since 5.0.0 + * @var int + */ + public $offset; + + /** + * List of parsed blocks + * + * @since 5.0.0 + * @var array[] + */ + public $output; + + /** + * Stack of partially-parsed structures in memory during parse + * + * @since 5.0.0 + * @var WP_Block_Parser_Frame[] + * @phpstan-var list + */ + public $stack; + + /** + * Parses a document and returns a list of block structures + * + * When encountering an invalid parse will return a best-effort + * parse. In contrast to the specification parser this does not + * return an error on invalid inputs. + * + * @since 5.0.0 + * + * @param string $document Input document being parsed. + * @return array[] + */ + public function parse( $document ) { + $this->document = $document; + $this->offset = 0; + $this->output = array(); + $this->stack = array(); + + while ( $this->proceed() ) { + continue; + } + + return $this->output; + } + + /** + * Processes the next token from the input document + * and returns whether to proceed eating more tokens + * + * This is the "next step" function that essentially + * takes a token as its input and decides what to do + * with that token before descending deeper into a + * nested block tree or continuing along the document + * or breaking out of a level of nesting. + * + * @internal + * @since 5.0.0 + * + * @return bool + */ + public function proceed() { + $next_token = $this->next_token(); + list( $token_type, $block_name, $attrs, $start_offset, $token_length ) = $next_token; + $stack_depth = count( $this->stack ); + + // we may have some HTML soup before the next block. + $leading_html_start = $start_offset > $this->offset ? $this->offset : null; + + switch ( $token_type ) { + case 'no-more-tokens': + // if not in a block then flush output. + if ( 0 === $stack_depth ) { + $this->add_freeform(); + return false; + } + + /* + * Otherwise we have a problem + * This is an error + * + * we have options + * - treat it all as freeform text + * - assume an implicit closer (easiest when not nesting) + */ + + // for the easy case we'll assume an implicit closer. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack(); + return false; + } + + /* + * for the nested case where it's more difficult we'll + * have to assume that multiple closers are missing + * and so we'll collapse the whole stack piecewise + */ + while ( 0 < count( $this->stack ) ) { + $this->add_block_from_stack(); + } + return false; + + case 'void-block': + /* + * easy case is if we stumbled upon a void block + * in the top-level of the document + */ + if ( 0 === $stack_depth ) { + if ( isset( $leading_html_start ) ) { + $this->output[] = (array) $this->freeform( + substr( + $this->document, + $leading_html_start, + $start_offset - $leading_html_start + ) + ); + } + + $this->output[] = (array) new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ); + $this->offset = $start_offset + $token_length; + return true; + } + + // otherwise we found an inner block. + $this->add_inner_block( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length + ); + $this->offset = $start_offset + $token_length; + return true; + + case 'block-opener': + // track all newly-opened blocks on the stack. + array_push( + $this->stack, + new WP_Block_Parser_Frame( + new WP_Block_Parser_Block( $block_name, $attrs, array(), '', array() ), + $start_offset, + $token_length, + $start_offset + $token_length, + $leading_html_start + ) + ); + $this->offset = $start_offset + $token_length; + return true; + + case 'block-closer': + /* + * if we're missing an opener we're in trouble + * This is an error + */ + if ( 0 === $stack_depth ) { + /* + * we have options + * - assume an implicit opener + * - assume _this_ is the opener + * - give up and close out the document + */ + $this->add_freeform(); + return false; + } + + // if we're not nesting then this is easy - close the block. + if ( 1 === $stack_depth ) { + $this->add_block_from_stack( $start_offset ); + $this->offset = $start_offset + $token_length; + return true; + } + + /* + * otherwise we're nested and we have to close out the current + * block and add it as a new innerBlock to the parent + */ + $stack_top = array_pop( $this->stack ); + $html = substr( $this->document, $stack_top->prev_offset, $start_offset - $stack_top->prev_offset ); + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + $stack_top->prev_offset = $start_offset + $token_length; + + $this->add_inner_block( + $stack_top->block, + $stack_top->token_start, + $stack_top->token_length, + $start_offset + $token_length + ); + $this->offset = $start_offset + $token_length; + return true; + + default: + // This is an error. + $this->add_freeform(); + return false; + } + } + + /** + * Scans the document from where we last left off + * and finds the next valid token to parse if it exists + * + * Returns the type of the find: kind of find, block information, attributes + * + * @internal + * @since 5.0.0 + * @since 4.6.1 fixed a bug in attribute parsing which caused catastrophic backtracking on invalid block comments + * + * @return array + */ + public function next_token() { + $matches = null; + + /* + * aye the magic + * we're using a single RegExp to tokenize the block comment delimiters + * we're also using a trick here because the only difference between a + * block opener and a block closer is the leading `/` before `wp:` (and + * a closer has no attributes). we can trap them both and process the + * match back in PHP to see which one it was. + */ + $has_match = preg_match( + '/).)*+)?}\s+)?(?P\/)?-->/s', + $this->document, + $matches, + PREG_OFFSET_CAPTURE, + $this->offset + ); + + // if we get here we probably have catastrophic backtracking or out-of-memory in the PCRE. + if ( false === $has_match ) { + return array( 'no-more-tokens', null, null, null, null ); + } + + // we have no more tokens. + if ( 0 === $has_match ) { + return array( 'no-more-tokens', null, null, null, null ); + } + + list( $match, $started_at ) = $matches[0]; + + $length = strlen( $match ); + $is_closer = isset( $matches['closer'] ) && -1 !== $matches['closer'][1]; + $is_void = isset( $matches['void'] ) && -1 !== $matches['void'][1]; + $namespace = $matches['namespace']; + $namespace = ( -1 !== $namespace[1] ) ? $namespace[0] : 'core/'; + $name = $namespace . $matches['name'][0]; + $has_attrs = isset( $matches['attrs'] ) && -1 !== $matches['attrs'][1]; + + /* + * Fun fact! It's not trivial in PHP to create "an empty associative array" since all arrays + * are associative arrays. If we use `array()` we get a JSON `[]` + */ + $attrs = $has_attrs + ? json_decode( $matches['attrs'][0], /* as-associative */ true ) + : array(); + + /* + * This state isn't allowed + * This is an error + */ + if ( $is_closer && ( $is_void || $has_attrs ) ) { + // we can ignore them since they don't hurt anything. + } + + if ( $is_void ) { + return array( 'void-block', $name, $attrs, $started_at, $length ); + } + + if ( $is_closer ) { + return array( 'block-closer', $name, null, $started_at, $length ); + } + + return array( 'block-opener', $name, $attrs, $started_at, $length ); + } + + /** + * Returns a new block object for freeform HTML + * + * @internal + * @since 5.0.0 + * + * @param string $inner_html HTML content of block. + * @return WP_Block_Parser_Block freeform block object. + */ + public function freeform( $inner_html ) { + return new WP_Block_Parser_Block( null, array(), array(), $inner_html, array( $inner_html ) ); + } + + /** + * Pushes a length of text from the input document + * to the output list as a freeform block. + * + * @internal + * @since 5.0.0 + * + * @param null|int $length How many bytes of document text to output. + */ + public function add_freeform( $length = null ) { + $length = $length ?? strlen( $this->document ) - $this->offset; + + if ( 0 === $length ) { + return; + } + + $this->output[] = (array) $this->freeform( substr( $this->document, $this->offset, $length ) ); + } + + /** + * Given a block structure from memory pushes + * a new block to the output list. + * + * @internal + * @since 5.0.0 + * + * @param WP_Block_Parser_Block $block The block to add to the output. + * @param int $token_start Byte offset into the document where the first token for the block starts. + * @param int $token_length Byte length of entire block from start of opening token to end of closing token. + * @param int|null $last_offset Last byte offset into document if continuing form earlier output. + */ + public function add_inner_block( WP_Block_Parser_Block $block, $token_start, $token_length, $last_offset = null ) { + $parent = $this->stack[ count( $this->stack ) - 1 ]; + $parent->block->innerBlocks[] = (array) $block; + $html = substr( $this->document, $parent->prev_offset, $token_start - $parent->prev_offset ); + + if ( ! empty( $html ) ) { + $parent->block->innerHTML .= $html; + $parent->block->innerContent[] = $html; + } + + $parent->block->innerContent[] = null; + $parent->prev_offset = $last_offset ?? $token_start + $token_length; + } + + /** + * Pushes the top block from the parsing stack to the output list. + * + * @internal + * @since 5.0.0 + * + * @param int|null $end_offset byte offset into document for where we should stop sending text output as HTML. + */ + public function add_block_from_stack( $end_offset = null ) { + $stack_top = array_pop( $this->stack ); + $prev_offset = $stack_top->prev_offset; + + $html = isset( $end_offset ) + ? substr( $this->document, $prev_offset, $end_offset - $prev_offset ) + : substr( $this->document, $prev_offset ); + + if ( ! empty( $html ) ) { + $stack_top->block->innerHTML .= $html; + $stack_top->block->innerContent[] = $html; + } + + if ( isset( $stack_top->leading_html_start ) ) { + $this->output[] = (array) $this->freeform( + substr( + $this->document, + $stack_top->leading_html_start, + $stack_top->token_start - $stack_top->leading_html_start + ) + ); + } + + $this->output[] = (array) $stack_top->block; + } +} + +/** + * WP_Block_Parser_Block class. + * + * Required for backward compatibility in WordPress Core. + */ +require_once __DIR__ . '/class-wp-block-parser-block.php'; + +/** + * WP_Block_Parser_Frame class. + * + * Required for backward compatibility in WordPress Core. + */ +require_once __DIR__ . '/class-wp-block-parser-frame.php';