Skip to content

Code reference: stop running wpautop over descriptions; render snippet source as a code block - #581

Draft
sirreal wants to merge 4 commits into
trunkfrom
add/php-snippet-source-code-block
Draft

Code reference: stop running wpautop over descriptions; render snippet source as a code block#581
sirreal wants to merge 4 commits into
trunkfrom
add/php-snippet-source-code-block

Conversation

@sirreal

@sirreal sirreal commented Aug 26, 2026

Copy link
Copy Markdown
Member

What trunk does

get_description() runs every reference description through the_content with wpautop() registered. The description is already complete HTML: phpdoc-parser renders it with Parsedown at import and then runs fix_newlines(), which turns a soft wrap after a sentence-ending . into a literal <br> and merges every other newline into a space. wpautop() has nothing left to do, so what it does is wrong:

  • It wraps the [code lang="php"] shortcode text in <p>, and do_shortcode() then expands the shortcode inside that paragraph. 182 published posts serve <p> <pre class="wp-block-code">…</pre></p>. A <pre> start tag closes an open <p>, so browsers eject the code block and leave an empty paragraph.
  • It wraps the parser's snippet placeholder comment in <p>, or leaves an unclosed <p> before one inside a list item, so <php-snippet> renders inside a paragraph.

Interactive snippets keep their PHP only in a <script type="application/x-php+json"> payload and build their UI in a shadow root. Read the page without running that module and there is no code anywhere in the document.

Changes

1. Stop running wpautop() over descriptions

get_description() removes wpautop around its one the_content call and restores it at whatever priority it held, the idiom already used for filter_code_content on the lines above. The shortcode and texturize passes still run.

Before, /reference/classes/wpdb/insert/:

<p>Examples:</p>
<p> <pre class="wp-block-code"><code lang="php" class="language-php line-numbers">$wpdb-&gt;insert(
…
);</code></pre></p>

After:

<p>Examples:</p> <pre class="wp-block-code"><code lang="php" class="language-php line-numbers">$wpdb-&gt;insert(
…
);</code></pre>

No <br> is removed and nothing reflows: the line breaks in descriptions are the parser's literal <br>, which wpautop() only respelled as <br />.

Each description is now served on one line. fix_newlines() collapsed the newlines and wpautop() was reinserting them around block tags. Rendering is identical; view-source is less readable (wpdb::prepare is 1,379 characters on one line).

Corpus

Every published parser post rendered both ways on the sandbox: 12,399 posts, 3,583 with a long description.

difference between trunk and this branch posts
[code] shortcode <pre> contents differ 0
paragraphs that only wpautop() supplied 0
<br> respelled <br />, line breaks around block tags 3,397
[code] <pre> no longer wrapped in <p> 182
other 4

The 4: wp_check_php_mysql_versions and update_core (whitespace around nested lists), get_linkobjectsbyname (a raw <li> in prose, malformed either way), WP_Duotone::get_svg_definitions (a stray …… text node gains or loses a paragraph).

2. Render the snippet source as a code block

render_php_code_snippet() appends <pre class="wp-block-code"><code class="language-php"> holding the source inside <php-snippet>, after the payload.

Before:

<p><php-snippet><script type="application/x-php+json">"$processor = new …"</script></php-snippet></p>

After:

<php-snippet><script type="application/x-php+json">"$processor = new …"</script><pre class="wp-block-code"><code class="language-php">$processor = new …</code></pre></php-snippet>

The <p> is gone because of change 1, not this one. Without change 1 the <pre> would close that paragraph and the browser would move the code block out of the element.

The element's shadow root has no <slot>, so the block disappears once the element upgrades. It is for the reader whose browser never gets there, and for anything reading the HTML rather than executing it. Its text is byte-identical to the JSON payload, so the shown source and the run source cannot drift.

Decisions

  • Remove the filter rather than steer around it. A <div> around <php-snippet> survives the ejection but leaves two empty paragraphs and the 182 broken pages. Converting the placeholder to a block element before wpautop() runs (a the_content filter at priority 9) avoids the empty paragraphs, couples the block to filter ordering, and leaves the 182. CSS to hide the ejected copy hides the fallback from the readers it exists for. Only removing the filter fixes the cause.
  • set_modifiable_text() rather than esc_html(). esc_html() does not double-encode, so a literal &lt;egg&gt; in a snippet (the class_list() example has one) would pass through and the browser would show <egg>: the shown source would differ from the source that runs. The Tag Processor encodes for the text position it writes into. It only writes to a #text token, so the <code> element carries a placeholder for it to overwrite.
  • Scope. get_description() has one caller, the code description block, on parser post types. Handbooks are untouched. The @see loop in the same file has an older unguarded remove_filter/add_filter pair for wpautop; it behaves the same at the default priority and is left alone here.
  • Exception safety. No try/finally around the filter removal, matching the filter_code_content handling beside it.

Verifying

