| name | wp-block-markup |
| description | WordPress block markup syntax and HTML structure. Understand block delimiters, attributes, nesting, and how blocks serialize to the database. Based on Gutenberg source code. |
| compatibility | WordPress 6.1+. List-item blocks required since 6.1. Verified against Gutenberg trunk April 2026. |
WordPress Block Markup Syntax
When to Use
Use this skill when:
- Writing raw block markup for patterns, templates, or REST API content pushes
- Debugging block validation errors ("Block contains unexpected content")
- Converting HTML to block format programmatically
- Working with block templates in PHP
- Understanding how blocks serialize to
post_content
Block Grammar
WordPress blocks use HTML comments as delimiters. The content between comments is valid HTML that the parser reconstructs on load.
Three Block Forms
<p>Content here.</p>
<figure class="wp-block-image size-large">
<img src="image.jpg" alt="" class="wp-image-123"/>
</figure>
Naming Rules
Core blocks omit the namespace. Custom blocks require one.
✓ Core block (no namespace)
✗ Never use core/ prefix
✓ Custom block with namespace
Attribute Serialization
Two Storage Locations
Block attributes are stored in two places, not one:
| Location | What Goes Here | Example |
|---|
| Comment JSON | Values not derivable from HTML: IDs, booleans, enums, style objects, layout config | {"id":123,"sizeSlug":"large"} |
| HTML content | Values sourced from the DOM: text content, src, href, alt, CSS classes | <img src="..." alt="..."/> |
The block's block.json defines which attributes go where via the source property:
source: (none) → stored in comment JSON
source: "attribute"→ read from HTML element attribute (src, href, alt, etc.)
source: "html" → read from element innerHTML
source: "text" → read from element textContent
JSON Format Rules
- Valid JSON object (double quotes, no trailing commas)
- Compact format: no spaces after colons or commas (
{"key":"val"} not { "key" : "val" })
- Omit attributes with default values (keeps markup clean)
- Attribute order doesn't matter
- Must be single line (no line breaks in JSON)
Common Attribute Patterns
Colors (palette preset):
{"backgroundColor":"primary","textColor":"white"}
Produces: class="has-primary-background-color has-white-color has-text-color has-background"
Colors (custom hex):
{"style":{"color":{"background":"#ff0000","text":"#ffffff"}}}
Produces: class="has-text-color has-background" style="color:#ffffff;background-color:#ff0000"
Typography:
{"style":{"typography":{"fontSize":"1.5rem","fontWeight":"700","lineHeight":"1.4"}}}
Typography (preset):
{"fontSize":"large"}
Produces: class="has-large-font-size"
Spacing:
{"style":{"spacing":{"padding":{"top":"2rem","right":"1rem","bottom":"2rem","left":"1rem"},"margin":{"top":"0","bottom":"2rem"}}}}
Border:
{"style":{"border":{"radius":"8px","width":"1px","color":"#e0e0e0","style":"solid"}}}
Layout:
{"layout":{"type":"constrained","contentSize":"1200px"}}
{"layout":{"type":"flex","flexWrap":"nowrap","justifyContent":"space-between"}}
Alignment:
{"align":"full"}
{"align":"wide"}
{"textAlign":"center"}
Block Reference
Paragraph
No special class by default. Attributes in comment only when non-default.
<p>Plain text paragraph.</p>
<p class="has-drop-cap">First letter is large.</p>
<p class="has-text-align-center">Centered text.</p>
<p class="is-style-notice">Custom style variant.</p>
<p class="has-text-color" style="color:#555555;font-size:1.125rem">Styled paragraph.</p>
Heading
level attribute controls <h1>-<h6>. Default is 2 (omit from JSON when level=2).
<h2 class="wp-block-heading">Default H2</h2>
<h3 class="wp-block-heading">H3 Heading</h3>
<h1 class="wp-block-heading" style="font-size:3rem;font-weight:800">Large H1</h1>
<h2 class="wp-block-heading has-text-align-center">Centered H2</h2>
<h2 class="wp-block-heading" id="my-section">My Section</h2>
List + List Item
Lists are container blocks. Each <li> must be wrapped with <!-- wp:list-item --> delimiters. This is required since WordPress 6.1.
<ul class="wp-block-list">
<li>First item</li>
<li>Second item</li>
</ul>
<ol class="wp-block-list">
<li>Step one</li>
<li>Step two</li>
</ol>
<ol class="wp-block-list" start="5">
<li>Fifth item</li>
Parent item
Nested child
Common mistake: Omitting <!-- wp:list-item --> wrappers. Without them, the editor shows the list as a single Classic block or triggers block recovery.
Image
url, alt, caption are sourced from the HTML. id, sizeSlug, linkDestination go in comment JSON.
<figure class="wp-block-image size-large">
<img src="https://example.com/image.jpg" alt="Description" class="wp-image-123"/>
</figure>
<figure class="wp-block-image size-large">
<img src="image.jpg" alt="" class="wp-image-123"/>
<figcaption class="wp-element-caption">Image caption here</figcaption>
</figure>
<figure class="wp-block-image size-large">
<a href="https://example.com"><img src="image.jpg" alt="" =/>
Table
<figure class="wp-block-table">
<table>
<thead>
<tr><th>Header 1</th><th>Header 2</th></tr>
</thead>
<tbody>
<tr><td>Cell 1</td><td>Cell 2</td></tr>
<tr><td>Cell 3</td><td>Cell 4</td></tr>
</tbody>
</table>
</figure>
<figure class="wp-block-table">
<table =>
NamePrice
Item$10
Table caption
...
Buttons + Button
Buttons is a container. Each button is an inner block. url, linkTarget, rel go in the button's comment JSON.
<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://example.com">Click Me</a></div>
</div>
<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://example.com" target="_blank" rel="nofollow sponsored noopener">External Link</a></div>
</div>
Primary
Outline
Group
Container block for grouping other blocks. Uses useInnerBlocksProps.save().
<div class="wp-block-group">
<h2 class="wp-block-heading">Title</h2>
<p>Content inside group.</p>
</div>
<div class="wp-block-group">
</div>
<div class="wp-block-group alignfull has-light-gray-background-color has-background" style="padding-top:4rem;padding-bottom:4rem">
</div>
<section class=>
Columns + Column
<div class="wp-block-columns">
<div class="wp-block-column" style="flex-basis:33.33%">
<p>Column 1 content.</p>
</div>
<div class="wp-block-column" style="flex-basis:66.66%">
<p>Column 2 content.</p>
</div>
</div>
<div class="wp-block-columns are-vertically-aligned-center">
<div class="wp-block-column">...</div>
</div>
Quote
Quote blocks use InnerBlocks for content (since WordPress 6.3+). Citation is a separate RichText attribute.
<blockquote class="wp-block-quote">
<p>The quote text goes here.</p>
<cite>Author Name</cite>
</blockquote>
Cover
<div class="wp-block-cover alignfull" style="min-height:500px">
<span aria-hidden="true" class="wp-block-cover__background has-black-background-color has-background-dim-50 has-background-dim"></span>
<img class="wp-block-cover__image-background wp-image-123" alt="" src="bg.jpg" data-object-fit="cover"/>
<div class="wp-block-cover__inner-container">
<h1 class="wp-block-heading has-text-align-center">Hero Title</h1>
</div>
</div>
Embed
URL goes as plain text inside the wp-block-embed__wrapper div.
<figure class="wp-block-embed is-type-video is-provider-youtube wp-block-embed-youtube wp-embed-aspect-16-9 wp-has-aspect-ratio">
<div class="wp-block-embed__wrapper">
https://www.youtube.com/watch?v=dQw4w9WgXcQ
</div>
</figure>
<figure class="wp-block-embed is-type-rich is-provider-twitter wp-block-embed-twitter">
<div class="wp-block-embed__wrapper">
https://twitter.com/user/status/123
</div>
<figcaption class="wp-element-caption">Tweet caption</figcaption>
</figure>
Separator
<hr class="wp-block-separator has-alpha-channel-opacity"/>
<hr class="wp-block-separator has-alpha-channel-opacity is-style-wide"/>
Spacer
Always self-closing in older versions, but balanced block with aria-hidden in current Gutenberg.
<div style="height:100px" aria-hidden="true" class="wp-block-spacer"></div>
Code
<pre class="wp-block-code"><code>const x = 42;
console.log(x);</code></pre>
Preformatted
<pre class="wp-block-preformatted">Preformatted text
preserves whitespace
and line breaks.</pre>
Pullquote
<figure class="wp-block-pullquote">
<blockquote>
<p>A highlighted quote pulled from the content.</p>
<cite>Citation Source</cite>
</blockquote>
</figure>
Details (Disclosure)
<details class="wp-block-details">
<summary>Click to expand</summary>
<p>Hidden content revealed on click.</p>
</details>
<details class="wp-block-details" open>
<summary>Already expanded</summary>
<p>Visible by default.</p>
</details>
HTML (Raw)
Content is stored as-is. No validation against save() output.
<div class="custom-widget">
<script>console.log('hello');</script>
</div>
Video
<figure class="wp-block-video">
<video controls src="https://example.com/video.mp4"></video>
<figcaption class="wp-element-caption">Video caption</figcaption>
</figure>
Audio
<figure class="wp-block-audio">
<audio controls src="https://example.com/audio.mp3"></audio>
</figure>
Gallery
Gallery is a container of image inner blocks.
<figure class="wp-block-gallery has-nested-images columns-3 is-cropped">
<figure class="wp-block-image size-large">
<img src="img1.jpg" alt="" class="wp-image-1"/>
</figure>
<figure class="wp-block-image size-large">
<img src="img2.jpg" alt="" class="wp-image-2"/>
</figure>
<figcaption class="wp-element-caption">Gallery caption</figcaption>
</figure>
Media & Text
<div class="wp-block-media-text is-stacked-on-mobile">
<figure class="wp-block-media-text__media">
<img src="img.jpg" alt="" class="wp-image-123 size-full"/>
</figure>
<div class="wp-block-media-text__content">
<p>Content next to the image.</p>
</div>
</div>
Pattern Reference
Inserts pattern content (not a live reference). Self-closing.
Dynamic Blocks
Dynamic blocks render server-side via PHP. Only attributes are stored. Always self-closing.
[contact-form-7 id="123"]
Nesting Rules
Container Blocks and Their Expected Children
| Container | Expected Inner Blocks |
|---|
wp:list | wp:list-item only |
wp:buttons | wp:button only |
wp:columns | wp:column only |
wp:gallery | wp:image primarily |
wp:group | Any block |
wp:cover | Any block (inside __inner-container) |
wp:quote | Any block + <cite> |
wp:details | Any block (after <summary>) |
wp:media-text | Any block (inside __content) |
Deeply Nested Example
<div class="wp-block-group alignfull" style="padding-top:4rem;padding-bottom:4rem">
<div class="wp-block-columns">
<div class="wp-block-column">
<h3 class="wp-block-heading">Card Title</h3>
<p>Card content.</p>
</div>
<div class="wp-block-column">
<h3 class="wp-block-heading">Card Title</h3>
<p>Card content.</p>
</div>
Validation and Block Recovery
How Validation Works
- Parser extracts block name, attributes, and innerHTML from stored
post_content
- WordPress runs the block's
save() function with the extracted attributes
- The output is compared to the stored innerHTML
- If they don't match → "Block contains unexpected content" (block recovery prompt)
Common Causes of Block Recovery Errors
| Cause | Example | Fix |
|---|
| Missing list-item wrappers | <li> without <!-- wp:list-item --> | Add list-item delimiters |
| Wrong class names | wp-block-paragraph on <p> (paragraphs have no base class) | Remove the class |
| Extra attributes | class="wp-block-heading" when no className set | Let useBlockProps handle classes |
| HTML structure mismatch | <div> where <figure> expected | Match save() output structure |
| Missing InnerBlocks | Button directly inside buttons div without comment delimiter | Wrap each inner block |
| Stale block format | Old list format without list-items (pre-6.1) | Run block recovery in editor |
Error Recovery Strategy
The parser uses best-effort recovery, not strict validation:
- Missing closers trigger implicit closure
- Nested unclosed blocks collapse the stack sequentially
- Leading HTML becomes freeform content
- No exceptions thrown; parsing continues with recovered state
Deprecation Chain
When current save() doesn't match:
- Check each deprecated version in reverse chronological order
- If deprecated
save() produces matching markup, run its migrate() function
- Pass migrated attributes to current
save() for final validation
- Stop at first match
PHP Block Templates
For theme templates and register_post_type:
$template = array(
array( 'core/heading', array(
'level' => 2,
'content' => 'Welcome'
) ),
array( 'core/paragraph', array(
'placeholder' => 'Add content...'
) ),
array( 'core/columns', array(), array(
array( 'core/column', array(), array(
array( 'core/paragraph', array() )
) ),
array( 'core/column', array(), array(
array( 'core/paragraph', array() )
) )
) )
);
register_post_type( 'product', array(
'template' => $template,
'template_lock' => 'all' // or 'insert', 'contentOnly', false
) );
Programmatic Block Generation Checklist
When generating block markup via code (REST API, scripts, migrations):
- Every block needs comment delimiters — no bare HTML tags
- Compact JSON —
{"key":"val"} not { "key" : "val" }
- List items need
<!-- wp:list-item --> — required since WP 6.1
- Button
url goes in comment JSON — not just in <a href="">
- Omit default attribute values — only serialize non-defaults
- Match the save() output structure exactly — wrong element types trigger recovery
- No
wp-block-paragraph class on <p> tags — paragraphs have no base class by default
wp-block-heading class on headings — the heading save() produces this
wp-element-button class on button links — required alongside wp-block-button__link
size-{slug} class on image figures — e.g., size-large, size-full
wp-image-{id} class on <img> tags — when image ID is known
- Test in the block editor — load the post and check for yellow "Attempt Block Recovery" warnings
Resources