Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 <ID> --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.
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/AGENTS/edit-block-content.md
Original file line number Diff line number Diff line change
@@ -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 <ID> --field=content > /tmp/post-<ID>-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 <ID> --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 <ID> --field=content > /tmp/post-<ID>-after.txt
diff <(fold -w100 /tmp/post-<ID>-before.txt) <(fold -w100 /tmp/post-<ID>-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
<!-- wp:acf/events-grid {"name":"acf/events-grid","data":{"events_0_body":"…Main &\r\nProf. Dr. …"}} /-->
```

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 `<p>` collapses them to spaces. If line
breaks must be visible, the template has to opt in:

```php
<p><?php echo nl2br( esc_html( $ev['body'] ) ); ?></p>
```

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.
17 changes: 17 additions & 0 deletions skills/wordpress/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 <ID> --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
Expand Down