diff --git a/AGENTS.md b/AGENTS.md index 6ae1db8..8841a89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ preconditions and guardrails. |---|---|---| | `/create-block` | Build a new ACF block from a design | block factory | | `/edit-block` | Modify an existing block | block factory | +| `/edit-block-content` | Change a block's **field values** on a live page | `parse_blocks()` | | `/list-blocks` | Audit blocks + missing SCSS imports | block factory | | `/delete-block` | Remove a block + its SCSS import | block factory | | `/sync-tokens` | Figma Variables → `_tokens.scss` | `wp brmbh tokens` | @@ -118,9 +119,31 @@ Plus the scaffold: `wp brmbh scaffold` (idempotent pages + menus, defined in `in **Hard rule:** scaffold never touches ACF; each block owns its own `fields.php`. +## Never string-edit `post_content` + +A page's block content is **structured data, not text**: each ACF block stores its whole field set +as JSON inside an HTML comment, and WordPress hex-escapes `&`, `<`, `>`, `--`, `"` (→ `&`, +`<`, …) plus `\r\n` so the JSON can't break out of that comment. + +`sed`, `str_replace`, or `wp_update_post()` **without `wp_slash()`** strips one backslash level and +every escape degrades into literal on-page text — `&` becomes `u0026`, a newline becomes `rn`. +Nothing errors: it's still valid JSON, just the wrong string. It ships, and someone finds it weeks +later. Worse, whoever finds it usually *deletes* the junk in the ACF field, which destroys the +original character with no trace. + +**Always** `parse_blocks()` → modify → `serialize_blocks()` → `wp_slash()`, then verify: + +```bash +wp post get --field=content | grep -oE '[^\\]u00[0-9a-f]{2}|[a-zäöüß]rn[A-ZÄÖÜ]' # must print nothing +``` + +Full procedure in `AGENTS/edit-block-content.md`. Applies to any scripted content edit, however +small — a one-line image-ID swap is exactly how this happened on a client site (Megaherz, 2026-07-30). + ## What NOT to do - Don't add `register_block_type()` calls by hand — the loader does it. +- Don't `sed`/`str_replace` a `post_content` that contains block comments — see above. - Don't introduce raw hex/px values in templates or block SCSS. - Don't bump a block to `apiVersion: 3`. - Don't rename token slugs — re-value them instead. diff --git a/packages/cli/AGENTS/edit-block-content.md b/packages/cli/AGENTS/edit-block-content.md new file mode 100644 index 0000000..f6b4091 --- /dev/null +++ b/packages/cli/AGENTS/edit-block-content.md @@ -0,0 +1,91 @@ +# /edit-block-content + +Change **field values** of an ACF block on a live page (`post_content` in the DB) — not block code. +For code (`fields.php`, `template.php`, `_style.scss`) use `/edit-block`. + +## The one rule + +`post_content` containing block comments is **structured data, not text**. +Never `sed` / `str_replace` / regex it. Parse → modify → re-serialize. + +A silent, unrecoverable data-loss bug follows from breaking this rule. See [Why](#why-string-editing-corrupts-it). + +## Workflow + +1. **Snapshot first** — the edit is not reversible from the editor UI: + ```bash + wp post get --field=content > /tmp/post--before.txt + ``` +2. **Find the block and field key.** ACF stores repeaters flattened: `events_0_body`, `events_1_image`, plus a `_events_0_body` pointer to the field key. Never guess an index — read the content and match on a stable value (title, URL), because **editors reorder repeater rows**. +3. **Edit via the block API**, in PHP: + ```php + $blocks = parse_blocks( get_post_field( 'post_content', $post_id ) ); + + array_walk_recursive( $blocks, function ( &$v, $k ) { + if ( 'events_0_image' === $k ) { $v = 263; } + } ); + + wp_update_post( [ + 'ID' => $post_id, + 'post_content' => wp_slash( serialize_blocks( $blocks ) ), // wp_slash is REQUIRED + ] ); + ``` +4. **Verify** — this must print nothing: + ```bash + wp post get --field=content | grep -oE '[^\\]u00[0-9a-f]{2}|[a-zäöüß]rn[A-ZÄÖÜ]' + ``` + Any hit means one level of backslashes was stripped. Restore from the snapshot and retry. +5. **Diff the copy, not just the thing you changed:** + ```bash + wp post get --field=content > /tmp/post--after.txt + diff <(fold -w100 /tmp/post--before.txt) <(fold -w100 /tmp/post--after.txt) + ``` + The expected diff is only the field you touched. Anything else — especially disappearing `\` — is corruption. + +## Why string-editing corrupts it + +An ACF block stores its whole field set as JSON inside an HTML comment in `post_content`: + +```html + +``` + +So WordPress hex-escapes anything that could break out of that comment. `serialize_block_attributes()` +(`wp-includes/blocks.php`) runs `wp_json_encode()` and then rewrites: + +| Stored as | Actually means | What a lost backslash leaves behind | +|---|---|---| +| `&` | `&` | `u0026` | +| `<` / `>` | `<` / `>` | `u003c` / `u003e` | +| `--` | `--` (would end the comment!) | `u002d` | +| `"` | `\"` | `u0022` | +| `\r\n` | newline (Enter in a textarea) | `rn` | + +Strip one backslash level and every one of those degrades into literal on-page text. + +**The usual culprit is the write, not the read:** `wp_update_post()` and `wp_insert_post()` call +`wp_unslash()` on their input — magic-quotes-era legacy, they expect raw `$_POST`. Hand them clean +content and it loses one backslash level. Always `wp_slash()` first. Shell round-trips +(`--post_content="$(…)"`, heredocs, `sed`, `jq`) do the same damage. + +**Nothing warns you.** `u0026` is still valid JSON, just the wrong string. `parse_blocks()` parses it, +ACF returns it, the template prints it. There is no checksum and no validation — the only detector is +the verify step above, or a human reading the page weeks later. + +## Rendering gotcha — newlines in a textarea field + +An ACF textarea stores real CRLF, but `esc_html()` inside a `

` collapses them to spaces. If line +breaks must be visible, the template has to opt in: + +```php +

+``` + +Don't "fix" a missing line break by editing the content — check the template first. + +## If content is already corrupted + +1. Do **not** hand-delete the junk tokens in the ACF field. Deleting `u0026` loses the `&` for good and + leaves no trace that anything was ever there — far worse than the visible corruption. +2. Recover the original from a pre-incident DB dump, then reapply via the workflow above. +3. Check every environment separately. A `db push` from a "cleaned" environment makes the loss permanent. diff --git a/skills/wordpress/SKILL.md b/skills/wordpress/SKILL.md index 1bf8679..1d04c96 100644 --- a/skills/wordpress/SKILL.md +++ b/skills/wordpress/SKILL.md @@ -210,6 +210,7 @@ Once doctor reports `class: ready` and the theme is active, the in-theme `AGENTS |---|---| | `/create-block` | Build a new ACF block from a Figma node, screenshot, or field schema | | `/edit-block` | Modify an existing block's json / fields / template / scss | +| `/edit-block-content` | Change a block's field **values** on a live page (never string-edit `post_content`) | | `/list-blocks` | Audit registered blocks, ACF groups, and missing SCSS imports | | `/delete-block` | Confirm + remove a block folder and its SCSS import | | `/sync-tokens` | Regenerate `_tokens.scss` from Figma Variables via MCP | @@ -221,6 +222,22 @@ Once doctor reports `class: ready` and the theme is active, the in-theme `AGENTS Invoke them directly (e.g. `/create-block`) or let the agent pick them up from context. +## Hard rule — never string-edit `post_content` + +Block content is structured data: each ACF block stores its field set as JSON inside an HTML +comment, with `&`, `<`, `>`, `--`, `"` and newlines hex-escaped so the JSON can't break out of that +comment. `sed`/`str_replace`, or `wp_update_post()` **without `wp_slash()`** (it calls `wp_unslash()` +on its input), strips one backslash level — `&` silently becomes the literal text `u0026`, +a newline becomes `rn`. It stays valid JSON, so nothing errors and it ships. + +Use `parse_blocks()` → modify → `serialize_blocks()` → `wp_slash()`, then verify: + +```bash +wp post get --field=content | grep -oE '[^\\]u00[0-9a-f]{2}|[a-zäöüß]rn[A-ZÄÖÜ]' # must print nothing +``` + +See `/edit-block-content` for the full procedure and recovery steps. + ## CLI reference ```bash