npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@qkix/better-blocks-astro-renderer

v0.15.1

Published

Astro renderer for Strapi v5 Blocks content with full Better Blocks plugin support - colors, tables, to-do lists, media embeds, alignment, and more. Native Astro components, zero client-side JavaScript.

Downloads

1,275

Readme


Table of Contents

  1. Why?
  2. Compatibility
  3. Installation
  4. Usage
  5. Supported Blocks
  6. Supported Modifiers
  7. Custom Renderers
  8. Registered Block Types
  9. TypeScript
  10. Contributing
  11. Support this project
  12. License

Why?

The official Strapi blocks renderers are built for React. If your site is built with Astro, you can render Strapi blocks through the @astrojs/react integration - but that pulls React into your build for what is purely presentational content.

This package is a native Astro renderer. It renders Strapi v5 Blocks content - including every feature the Better Blocks plugin adds (color marks, text alignment, to-do lists, tables, media embeds, and more) - using plain .astro components. The output is static HTML with zero client-side JavaScript, and math is rendered to a string on the server (see Math (KaTeX)).

It is a drop-in renderer that handles all Better Blocks features out of the box - no configuration needed.

Compatibility

| Strapi Version | Renderer Version | Astro Version | | -------------- | ---------------- | ------------- | | v5.x | v0.x | ≥ 4 |

Installation

# Using yarn
yarn add @qkix/better-blocks-astro-renderer

# Using npm
npm install @qkix/better-blocks-astro-renderer

Peer dependencies: astro >= 4

Usage

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';

const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} />

That's it. All Better Blocks features - colors, tables, to-do lists, media embeds, alignment, and more - work automatically, and the component renders to static HTML (no hydration, no client directive).

A typical page that fetches from Strapi:

---
import { BlocksRenderer, type BlocksContent } from '@qkix/better-blocks-astro-renderer';
// Import the KaTeX stylesheet once (e.g. in a shared layout) so math displays correctly.
import 'katex/dist/katex.min.css';

const res = await fetch('https://your-strapi.example.com/api/articles?status=published');
const { data } = await res.json();
---

{
  data.map((article: { content: BlocksContent }) => (
    <article>
      <BlocksRenderer content={article.content} />
    </article>
  ))
}

Math (KaTeX)

Math nodes are rendered with KaTeX - inline math becomes a <span class="katex-inline"> and block math a <div class="katex-block">. Rendering happens via katex.renderToString on the server, so it works during SSR and static builds with no client-side hydration step.

KaTeX needs its stylesheet to display correctly. Import it once in your app (for example in a shared layout):

---
import 'katex/dist/katex.min.css';
---

katex ships as a dependency of this package, so the stylesheet resolves without a separate install. If KaTeX fails to parse a formula, the renderer falls back to the raw LaTeX source instead of crashing.

Diagrams (Mermaid)

Mermaid diagram blocks ({ type: 'diagram', format: 'mermaid' }) are pre-rendered to inline SVG on the server using beautiful-mermaid - a pure-Node renderer that needs no headless browser (no Puppeteer, no Chromium download). Like math, rendering happens during SSR and static builds with zero client-side JavaScript and no hydration step.

Supported diagram types - flowchart, sequence, state, class, ER, and xychart - render to a <div class="mermaid-diagram"> wrapping the generated SVG. Diagram types beautiful-mermaid does not implement yet (gantt, pie, mindmap, gitGraph, …) and any source that fails to parse fall back gracefully to the raw definition in a <pre class="mermaid-source">, so content is never lost.

beautiful-mermaid ships as a dependency of this package, so no extra install or stylesheet is required.

Diagram colors

Diagrams render in color by default, with a palette that mirrors mermaid.js's familiar look (lavender node fills, purple borders, dark edges). Pass diagramTheme to pick a built-in palette (github-light, github-dark, dracula, nord, tokyo-night, catppuccin-mocha, solarized-light, …) or a custom color object ({ bg, fg, line, accent, muted, surface, border }):

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
const { blocks } = Astro.props;
---