Read the served HTML (view-source or curl). DevTools shows the repaired tree, which hides the bug.

  • /reference/classes/wp_html_tag_processor/class_list/: <php-snippet> at block level, <pre> inside it, no <p> touching it, and the <code> text decodes to exactly the JSON payload.
  • /reference/classes/wpdb/insert/: <pre class="wp-block-code"> no longer inside <p>.
  • /reference/functions/wp_insert_post/: curly quotes and autolinked references still present.
  • Any snippet page with JavaScript disabled: the source is visible.

Risk and rollback

git revert, no reimport and no data change. Page caches serve the old markup until they expire.

Testing

No PHP test harness here, so manual:

  • The corpus diff above, read-only and paced, on the sandbox.
  • The branch deployed on the sandbox, served HTML read with curl: the three pages above hold, class_list carries the DocBlock's literal &lt;egg&gt; as &amp;lt;egg&amp;gt;, and wp_insert_post has the same curly-quote and autolink counts as production.
  • set_modifiable_text() / get_modifiable_text() round trip against a real WP_HTML_Tag_Processor: <, >, &, quotes, a literal </code></pre><script>alert(1)</script>, heredocs, non-ASCII. All return the original string.
  • The real filter chain (core WP_Hook, real wpautop(), both nested the_content passes) run locally with and without the change. Trunk produces <p><php-snippet>…</php-snippet></p>; this branch produces the markup above. Core's do_blocks() removes wpautop before the outer pass reaches priority 10, so the assembled page never goes through it again.
  • Four placeholder shapes (own block, whole description, inside a list item, after a list) loaded in a browser against the real playground.wordpress.net/php-code-snippet.js: the <pre> is a child of <php-snippet> in each, hidden after upgrade, readable with the module blocked.

Written by Claude in Claude Code at my direction and reviewed by me; the parser, wpautop() and custom-element behaviour described here was measured, not assumed.

`<php-snippet>` carries its PHP in a `<script type="application/x-php+json">`
payload, so a page without the Playground module shows nothing where a snippet
should be, and the source is absent from the markup.

Append a `<pre class="wp-block-code"><code class="language-php">` copy of the
source inside the element. `<php-snippet>` builds its interface in a shadow
root with no slot, so this is what a reader sees before the module upgrades the
element and all they see if it never loads.

The text goes in through `WP_HTML_Tag_Processor::set_modifiable_text()`, which
encodes it for its context. `set_modifiable_text()` only accepts a `#text`
token, so the code element carries a placeholder to overwrite.

Wrap the element in a `<div>`. A `<pre>` start tag closes an open `<p>`, and
the description renders snippets inside one, since `wpautop()` puts a bare
placeholder comment in a paragraph. Unwrapped, the parser carries the code
block alone out of the paragraph, tearing it off the element and leaving it
outside the shadow root, showing code the element already shows. A `<div>`
closes the paragraph the same way but takes the whole snippet with it.
@sirreal
sirreal force-pushed the add/php-snippet-source-code-block branch from 5bbde91 to 591f4f3 Compare August 26, 2026 06:38
@sirreal
sirreal marked this pull request as draft August 26, 2026 06:48
The parser renders the long description with Parsedown at import and then runs
its own `fix_newlines()`, which turns a soft wrap after a sentence-ending `.`
into a literal `<br>` and merges every other newline into a space. So
`post_content` arrives as complete HTML with no bare newlines inside
paragraphs. `get_description()` runs it through `the_content` for the shortcode
and texturize passes, and `wpautop()` rides along at the same priority with
nothing left to do.

What it does instead, measured over all 12,399 published parser posts:

- Wraps the snippet placeholder comment in `<p>`, or leaves an unclosed `<p>`
  before one inside a list item. A `<pre>` start tag closes an open `<p>`, so
  the code block rendered inside `<php-snippet>` is carried out of the element
  by the HTML parser.
- Wraps the `[code]` shortcode's `<pre class="wp-block-code">` in `<p>` on 182
  posts, the same malformed shape, which browsers repair by ejecting the
  `<pre>` and leaving an empty paragraph behind.
- Respells the parser's `<br>` as `<br />` and re-breaks lines around block
  tags. Neither renders differently.

Remove `wpautop` around the one `the_content` call, restoring it at whatever
priority it held, the way `filter_code_content` is already handled here. The
`[code]` shortcode's `_trim_code()` already tolerates content wpautop never
touched: its rendered `<pre>` contents are byte-identical with and without.
The `<div>` existed only to survive the paragraph `wpautop()` put around the
snippet placeholder: it closed that paragraph the way the `<pre>` would have,
but carried the whole snippet out instead of tearing the code block off the
element.

With `wpautop()` no longer running over descriptions the placeholder is
already at block level, so the code block nests in `<php-snippet>` on its own
and the wrapper has nothing left to do. Removing it also drops the two empty
paragraphs the parser left on either side of it.
The comment blamed `wpautop()` for the `<br>` in descriptions. Those are the
parser's: `fix_newlines()` emits them at import and `wpautop()` only respells
them `<br />`. The comment also left out the larger effect, the `[code]`
shortcode's `<pre>` wrapped in a paragraph on 182 posts.
@sirreal sirreal changed the title Interactive snippets: render the snippet source as a code block Code reference: stop running wpautop over descriptions; render snippet source as a code block Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant