The Snippet Generator lives at workspace.matters.ai/content/snippet-generator. It lets
the content team build complex WordPress block snippets (testimonials, video players,
stats grids, FAQs, spacers, etc.) by filling out forms, then copy-paste the generated HTML
into the WordPress post body.
The public site (matters.ai) renders blog posts via BlogContent.tsx,
which parses the post HTML with html-react-parser and intercepts specific markers in
the markup to swap in custom React components. That hand-off — generator HTML in WordPress
→ parser interceptor in Next.js — is the core of the system.
| Approach | Best for | Example |
|---|---|---|
| Snippet block | Reusable content fragments that get embedded inside a blog post body | Spacer, CTA Banner, FAQ block, Stats Grid |
| ACF field | Per-post metadata that surrounds the content | pdf_download_url, is_featured |
| CPT module | Entire structured content types with their own admin UI | Industries, Videos, Use Cases |
Snippet blocks are perfect when:
┌──────────────────────────────────────────────────────────────────────┐
│ Workspace: /content/snippet-generator │
│ │
│ ┌────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Form │ → │ generator- │ → │ Generated HTML │ │
│ │ (per block)│ │ logic.ts │ │ + Live preview │ │
│ └────────────┘ └─────────────────┘ └────────────────────┘ │
│ │ │
│ ▼ │
│ Copy to clipboard │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ WordPress post editor │
│ Editor pastes HTML as a Custom HTML block │
│ (no plugins; the markup is portable + accessible) │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────┐
│ matters.ai/blogs/<slug> │
│ │
│ getEnrichedPost(slug) │
│ │ │
│ ▼ │
│ BlogContent renders post HTML with html-react-parser │
│ │ │
│ ├─ <videoplayer> → <VideoPlayer/> │
│ ├─ div[data-testimonial-id] → <TestimonialCard/> │
│ ├─ div.matters-faq-block → <FAQAccordion/> │
│ ├─ div.wp-block-spacer → native HTML (CSS only) │
│ └─ everything else → native HTML │
└──────────────────────────────────────────────────────────────────────┘
As of 2026-05-29 the generator supports 20 component types (19 distinct blocks; statements has a statements-large variant, so the ComponentType union has 20 members). Source: types.ts ComponentType.
| Block | Type key | Output | Renderer |
|---|---|---|---|
| Inline Blockquote | inlineblockquote | <inlineblockquote> tag | Native + CSS |
| Video Player | videoplayer | <videoplayer ...> | VideoPlayer |
| Stories | stories | Story cards grid | Native + CSS |
| Statements | statements / statements-large | Quote panels | Native + CSS |
| Story Points | story-points | Bulleted points panel | Native + CSS |
| Custom Div | custom-div | Arbitrary HTML/CSS | Native |
| Responsive Image | responsive-image | Picture element with mobile variant | Native + CSS |
| Storylane | storylane | iframe embed | Native |
| YouTube Video | youtube-video | iframe embed | Native |
| Matters Video Player | matters-video-player | <matters-video-player ...> | MattersVideoPlayer |
| Button (CTA) | button | Styled anchor | Native + CSS |
| Case Study Info | case-study-info | Sidebar/metadata panel | Native + CSS |
| Challenges & Solutions | challenges-solutions | Two-column or matrix | Native + CSS |
| Stats Grid | stats-grid | Grid of metrics | Native + CSS |
| Pull Quote | pull-quote | Editorial pull-quote | Native + CSS |
| CTA Banner | cta-banner | Full-width call-to-action | Native + CSS |
| Testimonial | testimonial | Registry-linked or custom | TestimonialCard (registry mode) |
| Spacer | spacer | Responsive vertical spacing | Native + CSS |
| FAQ | faq | <details> accordion + auto FAQPage JSON-LD | FAQAccordion |
Each block has:
<XxxForm> component in apps/workspace/app/(workspace)/content/components/generators/case "xxx" in generator-logic.tsHistorically the generator was one-directional: fill a form → copy HTML. There was no way to reopen a previously generated snippet and tweak it — you rebuilt it from scratch. It now round-trips.
Every generated snippet ends with an invisible HTML comment that carries the full
editor config (type + data + toggles) as UTF-8-safe base64 JSON, wrapped in a
versioned marker:
<!-- matters-snippet v1 eyJ2IjoxLCJ0eXBlIjoic3RhdHMtZ3JpZCIsImRhdGEiOnsuLi59fQ== -->
v1) so a future schema change can be detected and migrated.serializeSnippetConfig, parseSnippetConfig). Encoding is
btoa(encodeURIComponent(json)) — pure-ASCII, so it handles emoji / accents / smart
quotes without escape/unescape.The Import / Edit button in the Source-Component header opens a modal. Paste any
previously generated snippet → it parses the trailing comment, validates the type against
COMPONENT_LABELS, restores the exact data/toggles into the right form (merged onto
current defaults so newer fields stay populated), and switches selectedComponent to that
type. The modal is role="dialog" + aria-modal, closes on Esc or backdrop click, and
autofocuses the textarea.
Restoring config is a per-type dispatch in
client-page.tsx
(getActiveConfig reads the active type's state, applyImportedConfig writes it). Both
switches end in a never-typed exhaustiveness guard, so adding a new ComponentType
without wiring both halves is a compile error — the round-trip can't silently drift.
Pre-existing snippets (generated before this feature shipped) have no embedded comment, so they can't be auto-parsed — reverse-parsing 19 different HTML shapes would be unreliable. Anything generated from now on round-trips; regenerate old high-value snippets once to make them editable.
Any multi-line content field across every generator form can be edited as plain text or
as a WYSIWYG (Tiptap) editor — the choice is the user's, per field. This is built into the
shared SmartInput
primitive (so it propagates to every generator form — 19 *Form.tsx files under
content/components/generators/), not hand-wired per form.
type="textarea" field./<\/?[a-zA-Z][^>]*>/). Fields whose
content already contains markup (pull-quote <mark>, FAQ <a>, statements <strong>)
open in rich mode; plain fields stay plain. This is how the editing mode "persists" —
the formatting lives in the saved HTML, so on re-import the field re-opens correctly.next/dynamic (ssr:false), so it only downloads when a
field is actually switched to rich mode — SmartInput's other usages across the workspace
don't pay the ~100 KB cost.richText={false} for raw HTML/CSS/JSON fields that must stay literal:
Custom Div content/styles/attributes, Video Player Metadata (JSON).Single-line fields (titles, labels, URLs, hex colors) intentionally stay plain — rich text is a multi-line content concern, and a WYSIWYG toolbar on a hex field would be wrong.
The Stats Grid block (StatsGridForm.tsx)
has the richest layout config. Its CSS lives in
components/blogs/styles.css under .stats-grid.
Variants (stats-grid--<variant>): spotlight, editorial, minimal, bordered,
tiles, filled. The Tiles variant reuses the Bordered-cards theme but collapses
the cards into one seamless grid — gap: 0, no radius/shadow, and shared hairline borders
(the container draws the top+left edge; each tile draws its own right+bottom edge, so
adjacent borders merge into a single line at any column count).
Columns (stats-grid--cols-<n>): 1 / 2 / 3 / 4 / 5 / auto, default auto. Fixed
counts use explicit repeat(N, minmax(0, 1fr)) so "2 columns" really means two columns;
only auto uses repeat(auto-fit, …) and lets the browser choose by card density. Fixed
counts collapse responsively (3/4/5 → 2 under 768px, all → 1 under 480px).
A subtle earlier bug: every column count used
auto-fit, so "2 columns" still packed 4 cards across on a wide blog container. The fix was to use explicit track counts for the numbered options and reserveauto-fitforautoonly.
For blocks that don't need React hydration on the public site, the generator just emits plain HTML that styles itself via Tailwind classes or inline CSS. The Spacer block is the cleanest example — see below.
For blocks that do need React hydration (TestimonialCard, VideoPlayer, FAQ accordion), the markup must include a stable selector the parser can detect. Conventions:
| Marker | Used by | Detection in BlogContent.tsx |
|---|---|---|
Custom tag (<videoplayer>) | Video Player, Matters Video Player | domNode.name === "videoplayer" |
data-* attribute (data-testimonial-id) | Testimonial (registry mode) | domNode.attribs["data-testimonial-id"] |
Top-level class (matters-faq-block) | FAQ block | domNode.attribs.class?.includes("matters-faq-block") |
The marker must be stable — once content is published with one marker, changing it requires a content migration.
The shortest path is to mirror the Spacer block (the most recent addition, cleanest example). Concrete file map:
ComponentType + add interfaces#In types.ts:
export type ComponentType =
| "inlineblockquote"
// ...existing...
| "spacer"
| "your-block"; // ← add
export interface YourBlockData { /* ... */ }
export interface YourBlockToggles { /* ... */ }
In constants.ts:
import { YourIcon } from "lucide-react";
export const COMPONENT_ICONS: Record<ComponentType, React.ElementType> = {
// ...
"your-block": YourIcon,
};
export const COMPONENT_LABELS: Record<ComponentType, string> = {
// ...
"your-block": "Your Block",
};
Create apps/workspace/app/(workspace)/content/components/generators/YourBlockForm.tsx
following the existing pattern (use SpacerForm.tsx
as the template — it uses the shared SmartForm wrapper for boilerplate-free field
rendering).
In generator-logic.ts,
add a new case:
case "your-block": {
if (!yourBlockData || !yourBlockToggles) return "";
// …build a string of HTML…
return html;
}
In client-page.tsx:
const [yourBlockData, setYourBlockData] = useState<YourBlockData>(/* defaults */);
const [yourBlockToggles, setYourBlockToggles] = useState<YourBlockToggles>(/* defaults */);
// In the form-render switch:
{selectedComponent === "your-block" && (
<YourBlockForm
data={yourBlockData}
setData={setYourBlockData}
toggles={yourBlockToggles}
setToggles={setYourBlockToggles}
/>
)}
And in the useEffect that calls generateComponentCode:
yourBlockData,
yourBlockToggles,
If the generated HTML doesn't render correctly in isolation (e.g. Tailwind JIT can't see dynamic classes), add a preview branch in PreviewRenderer.tsx. The Spacer block uses this because Tailwind can't precompile its arbitrary classes.
If the block needs to hydrate to a React component on the public site (e.g. accordion
behavior, video player, registry lookup), add an interceptor in BlogContent.tsx
inside MemoizedArticleContent:
const parserOptions: HTMLReactParserOptions = {
replace: (domNode) => {
if (
domNode instanceof DOMElement &&
domNode.name === "div" &&
domNode.attribs?.class?.includes("matters-your-block")
) {
// Extract data, return your React component
return <YourBlock ... />;
}
// ... existing interceptors
},
};
The Spacer block is the canonical "lightweight" snippet — no React hydration, no parser interceptor, pure HTML/CSS that renders the same in the workspace preview, the WP editor preview, and the public site.
Users in the workspace pick:
top / bottom / vertical / height)mobile, optional tablet, optional desktop)px / rem / em / vh / %)tailwind or inline)aria-hidden, !important, extra classes<div aria-hidden="true" class="wp-block-spacer h-0 mt-[64px] md:!mt-[80px] lg:!mt-[92px]"></div>
<div aria-hidden="true" class="wp-block-spacer matters-spacer-abc123" style="margin-top:64px !important"></div>
<style>@media (min-width:768px){.matters-spacer-abc123{margin-top:80px !important;}} @media (min-width:1024px){.matters-spacer-abc123{margin-top:92px !important;}}</style>
The inline-mode output is self-contained: it works in any rendering context that doesn't have Tailwind. Use this when you're not sure the destination compiles your arbitrary classes. The blog renderer (BlogContent.tsx) does compile them, so Tailwind mode is fine there — but other surfaces (email exports, WP-rendered previews) won't, hence the mode toggle.
mt-[64px], etc.). The preview falls
back to inline styles so the spacer is always visible during authoring.<style> block (in
inline mode) does the job.The FAQ block is the canonical "rich hydrated" snippet — its generated markup is
collapsible-by-default via <details>, the public site upgrades it to a fancier React
accordion, and it auto-generates FAQPage JSON-LD on the blog detail page.
<div class="matters-faq-block" data-matters-faq>
<details class="matters-faq-item" data-q="What is Matters AI?">
<summary>What is Matters AI?</summary>
<div class="matters-faq-answer"><p>Rich answer HTML…</p></div>
</details>
<details class="matters-faq-item" data-q="How is it priced?">
<summary>How is it priced?</summary>
<div class="matters-faq-answer"><p>…</p></div>
</details>
</div>
Key invariants:
<details> elements work without JavaScript — the markup is accessible everywhere
it's pasted, even if no parser interceptor runs.data-q on each item carries the question text in clean form, so the schema
extractor doesn't have to strip HTML..matters-faq-answer div is the canonical answer container, used by both the schema
extractor (to extract plain text) and the React accordion (to inject as dangerouslySetInnerHTML)..matters-faq-block is the selector both the parser interceptor and the
schema extractor look for.BlogContent.tsx intercepts any
div.matters-faq-block and replaces it with a React <FAQAccordion> component that:
If JS is disabled or the parser doesn't run, the native <details> markup remains, so
the content is never inaccessible.
The blog detail page extracts every .matters-faq-block in the post HTML, pulls each
data-q + plain-text answer, and emits a FAQPage JSON-LD block alongside the existing
Rank Math JSON-LD.
This is gated by the ACF flag disable_auto_faq_schema on the post — set it to true if
the FAQ block on a given post is purely visual and shouldn't appear in search.
Three optional ACF fields on every blog post let editors override the auto-generated schema:
| Field | Effect |
|---|---|
disable_auto_faq_schema (toggle) | Suppress the auto-generated FAQPage JSON-LD entirely |
faq_schema_override (textarea, JSON) | Replace the auto-generated FAQ schema with this raw JSON-LD |
custom_jsonld (textarea, JSON or array) | Inject additional arbitrary JSON-LD blocks (always appended) |
See ACF Usage for the field-group setup. The injection point is the
blog detail page (app/[...slug]/page.tsx for the routed flow,
components/templates/BlogPostTemplate.tsx for the render).
<details> so it works without JS. The Spacer is just a div with CSS. Any new
interactive block must degrade gracefully.</div>, it can break the entire
block. Use the existing HTML-escape helpers in generator-logic.ts.