<!-- built-in theme -->
<BlocksRenderer content={blocks} diagramTheme="github-dark" />

<!-- or a custom palette -->
<BlocksRenderer content={blocks} diagramTheme={{ bg: '#fff', fg: '#1f2328', accent: '#8250df' }} />

beautiful-mermaid derives a clean, single-accent palette from these colors - it is intentionally minimal, not a 1:1 clone of mermaid.js's multi-color default theme.

Rendering with mermaid.js instead (clientMermaid)

beautiful-mermaid is a reimplementation of mermaid's layout rather than mermaid itself, and it disagrees with the real thing on some diagrams: flowcharts containing a cycle come out in the wrong order, and sequence diagrams omit the closing actor row mermaid draws at the foot of the lifelines. Diagram types it does not implement (gantt, pie, mindmap, gitGraph, …) never render at all.

Set clientMermaid to render diagrams with mermaid.js in the browser instead:

<BlocksRenderer content={blocks} clientMermaid />

The server then emits the raw definition in a <pre class="mermaid-source" data-bb-mermaid> and a small module script swaps in a <div class="mermaid-diagram"> after load. mermaid is imported dynamically, so it is only fetched on pages that actually contain a diagram, and the <pre> stays put if it fails to load or the source fails to parse.

This is off by default on purpose: the whole point of the server-rendered SVG is that a page stays zero-JS, and the trade is real - with clientMermaid a diagram is raw text until JavaScript runs, which is what crawlers and no-JS readers see. Turn it on when diagram fidelity matters more than that. diagramTheme works in both modes; in client mode it travels with the source as a %%{init}%% directive, and a diagram whose source already begins with one keeps the author's.

mermaid ships as a dependency of this package, so there is nothing extra to install either way.

To take full control of the markup, override the diagram block via blocks.diagram - it wins over clientMermaid.

Callouts (Admonitions)

Block-level callout nodes render GitHub-style alerts in five variants — note, tip, important, warning, and caution. Each renders as an <aside role="note"> with a colored left border, a title row (icon + label), and the nested block children (paragraphs, lists, links, etc.). If a title is set on the node it is used; otherwise the localized variant label is shown.

Colors come from a small scoped <style> that ships with the component (still zero client-side JavaScript), and the default palette adapts to dark mode automatically via @media (prefers-color-scheme: dark). The accent for each variant is driven by a --bb-callout-accent custom property on the .bb-callout-{variant} element, so you can retheme colors from your own CSS without replacing the markup:

/* Recolor a single variant, or override per color scheme */
.bb-callout-note {
  --bb-callout-accent: #2563eb;
}

To replace the markup entirely, override the callout block. It receives variant and title; the nested children arrive via <slot />:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyCallout from '../components/MyCallout.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ callout: MyCallout }} />

Details / Summary (Collapsible)

Block-level details nodes render a native, keyboard-accessible <details> / <summary> disclosure with zero client-side JavaScript — the open/closed state is handled entirely by the browser. The summary field is the plain-text label, the optional defaultOpen boolean maps to the HTML open attribute (honored on initial render so screen readers get the correct state), and children are block-level content (paragraphs, lists, tables, images, and nested details) rendered after the summary. The default markup carries stable bb-details and bb-details-summary classes.

A small scoped <style> ships with the component (still zero client-side JavaScript): a GitHub-inspired card with a rotating disclosure marker. Retheme it from your own CSS via the --bb-details-* custom properties (--bb-details-border, --bb-details-bg, --bb-details-summary-bg, --bb-details-marker) without replacing the markup:

.bb-details {
  --bb-details-border: #c8c8c8;
  --bb-details-summary-bg: #eee;
}

To replace the markup entirely, override the details block. It receives summary and defaultOpen; the nested children arrive via <slot />:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyDetails from '../components/MyDetails.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ details: MyDetails }} />

Buttons (CTA & File Download)

Block-level button nodes render a WordPress-style call-to-action as a single, accessible <a> (or a styled <span> when no target is set). Two modes are driven by buttonType:

  • Link (buttonType: 'link') → <a href={link.url} target rel aria-label> for a normal CTA. rel="noopener noreferrer" is honored when present (the editor adds it automatically for target="_blank").
  • File (buttonType: 'file') → a download link (<a href={file.url} download={file.name}>) for a Media Library asset, optionally prefixed with a file-type icon (showFileIcon) and suffixed with a human-readable size (showFileSize, e.g. (5 MB)).

Download vs. preview

By default a file button force-downloads the asset. The native download attribute only works same-origin, so for cross-origin assets (Strapi/CDN) browsers ignore it and open renderable files (PDF, video, images) inline. To fix that, download-mode buttons are tagged data-bb-download and a tiny scoped <script> (the renderer's only client-side JavaScript) fetches the asset as a blob and saves it from a same-origin object URL. This is progressive enhancement: without JS the anchor still works via its href + download attributes, and a CORS-blocked fetch falls back to native navigation.

Set filePreview: true to instead open the file in a new tab (target="_blank" rel="noopener noreferrer", no download) so users can preview it before saving - this path is fully zero-JS.

The optional style object is applied as inline CSS (backgroundColor, textColor, borderRadius, fontSize, fontWeight, padding, border), and alignment (left / center / right) wraps the button in a text-aligned .bb-button-wrapper (none renders it inline with no wrapper). A cssClass is appended to the default bb-button class for theming.

Because inline styles can't express :hover, the hoverBackgroundColor / hoverTextColor are exposed as --bb-button-hover-bg / --bb-button-hover-color custom properties, and a scoped <style> wires up the hover transition and a visible keyboard focus ring by default - no extra CSS required.

To replace the markup entirely, override the button block. It receives label, buttonType, alignment, link, file, showFileSize, showFileIcon, filePreview, style, and cssClass as props:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyButton from '../components/MyButton.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ button: MyButton }} />

Social Embeds

Block-level social-embed nodes render a post from Twitter/X, Instagram, Facebook, TikTok, LinkedIn, or Pinterest. The renderer picks the embed HTML in priority order:

  1. embedCode — a manual override pasted by the author, if present.
  2. oembed.html — the markup the plugin fetched from the platform's oEmbed API at author time (a <blockquote> for Twitter/TikTok/Instagram, an <iframe> for Pinterest/LinkedIn).
  3. Fallback link card — when neither is available, a card enriched with the oEmbed thumbnailUrl, title, and author when present. It's a plain <a> to the original post when a url is known, or a non-interactive <div> for embed-code-only nodes that carry no post URL (never an empty <a href="">).

The embed is wrapped in a <figure class="bb-social-embed bb-social-embed-{platform} social-embed align-{alignment}"> (alignment defaults to center) with an aria-label describing it ("{providerName} post by {author}"), and the optional caption renders below it in a <figcaption>. Any <iframe> in the embed markup (e.g. LinkedIn) is given loading="lazy". This markup is byte-for-byte compatible with the React renderer, so shared CSS themes both.

Widget scripts (lazy & deduped). Twitter, Instagram, TikTok, Pinterest, and Facebook enhance their <blockquote>/<div> markup into a rich embed via a platform script (LinkedIn renders a self-contained <iframe> and needs none). Because Astro ships zero JavaScript by default, this block adds one tiny loader - its only client-side script - that watches embeds with an IntersectionObserver and injects a platform's script once per page (deduped by URL, guarded against double-injection) only when one of its embeds nears the viewport, so no third-party JavaScript loads eagerly. Any widget <script> shipped inline in the embed markup (TikTok's oEmbed always ships one; hand-pasted Instagram/Facebook codes may too) is stripped before render so the loader is the single script injector - no duplicate widget script. After the script loads it re-runs the platform's processor (twttr.widgets.load(), instgrm.Embeds.process(), FB.XFBML.parse(), tiktokEmbed.lib.render(), …), and it re-scans on astro:page-load so view-transition navigations upgrade too.

Trust boundary. The embed HTML is emitted verbatim via Astro's set:html and is not sanitized — social embeds rely on <iframe>/<blockquote> that a sanitizer would strip (widget <script> tags are the one exception: they're stripped, and the lazy loader injects them instead). This markup originates from the platform's oEmbed API or a manual override entered by a trusted editor, so treat your CMS content as trusted. If you accept social-embed blocks from untrusted authors, sanitize on the server before storing.

To fully control the markup, override the social-embed block. It receives platform, url, embedCode, oembed, alignment, and caption:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MySocialEmbed from '../components/MySocialEmbed.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ 'social-embed': MySocialEmbed }} />

Audio

Block-level audio nodes embed an audio file - from the Strapi Media Library or a raw URL - using a native HTML5 <audio> player, with zero client-side JavaScript (the native player is enough). The file.url is rendered as-is: for Media-Library assets the editor already stores the backend-prefixed URL (same convention as the image/button blocks), so the renderer never re-prefixes it.

The block renders a <figure class="bb-audio align-{alignment}"> (alignment defaults to center) containing an <audio class="bb-audio-player"> element. The player flags map 1:1 to the element: controls (default true), autoplay, loop, and preload (none / metadata / auto). An optional title renders above the player and an optional caption below it, each in a <figcaption>. For accessibility the player gets an aria-label (the title, or "Audio player" when absent) and an aria-describedby pointing at the caption, and inside the <audio> element a fallback line plus a download link cover unsupported formats/browsers. The alignment cross-axis placement (left/center/rightflex-start/center/flex-end; none = full-width, flows inline) ships as inline styles, and the markup - bb-audio, bb-audio-player, bb-audio-title, bb-audio-caption class hooks included - is byte-for-byte compatible with the React renderer, so a shared CSS theme covers both.

The baseline appearance (flex column, centered, max-width: 32rem player) ships as inline styles - retheme it from your own CSS via the stable bb-audio* classes.

To fully control the markup, override the audio block. It receives file, title, caption, player, and alignment:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyAudio from '../components/MyAudio.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ audio: MyAudio }} />

Embeds

Block-level embed nodes render a generic third-party embed - YouTube, Vimeo, Loom, Wistia, Dailymotion, api.video, or any generic provider - as a plain <iframe> with zero client-side JavaScript. Only one field is rendered: embedHtml, the sanitized iframe markup the plugin built at author time. It's rebuilt from an attribute allowlist over an https-only src, with scripts, event handlers, inline styles and unknown attributes stripped, so it's emitted verbatim via Astro's set:html. The url / iframe fields exist only to round-trip the editor and are ignored when rendering.

The block renders a <figure class="bb-embed align-{alignment}"> (alignment defaults to center) containing a <div class="bb-embed-frame"> whose CSS aspect-ratio sizes the iframe responsively. Named ratios convert "16:9"16 / 9; when aspectRatio is "custom" the customAspectRatio value (e.g. "3 / 2") is used verbatim; both default to 16 / 9. Alignment positions the box (left/center/rightflex-start/center/flex-end; none = full-width). An optional title renders above and an optional caption below, each in a <figcaption>.

Aligned embeds are capped at a retheme-able --bb-embed-max-width (default 40rem); alignment: none removes the cap and flows full-width.

Trust boundary. embedHtml is emitted verbatim via set:html and is not re-sanitized here - it relies on the <iframe> that a sanitizer would strip. The plugin sanitizes it at author time (allowlisted attributes over an https-only src); treat your CMS content as trusted, and sanitize on the server before storing if you accept embed blocks from untrusted authors.

To fully control the markup, override the embed block. It receives embedHtml, embedSrc, provider, thumbnail, aspectRatio, customAspectRatio, alignment, caption, and title:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyEmbed from '../components/MyEmbed.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ embed: MyEmbed }} />

Video

Block-level video nodes render a provider-aware video (local, mux, api-video, cloudinary, or custom) as a native HTML5 <video> player, with zero client-side JavaScript. The playback source is picked in order: an explicit url, then the Media-Library file.url, then - for provider: "mux" - a public-playback stream derived from playbackId (https://stream.mux.com/{playbackId}.m3u8, with a matching https://image.mux.com/{playbackId}/thumbnail.jpg poster when none is set). The player flags map 1:1 to the element - controls (default true), autoplay, loop, and muted (forced on whenever autoplay is set, since browsers block unmuted autoplay). A transcript URL renders as a <track kind="captions">, and the caption is associated via aria-describedby.

The block renders a <figure class="bb-video align-{alignment}"> with the same alignment / aspect-ratio behavior (and --bb-video-max-width, default 40rem) as embeds. A <video class="bb-video-player"> carries a poster, playsinline, and an inner fallback line with an open-link for browsers that can't play the source. When there is no playable source (e.g. a Mux node without url/playbackId) the poster renders as an <img class="bb-video-poster"> instead.

HLS/DASH (Mux) & the no-JS stance. Streaming sources (url ending .m3u8 / .mpd) only play natively in Safari; other browsers show the poster and the fallback link. Because a cross-browser player (<mux-player>, hls.js, …) requires a CDN script that conflicts with this package's zero-JavaScript output, the default renderer stays script-free and marks streaming figures with data-hls. To play HLS everywhere, override the video block with your own player - it receives playbackId, url, poster, and everything else below.

To fully control the markup, override the video block. It receives provider, url, assetId, playbackId, file, poster, title, caption, transcript, player, alignment, aspectRatio, and customAspectRatio:

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyVideo from '../components/MyVideo.astro';
const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} blocks={{ video: MyVideo }} />

Both blocks need CSP frame-src / img-src / media-src hosts for the providers you use (YouTube, Vimeo, Mux, Cloudinary, …) - see the plugin README's "Embed / Video JSON shapes" for the exact directive list.

Tables, Blockquotes & Code Blocks (GitHub-style)

Tables, blockquotes, and code blocks ship with GitHub-flavored defaults out of the box - no CSS to import. Each default carries stable bb-* classes and a scoped <style> (still zero client-side JavaScript), rethemable from your own CSS via custom properties without replacing the markup. As with every block, supply a blocks={{ … }} override to take full control of the markup.

Tables render as <table class="bb-table"> with bordered cells, a shaded header, and zebra-striped body rows. Leading header rows (rows whose cells are all header cells) are grouped into a <thead> and their cells render as <th scope="col"> for screen-reader header announcement; the remaining rows go into <tbody> as <td>. Cell children are the full set of inline nodes and marks (bold, links, inline math, colors, …) and render through the same inline renderer as paragraphs. Each cell honors three optional properties, all following the "absent means default" convention so existing content renders unchanged: align (left / center / right) maps to text-align (omitted ⇒ left), and colSpan / rowSpan map to the matching HTML attributes (omitted ⇒ 1). The table scrolls horizontally on overflow. Retheme via --bb-table-border, --bb-table-header-bg, --bb-table-row-bg, and --bb-table-stripe-bg.

Blockquotes render as <blockquote class="bb-quote"> with a muted left border and dimmed, indented text. Retheme via --bb-quote-border and --bb-quote-fg.

Code blocks are syntax-highlighted with Shiki via Astro's built-in <Code /> component - highlighting happens at build/SSR, so the output is styled static HTML with zero client-side JavaScript. The block's language (attached in the editor) selects the grammar; unknown or missing languages fall back to themed-but-unhighlighted plaintext, so a stray value never breaks the build. The highlighted <pre> is wrapped in a <div class="bb-code">.

Two props on <BlocksRenderer> tune the defaults:

  • codeTheme - any bundled Shiki theme name (github-dark default, or github-light, dracula, nord, …).
  • codeCopyButton - set true to add a copy button to each code block. It's off by default to keep the output zero-JavaScript; enabling it bundles a small client script.
<BlocksRenderer content={blocks} codeTheme="github-light" codeCopyButton />

The copy button is themed via --bb-code-copy-fg, --bb-code-copy-bg, --bb-code-copy-border, and --bb-code-copy-hover-bg.

Supported Blocks

| Block | Default element | Source | | ------------------------------- | ----------------------- | --------------------------- | | paragraph | <p> | Strapi core | | heading (1–6) | <h1><h6> | Strapi core | | list (ordered/unordered/todo) | <ol> / <ul> | Strapi core + Better Blocks | | list-item | <li> | Strapi core | | link | <a> | Strapi core | | quote | <blockquote> | Strapi core | | code | <pre> (Shiki) | Strapi core | | image | <figure><img> | Strapi core | | horizontal-line | <hr> | Better Blocks | | table | <table> (thead/tbody) | Better Blocks | | media-embed | <iframe> (16:9) | Better Blocks | | math (inline/block) | <span> / <div> | Better Blocks | | diagram (mermaid) | <div> (inline SVG) | Better Blocks | | callout (admonition) | <aside> | Better Blocks | | details (collapsible) | <details> | Better Blocks | | button (CTA / file download) | <a> / <span> | Better Blocks | | social-embed | <figure> | Better Blocks | | audio (HTML5 player) | <figure><audio> | Better Blocks | | embed (generic iframe) | <figure><iframe> | Better Blocks | | video (provider-aware) | <figure><video> | Better Blocks |

Block properties

| Property | Applies to | Description | | ------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------ | | textAlign | paragraph, heading, quote | Text alignment (left, center, right, justify) | | lineHeight | paragraph, heading, quote | CSS line-height value (e.g. 1.5, 2.0) | | indent | paragraph, heading, quote | Block indentation level (marginLeft: N * 2rem) | | indentLevel | list | Cycling list-style-type per nesting depth | | format | list | ordered, unordered, or todo | | checked | list-item (in todo lists) | Checkbox state (true/false) | | target | link | _blank for new-tab links | | rel | link | noopener noreferrer for new-tab links | | language | code | Shiki grammar for syntax highlighting (e.g. typescript, python); falls back to plaintext | | caption | image | Text displayed below the image | | imageAlign | image | Image alignment (left, center, right) | | url | media-embed | Embed URL (YouTube/Vimeo iframe src) | | originalUrl | media-embed | Original user-provided URL | | format | math | inline (<span>) or block (<div>) | | value | math | LaTeX source rendered with KaTeX | | format | diagram | mermaid (the only supported diagram format) | | value | diagram | Mermaid source, pre-rendered to SVG on the server | | summary | details | Plain-text label for the <summary> | | defaultOpen | details | Open on initial render (HTML open attribute) | | buttonType | button | link (CTA) or file (Media Library download) | | label | button | Visible button text | | alignment | button | left, center, right, or none (inline) | | link | button | { url, target?, rel?, ariaLabel? } (link mode) | | file | button | { url, name, size?, ext?, mime? } (file mode) | | showFileIcon | button | Prefix a file-type icon (file mode) | | showFileSize | button | Suffix a human-readable size, e.g. (5 MB) | | filePreview | button | true opens the file in a new tab instead of downloading | | style | button | Inline CSS + hover* colors via custom properties | | cssClass | button | Extra class appended to bb-button | | platform | social-embed | twitter, instagram, facebook, tiktok, linkedin, pinterest | | url | social-embed | Original post URL (used by the fallback link card) | | embedCode | social-embed | Optional manual HTML override (highest priority) | | oembed | social-embed | Fetched oEmbed payload { html, title, author, authorUrl, thumbnailUrl, providerName, width, height } | | alignment | social-embed | left, center (default), or right | | caption | social-embed | Optional caption rendered in a <figcaption> | | file | audio | { url, id?, name?, ext?, hash?, mime?, size?, provider?, duration? } (url is rendered as-is) | | title | audio | Optional heading rendered above the player | | caption | audio | Optional caption rendered below the player in a <figcaption> | | player | audio | { controls (default true), autoplay, loop, preload } (mapped 1:1 to <audio>) | | alignment | audio | left, center (default), right, or none (full-width, inline) | | embedHtml | embed | Sanitized <iframe> markup - the only field rendered (via set:html) | | provider | embed | youtube, vimeo, loom, wistia, dailymotion, api-video, or generic | | aspectRatio | embed, video | 16:9 (default), 21:9, 4:3, 1:1, or custom (→ CSS aspect-ratio) | | customAspectRatio | embed, video | Verbatim aspect-ratio value (e.g. 3 / 2) used when aspectRatio is custom | | alignment | embed, video | left, center (default), right, or none (full-width) | | caption | embed, video | Optional caption rendered below in a <figcaption> | | title | embed, video | Optional heading rendered above the media | | provider | video | local, mux, api-video, cloudinary, or custom | | url | video | Direct/stream URL (preferred source; for Mux, derivable from playbackId) | | playbackId | video | Mux public playback id - streams and posters are derived from it | | file | video | Media-Library asset { url, id?, name?, ext?, mime?, size?, duration?, provider? } | | poster | video | Poster image URL (derived from a Mux playbackId when omitted) | | transcript | video | WebVTT captions URL rendered as <track kind="captions"> | | player | video | { controls (default true), autoplay, loop, muted } (muted forced on with autoplay) |

Supported Modifiers

| Modifier | Default element | Source | | ----------------- | --------------------------------- | ------------- | | bold | <strong> | Strapi core | | italic | <em> | Strapi core | | underline | <span> | Strapi core | | strikethrough | <del> | Strapi core | | code | <code> | Strapi core | | uppercase | <span style="text-transform"> | Better Blocks | | superscript | <sup> | Better Blocks | | subscript | <sub> | Better Blocks | | color | <span style="color"> | Better Blocks | | backgroundColor | <span style="background-color"> | Better Blocks | | fontFamily | <span style="font-family"> | Better Blocks | | fontSize | <span style="font-size"> | Better Blocks |

Custom Renderers

Override any block type or text modifier with your own Astro component. Pass a map of type → component via the blocks and modifiers props. Each custom component receives its props through Astro.props and its inner content through the default <slot />.

Custom block renderers

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import MyParagraph from '../components/MyParagraph.astro';
import MyImage from '../components/MyImage.astro';
import MyTable from '../components/MyTable.astro';

const { blocks } = Astro.props;
---

<BlocksRenderer
  content={blocks}
  blocks={{
    paragraph: MyParagraph,
    image: MyImage,
    table: MyTable,
  }}
/>
---
// src/components/MyImage.astro
const { image, caption, imageAlign } = Astro.props;
---

<figure style={{ textAlign: imageAlign }}>
  <img src={image.url} alt={image.alternativeText || ''} loading="lazy" />
  {caption && <figcaption>{caption}</figcaption>}
</figure>

The props each custom block component receives:

| Block | Props (plus <slot /> for children where applicable) | | ---------------------------------- | ------------------------------------------------------------- | | paragraph | { style?} | | heading | { level: 1–6; style? } | | list | { format: 'ordered' \| 'unordered' \| 'todo'; indentLevel } | | list-item | { checked? } | | link | { url; target?; rel? } | | quote | { style? } | | code | { plainText; language? } (also via <slot />) | | image | { image; caption?; imageAlign? } (no slot) | | horizontal-line | none | | table / table-row | children via <slot /> | | table-cell / table-header-cell | { align?; colSpan?; rowSpan? }, children via <slot /> | | media-embed | { url; originalUrl? } (no slot) | | math | { formula; inline } (no slot) - bring your own math engine | | diagram | { code; format } (no slot) - bring your own diagram engine |

Custom modifier renderers

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import Highlight from '../components/Highlight.astro';

const { blocks } = Astro.props;
---

<BlocksRenderer content={blocks} modifiers={{ backgroundColor: Highlight }} />
---
// src/components/Highlight.astro
const { backgroundColor } = Astro.props;
---

<mark style={{ backgroundColor }}><slot /></mark>

The color/size/font modifiers receive a value prop (color, backgroundColor, fontFamily, fontSize); the rest receive only their <slot />.

Registered Block Types

blocks overrides how a known block is drawn. blockPlugins adds a block type this renderer has never heard of - one owned by another package, such as a chart.

---
import { BlocksRenderer } from '@qkix/better-blocks-astro-renderer';
import Chart from '../components/Chart.astro';

const chart = {
  type: 'chart',
  // 'void' (attributes only, the default), 'inline' (text), or 'blocks' (nested blocks).
  content: 'void',
  component: Chart,
};
---

<BlocksRenderer content={content} blockPlugins={[chart]} />

The component receives the whole node as node - this renderer does not know what attributes the block has, which is the point - and, when the content model is inline or blocks, its rendered children via the default <slot />:

---
const { node } = Astro.props;
---

<figure class="chart" data-title={node.spec.title}>{/* … */}</figure>

A registered block works at any depth, including inside a callout or a details.

An AstroBlockPlugin is a core BlockDefinition plus component, so the same object that teaches validateDocument and migrateDocument about the block also teaches this renderer to draw it. See @qkix/better-blocks-core.

Passing plugins explicitly, rather than registering them into a global, is deliberate: this renderer runs on servers handling concurrent requests, where mutable module state leaks one page's registrations into another's.

A block type nobody registered renders nothing, exactly as before.

TypeScript

All types are exported:

import type {
  BlocksContent,
  BlocksRendererProps,
  BlockNode,
  TextNode,
  LinkNode,
  ListNode,
  ListItemNode,
  ParagraphNode,
  HeadingNode,
  QuoteNode,
  CodeNode,
  ImageNode,
  HorizontalLineNode,
  TableNode,
  TableRowNode,
  TableCellNode,
  TableHeaderCellNode,
  MediaEmbedNode,
  MathNode,
  DiagramNode,
  SocialEmbedNode,
  SocialPlatform,
  SocialEmbedAlignment,
  SocialEmbedOembed,
  AudioNode,
  AudioAlignment,
  AudioFile,
  AudioPlayer,
  AudioPreload,
  TextAlign,
  CustomBlocksConfig,
  CustomModifiersConfig,
} from '@qkix/better-blocks-astro-renderer';

Contributing

Contributions are welcome! This package lives in the strapi-plugin-better-blocks monorepo, next to the Strapi plugin and the React and Vue renderers. The easiest way to get started is with Docker:

git clone https://github.com/qkix/strapi-plugins.git
cd strapi-plugin-better-blocks

docker compose up --build

That brings up a Strapi v5 instance running the Better Blocks plugin, seeded with the showcase articles, plus every renderer displaying the same content.

  • Strapi admin: http://localhost:1337/admin (login: [email protected] / admin12#)
  • Astro example: http://localhost:4321
  • React example: http://localhost:5173
  • Nuxt example: http://localhost:3000

Development workflow

  1. Edit the .astro components in packages/better-blocks-astro-renderer/src/
  2. Rebuild with docker compose up --build - the renderer is baked into the image
  3. Editing examples/astro-app/src/ hot-reloads on its own, with no rebuild

Without Docker

pnpm install
pnpm build   # the core and the plugin; this package ships .astro source, so it
             # has no bundling step of its own

pnpm --filter @qkix/example-strapi-app develop
pnpm --filter @qkix/example-astro-app dev   # in another terminal

Running tests

pnpm test        # every package, from the repo root
pnpm typecheck
pnpm lint

pnpm --filter @qkix/better-blocks-astro-renderer test   # just this one

Community & Support

Related

Support this project

This package is built and maintained in my free time, and it's free for everyone. If it has saved you time on a project, you can help keep it caffeinated and actively developed:

Every coffee goes toward fixing bugs, reviewing PRs, writing docs, and shipping the features you ask for. Thank you! ☕

License

MIT License © qkix