@qkix/better-blocks-react-renderer 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +970 -0
- package/dist/index.d.mts +189 -0
- package/dist/index.d.ts +189 -0
- package/dist/index.js +1288 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1269 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +105 -0
package/README.md
ADDED
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
<h1 align="center">Better Blocks React Renderer</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">React renderer for Strapi v5 Blocks content — supports all standard blocks plus Better Blocks features: color, highlight, text alignment, nested lists, to-do lists, tables, media embeds, image captions, and more.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@qkix/better-blocks-react-renderer">
|
|
7
|
+
<img alt="npm version" src="https://img.shields.io/npm/v/@qkix/better-blocks-react-renderer.svg" />
|
|
8
|
+
</a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@qkix/better-blocks-react-renderer">
|
|
10
|
+
<img alt="npm downloads" src="https://img.shields.io/npm/dm/@qkix/better-blocks-react-renderer.svg" />
|
|
11
|
+
</a>
|
|
12
|
+
<a href="https://github.com/qkix/strapi-plugin-better-blocks/blob/main/LICENSE">
|
|
13
|
+
<img alt="license" src="https://img.shields.io/npm/l/@qkix/better-blocks-react-renderer.svg" />
|
|
14
|
+
</a>
|
|
15
|
+
<a href="https://buymeacoffee.com/k11k">
|
|
16
|
+
<img alt="Buy Me a Coffee" src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-FFDD00?logo=buymeacoffee&logoColor=black" />
|
|
17
|
+
</a>
|
|
18
|
+
</p>
|
|
19
|
+
|
|
20
|
+
<p align="center">
|
|
21
|
+
<img src="./docs/playground-showcase.png" alt="Strapi editor (left) and rendered output (right)" width="800" />
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Table of Contents
|
|
27
|
+
|
|
28
|
+
1. [Why?](#why)
|
|
29
|
+
2. [Compatibility](#compatibility)
|
|
30
|
+
3. [Installation](#installation)
|
|
31
|
+
4. [Usage](#usage)
|
|
32
|
+
5. [GitHub-style defaults](#tables-blockquotes--code-blocks-github-style)
|
|
33
|
+
6. [Supported Blocks](#supported-blocks)
|
|
34
|
+
7. [Supported Modifiers](#supported-modifiers)
|
|
35
|
+
8. [Custom Renderers](#custom-renderers)
|
|
36
|
+
9. [TypeScript](#typescript)
|
|
37
|
+
10. [Contributing](#contributing)
|
|
38
|
+
11. [Support this project](#support-this-project)
|
|
39
|
+
12. [License](#license)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Why?
|
|
44
|
+
|
|
45
|
+
The official [`@strapi/blocks-react-renderer`](https://github.com/strapi/blocks-react-renderer) doesn't support the features that the [Better Blocks](https://github.com/qkix/strapi-plugin-better-blocks) plugin adds to the Strapi editor — color marks, text alignment, to-do lists, tables, media embeds, and more.
|
|
46
|
+
|
|
47
|
+
This package is a **drop-in replacement** that renders all Better Blocks features out of the box — no configuration needed.
|
|
48
|
+
|
|
49
|
+
## Compatibility
|
|
50
|
+
|
|
51
|
+
| Strapi Version | Renderer Version | React Version |
|
|
52
|
+
| -------------- | ---------------- | ------------- |
|
|
53
|
+
| v5.x | v0.x | ≥ 17 |
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# Using yarn
|
|
59
|
+
yarn add @qkix/better-blocks-react-renderer
|
|
60
|
+
|
|
61
|
+
# Using npm
|
|
62
|
+
npm install @qkix/better-blocks-react-renderer
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Peer dependencies:** `react >= 17`
|
|
66
|
+
|
|
67
|
+
## Usage
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
import { BlocksRenderer } from '@qkix/better-blocks-react-renderer';
|
|
71
|
+
|
|
72
|
+
// Basic — renders all blocks including color/highlight
|
|
73
|
+
<BlocksRenderer content={blocks} />;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That's it. All Better Blocks features — colors, tables, to-do lists, media embeds, alignment, and more — work automatically.
|
|
77
|
+
|
|
78
|
+
### Math (KaTeX)
|
|
79
|
+
|
|
80
|
+
Math nodes are rendered with [KaTeX](https://katex.org/) — inline math becomes a `<span class="katex-inline">` and block math a `<div class="katex-block">`. Rendering happens via `katex.renderToString`, so it works in SSR and during static export with no client-side hydration step.
|
|
81
|
+
|
|
82
|
+
KaTeX needs its stylesheet to display correctly. Import it **once** in your app entry point:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import 'katex/dist/katex.min.css';
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`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.
|
|
89
|
+
|
|
90
|
+
### Diagrams (Mermaid)
|
|
91
|
+
|
|
92
|
+
Block-level `diagram` nodes (`format: 'mermaid'`) are rendered to inline SVG with [Mermaid](https://mermaid.js.org/) — flowcharts, sequence, class, state, ER, pie charts, and more.
|
|
93
|
+
|
|
94
|
+
Unlike KaTeX, Mermaid needs a real browser DOM to measure text, so it cannot render synchronously on the server. The renderer keeps SSR/static export safe by emitting the raw Mermaid source inside a `<pre class="mermaid-source">` on the server and during the first client render (so hydration matches), then swapping in the rendered `<div class="mermaid-diagram">` SVG after mount. If Mermaid fails to parse the source, the raw text stays in place as a graceful fallback.
|
|
95
|
+
|
|
96
|
+
`mermaid` ships as a dependency and is **lazy-loaded** the first time a diagram renders, so it stays out of your server bundle and only downloads on pages that actually use a diagram — no setup or stylesheet import required.
|
|
97
|
+
|
|
98
|
+
To render diagrams yourself (e.g. a different engine or custom theming), override the `diagram` block:
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<BlocksRenderer
|
|
102
|
+
content={blocks}
|
|
103
|
+
blocks={{
|
|
104
|
+
diagram: ({ code, format }) => <MyDiagram code={code} format={format} />,
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Callouts (Admonitions)
|
|
110
|
+
|
|
111
|
+
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 are applied inline, so there is no stylesheet to import.
|
|
112
|
+
|
|
113
|
+
To match your design system, override the `callout` block. It receives `variant`, `title`, and the already-rendered `children`:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<BlocksRenderer
|
|
117
|
+
content={blocks}
|
|
118
|
+
blocks={{
|
|
119
|
+
callout: ({ variant, title, children }) => (
|
|
120
|
+
<div className={`alert alert-${variant}`}>
|
|
121
|
+
{title && <h4>{title}</h4>}
|
|
122
|
+
{children}
|
|
123
|
+
</div>
|
|
124
|
+
),
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**Styling & dark mode.** The default markup carries stable classes — `bb-callout`, `bb-callout-{variant}`, `bb-callout-title`, and `bb-callout-icon` — which you can target for spacing, typography, radius, etc. The accent **colors are applied inline** (so the default works with zero setup), which means you can't recolor them with a plain CSS class. To re-theme colors — including a dark-mode palette — override the `callout` block and apply your own colors per `variant`:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
const ACCENT: Record<string, string> = {
|
|
133
|
+
note: 'var(--cl-note, #4493f8)',
|
|
134
|
+
tip: 'var(--cl-tip, #3fb950)',
|
|
135
|
+
important: 'var(--cl-important, #ab7df8)',
|
|
136
|
+
warning: 'var(--cl-warning, #d29922)',
|
|
137
|
+
caution: 'var(--cl-caution, #f85149)',
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
<BlocksRenderer
|
|
141
|
+
content={blocks}
|
|
142
|
+
blocks={{
|
|
143
|
+
callout: ({ variant, title, children }) => (
|
|
144
|
+
<aside
|
|
145
|
+
className={`callout callout-${variant}`}
|
|
146
|
+
style={{ borderLeft: `4px solid ${ACCENT[variant]}` }}
|
|
147
|
+
>
|
|
148
|
+
<p style={{ color: ACCENT[variant], fontWeight: 600 }}>{title ?? variant}</p>
|
|
149
|
+
{children}
|
|
150
|
+
</aside>
|
|
151
|
+
),
|
|
152
|
+
}}
|
|
153
|
+
/>;
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Driving the accent from CSS variables (as above) lets you flip palettes with a `@media (prefers-color-scheme: dark)` or a `.dark` class rule on a parent.
|
|
157
|
+
|
|
158
|
+
### Details / Summary (Collapsible)
|
|
159
|
+
|
|
160
|
+
Block-level `details` nodes render a native, keyboard-accessible `<details>` / `<summary>` disclosure. 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 for styling.
|
|
161
|
+
|
|
162
|
+
To match your design system, override the `details` block. It receives `summary`, `defaultOpen`, and the already-rendered `children`:
|
|
163
|
+
|
|
164
|
+
```tsx
|
|
165
|
+
<BlocksRenderer
|
|
166
|
+
content={blocks}
|
|
167
|
+
blocks={{
|
|
168
|
+
details: ({ summary, defaultOpen, children }) => (
|
|
169
|
+
<details open={defaultOpen} className="custom-details">
|
|
170
|
+
<summary>{summary}</summary>
|
|
171
|
+
{children}
|
|
172
|
+
</details>
|
|
173
|
+
),
|
|
174
|
+
}}
|
|
175
|
+
/>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Buttons (CTA & File Download)
|
|
179
|
+
|
|
180
|
+
Block-level `button` nodes render a WordPress-style call-to-action. The `buttonType` selects the mode:
|
|
181
|
+
|
|
182
|
+
- **`link`** — renders `<a href={link.url} target={link.target} rel={link.rel} aria-label={link.ariaLabel}>{label}</a>`.
|
|
183
|
+
- **`file`** — renders a download link `<a href={file.url} download={file.name} aria-label="Download …">`, optionally prefixed with a file-type icon (`showFileIcon`) and suffixed with a human-readable size (`showFileSize`). Clicking force-downloads the file via a blob fetch (so renderable types like PDF/video/images download instead of opening inline, which the native `download` attribute can't guarantee). 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.
|
|
184
|
+
|
|
185
|
+
**Cross-origin downloads.** Forcing a download only works for **same-origin** assets (e.g. Strapi's local upload provider) or cross-origin hosts that send CORS headers. For a cross-origin asset **without** CORS (some CDN / cloud upload providers), the browser blocks the blob fetch _and_ ignores the `download` attribute, so the renderer falls back to opening the file. This is a browser security limitation, not something a client-side renderer can work around — fix it on the server instead:
|
|
186
|
+
|
|
187
|
+
- Serve the asset with `Content-Disposition: attachment` (most reliable; then even a plain link downloads, no CORS needed).
|
|
188
|
+
- Enable CORS (`Access-Control-Allow-Origin`) on the asset host so the blob fetch can read the file.
|
|
189
|
+
- Proxy uploads through your site's own origin so they're same-origin.
|
|
190
|
+
- Or use a provider flag, e.g. Cloudinary `fl_attachment` or S3 `response-content-disposition`.
|
|
191
|
+
|
|
192
|
+
The `style` object is applied as inline CSS (`backgroundColor`, `color` ← `textColor`, `borderRadius`, `fontSize`, `fontWeight`, `padding`, `border`). The block is wrapped in a `<div className="bb-button-wrapper">` whose `text-align` honors `alignment` (`left` / `center` / `right`); `alignment: "none"` renders the button inline with no wrapper. A `cssClass` is appended to the default `bb-button` class.
|
|
193
|
+
|
|
194
|
+
**Hover colors.** `hoverBackgroundColor` / `hoverTextColor` work out of the box — no setup, no stylesheet import. The renderer ships a small `<style>` (emitted once, only when a default button is present) that wires the hover and `:focus-visible` states to the `--bb-button-hover-bg` / `--bb-button-hover-color` custom properties it sets from those fields. Buttons without hover colors keep their base colors on hover.
|
|
195
|
+
|
|
196
|
+
To customize the hover behavior, target `.bb-button:hover` yourself. Because the base colors are applied inline, your rule needs `!important` to win:
|
|
197
|
+
|
|
198
|
+
```css
|
|
199
|
+
.bb-button:hover {
|
|
200
|
+
background-color: #3732c9 !important;
|
|
201
|
+
color: #fff !important;
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
To fully control the markup, override the `button` block. It receives `label`, `buttonType`, `alignment`, `link`, `file`, `showFileSize`, `showFileIcon`, `filePreview`, `style`, and `cssClass`:
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
<BlocksRenderer
|
|
209
|
+
content={blocks}
|
|
210
|
+
blocks={{
|
|
211
|
+
button: ({ label, link, alignment }) => (
|
|
212
|
+
<div className={`button-wrapper align-${alignment}`}>
|
|
213
|
+
<a href={link?.url} target={link?.target} rel={link?.rel}>
|
|
214
|
+
{label}
|
|
215
|
+
</a>
|
|
216
|
+
</div>
|
|
217
|
+
),
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Social Embeds
|
|
223
|
+
|
|
224
|
+
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:
|
|
225
|
+
|
|
226
|
+
1. **`embedCode`** — a manual override pasted by the author, if present.
|
|
227
|
+
2. **`oembed.html`** — the markup the plugin fetched from the platform's oEmbed API at author time.
|
|
228
|
+
3. **Fallback link card** — when neither is available, a plain `<a>` link to the original post, enriched with the oEmbed `thumbnailUrl`, `title`, and `author` when present, so the block always links somewhere useful. The card's title is the oEmbed `title`, else `Post by {author}`, else `View on {providerName}` — and the provider subtitle is omitted in that last case, where it would only repeat the title. If the block has no `url` either (an author can save a manual `embedCode` without one), the same card renders as a `<div>` instead of an empty anchor.
|
|
229
|
+
|
|
230
|
+
The embed is wrapped in a `<figure className="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"`.
|
|
231
|
+
|
|
232
|
+
**Widget scripts.** 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). The renderer loads the right script **once per platform** on mount and then re-runs the platform's processor (`twttr.widgets.load()`, `instgrm.Embeds.process()`, `tiktokEmbed.lib.render()`, `PinUtils.build()`, `FB.XFBML.parse()`) so freshly-mounted embeds get upgraded — including on remount and client-side navigation, when the script is long since loaded. This runs on the client only; on the server (and the first client render) the raw platform markup is emitted so SSR/hydration stay consistent.
|
|
233
|
+
|
|
234
|
+
Any `<script>` tag inside the embed markup is **stripped before injection**. Some providers (TikTok always, hand-pasted Instagram snippets often) ship their widget script inline in the payload, but a script inserted through `dangerouslySetInnerHTML` never executes — it would only sit in the DOM and defeat the loader's deduplication, leaving the embed permanently un-upgraded. The renderer injects the widget script itself instead, tagged with `data-bb-social-script="{platform}"`.
|
|
235
|
+
|
|
236
|
+
> **Trust boundary.** Apart from that `<script>` removal, the embed HTML is injected verbatim via `dangerouslySetInnerHTML` and is **not** sanitized — social embeds rely on `<iframe>`/`<blockquote>` markup that a sanitizer would strip. 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.
|
|
237
|
+
|
|
238
|
+
To fully control the markup, override the `social-embed` block. It receives `platform`, `url`, `embedCode`, `oembed`, `alignment`, and `caption`:
|
|
239
|
+
|
|
240
|
+
```tsx
|
|
241
|
+
<BlocksRenderer
|
|
242
|
+
content={blocks}
|
|
243
|
+
blocks={{
|
|
244
|
+
'social-embed': ({ platform, url, oembed, caption }) => (
|
|
245
|
+
<figure className={`embed embed-${platform}`}>
|
|
246
|
+
{oembed?.html ? (
|
|
247
|
+
<div dangerouslySetInnerHTML={{ __html: oembed.html }} />
|
|
248
|
+
) : (
|
|
249
|
+
<a href={url}>{oembed?.author ?? 'View post'}</a>
|
|
250
|
+
)}
|
|
251
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
252
|
+
</figure>
|
|
253
|
+
),
|
|
254
|
+
}}
|
|
255
|
+
/>
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Audio
|
|
259
|
+
|
|
260
|
+
Block-level `audio` nodes embed audio from the Strapi Media Library (or a raw URL) with a native HTML5 player. The player flags map 1:1 onto the `<audio>` element: `controls` (defaults to `true`), `autoplay`, `loop`, and `preload` (`none` / `metadata` / `auto`). `file.url` is rendered as-is — it is already backend-prefixed for Media-Library assets (same as the `image` and `button` blocks), so it is **not** re-prefixed.
|
|
261
|
+
|
|
262
|
+
The player is wrapped in a `<figure className="bb-audio align-{alignment}">` (alignment defaults to `center`; `left` / `center` / `right` place the player via flexbox, `none` stretches it full-width). An optional `title` renders above the player in a `<figcaption className="bb-audio-title">` and an optional `caption` below it in a `<figcaption className="bb-audio-caption">`. For accessibility the `<audio>` gets an `aria-label` (the `title`, falling back to `"Audio player"`) and, when a caption is present, an `aria-describedby` pointing at it; native HTML5 controls are keyboard-accessible. Inside the element, fallback text and a download link render for browsers/formats that can't play it.
|
|
263
|
+
|
|
264
|
+
Baseline appearance ships as inline styles (zero-config, no stylesheet import), and every element carries a stable `bb-audio*` class so you can restyle from your own CSS:
|
|
265
|
+
|
|
266
|
+
```css
|
|
267
|
+
.bb-audio {
|
|
268
|
+
display: flex;
|
|
269
|
+
flex-direction: column;
|
|
270
|
+
gap: 0.5rem;
|
|
271
|
+
margin: 1rem 0;
|
|
272
|
+
}
|
|
273
|
+
.bb-audio.align-left {
|
|
274
|
+
align-items: flex-start;
|
|
275
|
+
}
|
|
276
|
+
.bb-audio.align-center {
|
|
277
|
+
align-items: center;
|
|
278
|
+
}
|
|
279
|
+
.bb-audio.align-right {
|
|
280
|
+
align-items: flex-end;
|
|
281
|
+
}
|
|
282
|
+
.bb-audio.align-none {
|
|
283
|
+
align-items: stretch;
|
|
284
|
+
}
|
|
285
|
+
.bb-audio audio {
|
|
286
|
+
width: 100%;
|
|
287
|
+
max-width: 32rem;
|
|
288
|
+
}
|
|
289
|
+
.bb-audio.align-none audio {
|
|
290
|
+
max-width: 100%;
|
|
291
|
+
}
|
|
292
|
+
.bb-audio-title {
|
|
293
|
+
font-weight: 600;
|
|
294
|
+
}
|
|
295
|
+
.bb-audio-caption {
|
|
296
|
+
font-size: 0.875rem;
|
|
297
|
+
color: #6b7280;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
To fully control the markup, override the `audio` block. It receives `file`, `title`, `caption`, `player`, and `alignment`:
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
<BlocksRenderer
|
|
305
|
+
content={blocks}
|
|
306
|
+
blocks={{
|
|
307
|
+
audio: ({ file, title, caption, player, alignment = 'center' }) => (
|
|
308
|
+
<figure className={`bb-audio align-${alignment}`}>
|
|
309
|
+
{title && <figcaption className="bb-audio-title">{title}</figcaption>}
|
|
310
|
+
<audio
|
|
311
|
+
src={file.url}
|
|
312
|
+
controls={player.controls}
|
|
313
|
+
autoPlay={player.autoplay}
|
|
314
|
+
loop={player.loop}
|
|
315
|
+
preload={player.preload}
|
|
316
|
+
aria-label={title || 'Audio player'}
|
|
317
|
+
>
|
|
318
|
+
Your browser does not support the audio element. <a href={file.url}>Download the audio</a>
|
|
319
|
+
.
|
|
320
|
+
</audio>
|
|
321
|
+
{caption && <figcaption className="bb-audio-caption">{caption}</figcaption>}
|
|
322
|
+
</figure>
|
|
323
|
+
),
|
|
324
|
+
}}
|
|
325
|
+
/>
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
### Embeds (iframes)
|
|
329
|
+
|
|
330
|
+
Block-level `embed` nodes render an iframe from a share URL (YouTube, Vimeo, Loom, Wistia, Dailymotion, api.video) or from raw embed code the author pasted. The node carries a ready-to-render `embedHtml` field, and that is the **only** field the renderer needs — `url` and `iframe` exist purely to round-trip the editor UI and are ignored.
|
|
331
|
+
|
|
332
|
+
The markup is wrapped in a `<figure className="bb-embed align-{alignment}">` (alignment defaults to `center`; `left` / `center` / `right` place the box via flexbox, `none` stretches it full-width) containing a `<div className="bb-embed-frame">` that carries the CSS `aspect-ratio`. `aspectRatio` converts by replacing `:` with `/` (`"16:9"` → `16 / 9`); when it is `"custom"` the `customAspectRatio` value is used verbatim, and anything missing falls back to `16 / 9`. The optional `caption` renders below in a `<figcaption className="bb-embed-caption">`, and `title` becomes the figure's `aria-label` (it is already baked into the iframe's `title` attribute for URL-derived embeds). An embed whose source was cleared degrades to a plain link to `url` rather than vanishing.
|
|
333
|
+
|
|
334
|
+
> **Trust boundary.** `embedHtml` is injected with `dangerouslySetInnerHTML`. The plugin sanitizes it at author time — the iframe is rebuilt from an attribute allowlist over an **https-only** `src`, with scripts, event handlers, inline styles and unknown attributes stripped, and `allow` filtered to a safe permission set — so treat your CMS content as trusted. If you accept `embed` blocks from untrusted authors, sanitize on the server before storing, or override the block (below) and render the parsed parts instead of the HTML.
|
|
335
|
+
|
|
336
|
+
Consumers need to allow the embed hosts in their `frame-src` (and `img-src` for thumbnails) CSP directives — see the [plugin README](https://github.com/qkix/strapi-plugin-better-blocks#embed-json-shape-for-frontend-renderers) for the host list.
|
|
337
|
+
|
|
338
|
+
Baseline appearance ships as inline styles plus one small stylesheet (injected only when a default embed renders, since inline styles can't reach into injected markup). Every element carries a stable `bb-embed*` class:
|
|
339
|
+
|
|
340
|
+
```css
|
|
341
|
+
.bb-embed-frame {
|
|
342
|
+
overflow: hidden;
|
|
343
|
+
}
|
|
344
|
+
.bb-embed-frame iframe {
|
|
345
|
+
width: 100%;
|
|
346
|
+
height: 100%;
|
|
347
|
+
border: 0;
|
|
348
|
+
display: block;
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
To render the parsed parts yourself — a privacy-friendly click-to-play using `thumbnail`, say, or a provider-specific component — override the `embed` block. It receives `source`, `url`, `iframe`, `embedHtml`, `embedSrc`, `provider`, `thumbnail`, `aspectRatio`, `customAspectRatio`, `alignment`, `caption`, and `title`:
|
|
353
|
+
|
|
354
|
+
```tsx
|
|
355
|
+
<BlocksRenderer
|
|
356
|
+
content={blocks}
|
|
357
|
+
blocks={{
|
|
358
|
+
embed: ({ embedSrc, provider, thumbnail, title, caption }) => (
|
|
359
|
+
<figure className={`embed embed-${provider}`}>
|
|
360
|
+
<ClickToPlay src={embedSrc} poster={thumbnail} title={title} />
|
|
361
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
362
|
+
</figure>
|
|
363
|
+
),
|
|
364
|
+
}}
|
|
365
|
+
/>
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Video
|
|
369
|
+
|
|
370
|
+
Block-level `video` nodes render a provider-aware player. Direct file URLs (`provider: "local"` from the Media Library, or `"custom"`) use a native HTML5 `<video>`, with the nested `player` flags mapped 1:1 (`controls` defaults to `true`; `autoplay`, `loop`, `muted` default to `false`). A `transcript` URL becomes a `<track kind="captions">`, `poster` shows before playback, and the layout follows the same `alignment` / `aspectRatio` rules as [Embeds](#embeds-iframes) — a `<figure className="bb-video align-{alignment}">` holding a `<div className="bb-video-frame">`. The optional `title` renders above in a `<figcaption className="bb-video-title">` (and as the player's `aria-label`), the `caption` below in a `<figcaption className="bb-video-caption">` linked via `aria-describedby`.
|
|
371
|
+
|
|
372
|
+
**HLS / DASH.** Mux and friends serve `.m3u8` (HLS) or `.mpd` (DASH) manifests, which a bare `<video>` only plays in Safari and iOS WebKit. This package takes **no streaming dependency** — it would cost every consumer bundle size for a block most pages don't use — and instead upgrades playback opportunistically when you provide a player:
|
|
373
|
+
|
|
374
|
+
| You provide | Result |
|
|
375
|
+
| ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
|
|
376
|
+
| [`@mux/mux-player`](https://www.npmjs.com/package/@mux/mux-player) (registers the `<mux-player>` custom element) | `provider: "mux"` nodes with a `playbackId` render `<mux-player>` |
|
|
377
|
+
| [`hls.js`](https://www.npmjs.com/package/hls.js) exposed as `window.Hls` | `.m3u8` sources attach hls.js on mount and play everywhere |
|
|
378
|
+
| Nothing | Native playback (works in Safari), `poster` elsewhere |
|
|
379
|
+
|
|
380
|
+
Both are detected at runtime, so nothing extra is bundled when you don't use them. `<mux-player>` is watched via `customElements.whenDefined`, so a player registered by an async side-effect import still takes over once it lands. Wiring up hls.js is a one-liner in your app entry:
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
import Hls from 'hls.js';
|
|
384
|
+
|
|
385
|
+
window.Hls = Hls;
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
For Mux, a `playbackId` is all the frontend needs for a **public** playback policy — no credentials. Signed-policy assets aren't selectable in the editor, since they need a short-lived JWT minted per request.
|
|
389
|
+
|
|
390
|
+
To fully control the player, override the `video` block. It receives `provider`, `url`, `assetId`, `playbackId`, `file`, `poster`, `title`, `caption`, `transcript`, `player`, `alignment`, `aspectRatio`, and `customAspectRatio`:
|
|
391
|
+
|
|
392
|
+
```tsx
|
|
393
|
+
import MuxPlayer from '@mux/mux-player-react';
|
|
394
|
+
|
|
395
|
+
<BlocksRenderer
|
|
396
|
+
content={blocks}
|
|
397
|
+
blocks={{
|
|
398
|
+
video: ({ provider, url, playbackId, poster, title, caption }) => (
|
|
399
|
+
<figure className="video">
|
|
400
|
+
{provider === 'mux' && playbackId ? (
|
|
401
|
+
<MuxPlayer playbackId={playbackId} poster={poster} metadata={{ video_title: title }} />
|
|
402
|
+
) : (
|
|
403
|
+
<video src={url} poster={poster} controls />
|
|
404
|
+
)}
|
|
405
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
406
|
+
</figure>
|
|
407
|
+
),
|
|
408
|
+
}}
|
|
409
|
+
/>;
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
> **Deprecated:** the older `media-embed` block (`{ type: "media-embed", url, originalUrl }`) is no longer inserted by the editor — the toolbar's media button now creates an `embed` node — but the renderer keeps handling it so content authored before the `embed` block still displays.
|
|
413
|
+
|
|
414
|
+
### Tables
|
|
415
|
+
|
|
416
|
+
Tables render as semantic HTML. Every **leading row whose cells are all `table-header-cell`** is a header row: those rows render inside `<thead>` with each cell as `<th scope="col">`, and the rest go in `<tbody>`. A row of any other shape ends the header, so a table that doesn't start with header cells stays entirely in `<tbody>`. A `table-header-cell` that appears in a body row is a row header and gets `scope="row"`.
|
|
417
|
+
|
|
418
|
+
Cells carry three optional properties, each with a "cheapest" default the editor omits:
|
|
419
|
+
|
|
420
|
+
| Property | Values | Absent means |
|
|
421
|
+
| --------- | ------------------------- | ------------------- |
|
|
422
|
+
| `align` | `left`, `center`, `right` | `left` (no `style`) |
|
|
423
|
+
| `colSpan` | integer ≥ 1 | `1` (no attribute) |
|
|
424
|
+
| `rowSpan` | integer ≥ 1 | `1` (no attribute) |
|
|
425
|
+
|
|
426
|
+
`align` is applied as inline `text-align`; the spans map straight onto the HTML attributes of the same name. Merged cells follow hand-written HTML rules — a spanned-over slot has no node at all, so rows and cells render in document order with no grid reconstruction.
|
|
427
|
+
|
|
428
|
+
A merged header with a `rowSpan` grouping in the body — `Region` and `Team` span both header rows, `Half-year totals` spans its two sub-columns, and `North` spans the two rows below it:
|
|
429
|
+
|
|
430
|
+
```json
|
|
431
|
+
{
|
|
432
|
+
"type": "table",
|
|
433
|
+
"children": [
|
|
434
|
+
{
|
|
435
|
+
"type": "table-row",
|
|
436
|
+
"children": [
|
|
437
|
+
{
|
|
438
|
+
"type": "table-header-cell",
|
|
439
|
+
"rowSpan": 2,
|
|
440
|
+
"children": [{ "type": "text", "text": "Region" }]
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"type": "table-header-cell",
|
|
444
|
+
"rowSpan": 2,
|
|
445
|
+
"children": [{ "type": "text", "text": "Team" }]
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
"type": "table-header-cell",
|
|
449
|
+
"colSpan": 2,
|
|
450
|
+
"align": "center",
|
|
451
|
+
"children": [{ "type": "text", "text": "Half-year totals" }]
|
|
452
|
+
}
|
|
453
|
+
]
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
"type": "table-row",
|
|
457
|
+
"children": [
|
|
458
|
+
{
|
|
459
|
+
"type": "table-header-cell",
|
|
460
|
+
"align": "center",
|
|
461
|
+
"children": [{ "type": "text", "text": "Q3" }]
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"type": "table-header-cell",
|
|
465
|
+
"align": "center",
|
|
466
|
+
"children": [{ "type": "text", "text": "Q4" }]
|
|
467
|
+
}
|
|
468
|
+
]
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
"type": "table-row",
|
|
472
|
+
"children": [
|
|
473
|
+
{ "type": "table-cell", "rowSpan": 2, "children": [{ "type": "text", "text": "North" }] },
|
|
474
|
+
{ "type": "table-cell", "children": [{ "type": "text", "text": "Alpha" }] },
|
|
475
|
+
{ "type": "table-cell", "align": "center", "children": [{ "type": "text", "text": "12" }] },
|
|
476
|
+
{ "type": "table-cell", "align": "center", "children": [{ "type": "text", "text": "18" }] }
|
|
477
|
+
]
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
"type": "table-row",
|
|
481
|
+
"children": [
|
|
482
|
+
{ "type": "table-cell", "children": [{ "type": "text", "text": "Beta" }] },
|
|
483
|
+
{ "type": "table-cell", "align": "center", "children": [{ "type": "text", "text": "9" }] },
|
|
484
|
+
{ "type": "table-cell", "align": "center", "children": [{ "type": "text", "text": "14" }] }
|
|
485
|
+
]
|
|
486
|
+
}
|
|
487
|
+
]
|
|
488
|
+
}
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
renders as:
|
|
492
|
+
|
|
493
|
+
```html
|
|
494
|
+
<table class="bb-table">
|
|
495
|
+
<thead>
|
|
496
|
+
<tr>
|
|
497
|
+
<th scope="col" rowspan="2">Region</th>
|
|
498
|
+
<th scope="col" rowspan="2">Team</th>
|
|
499
|
+
<th scope="col" colspan="2" style="text-align: center">Half-year totals</th>
|
|
500
|
+
</tr>
|
|
501
|
+
<tr>
|
|
502
|
+
<th scope="col" style="text-align: center">Q3</th>
|
|
503
|
+
<th scope="col" style="text-align: center">Q4</th>
|
|
504
|
+
</tr>
|
|
505
|
+
</thead>
|
|
506
|
+
<tbody>
|
|
507
|
+
<tr>
|
|
508
|
+
<td rowspan="2">North</td>
|
|
509
|
+
<td>Alpha</td>
|
|
510
|
+
<td style="text-align: center">12</td>
|
|
511
|
+
<td style="text-align: center">18</td>
|
|
512
|
+
</tr>
|
|
513
|
+
<tr>
|
|
514
|
+
<td>Beta</td>
|
|
515
|
+
<td style="text-align: center">9</td>
|
|
516
|
+
<td style="text-align: center">14</td>
|
|
517
|
+
</tr>
|
|
518
|
+
</tbody>
|
|
519
|
+
</table>
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
Note that the second header row holds only two cells and the `Beta` row only three — the slots covered by a `rowSpan` above them carry no node.
|
|
523
|
+
|
|
524
|
+
Cell `children` go through the same inline renderer as paragraphs, so marks (`bold`, `italic`, `underline`, `strikethrough`, `code`, `color`, `backgroundColor`), links, and inline math all work inside cells.
|
|
525
|
+
|
|
526
|
+
```jsx
|
|
527
|
+
<BlocksRenderer
|
|
528
|
+
content={content}
|
|
529
|
+
blocks={{
|
|
530
|
+
'table-cell': ({ children, align, colSpan, rowSpan, style }) => (
|
|
531
|
+
<td className="my-td" colSpan={colSpan} rowSpan={rowSpan} style={style} data-align={align}>
|
|
532
|
+
{children}
|
|
533
|
+
</td>
|
|
534
|
+
),
|
|
535
|
+
}}
|
|
536
|
+
/>
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Tables, Blockquotes & Code Blocks (GitHub-style)
|
|
540
|
+
|
|
541
|
+
These three blocks ship with GitHub-flavored defaults out of the box — no stylesheet to import. Each carries a stable `bb-*` class, and the styles are injected as a `<style>` tag only when the block actually appears in the content (and skipped entirely when you override the block). Everything is rethemable through CSS custom properties, so you can restyle without replacing any markup.
|
|
542
|
+
|
|
543
|
+
The same classes and custom properties are used by the [Astro renderer](https://github.com/qkix/strapi-plugin-better-blocks/tree/main/packages/better-blocks-astro-renderer), so one shared theme covers both.
|
|
544
|
+
|
|
545
|
+
| Block | Default element | Custom properties |
|
|
546
|
+
| ------- | ------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
547
|
+
| `table` | `<table class="bb-table">` | `--bb-table-border`, `--bb-table-header-bg`, `--bb-table-row-bg`, `--bb-table-stripe-bg` |
|
|
548
|
+
| `quote` | `<blockquote class="bb-quote">` | `--bb-quote-border`, `--bb-quote-fg` |
|
|
549
|
+
| `code` | `<div class="bb-code">` | `--bb-code-fallback-bg`, `--bb-code-fallback-fg`, `--bb-code-copy-*` |
|
|
550
|
+
|
|
551
|
+
**Tables** get bordered cells, a shaded header, zebra-striped body rows, and horizontal scrolling on overflow. See [Tables](#tables) for the `<thead>` / `<tbody>` rules and cell properties.
|
|
552
|
+
|
|
553
|
+
**Blockquotes** get a muted left border with indented, dimmed text — GitHub's markdown quote, which has no background fill.
|
|
554
|
+
|
|
555
|
+
**Code blocks** are syntax-highlighted with [Shiki](https://shiki.style/), driven by the `language` the editor stores on the block:
|
|
556
|
+
|
|
557
|
+
```json
|
|
558
|
+
{
|
|
559
|
+
"type": "code",
|
|
560
|
+
"language": "typescript",
|
|
561
|
+
"children": [{ "type": "text", "text": "const x: number = 1;" }]
|
|
562
|
+
}
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Shiki needs to resolve grammars asynchronously, so — like [diagrams](#diagrams-mermaid) — highlighting happens on the client. The server render and first paint emit the raw source in a plain `<pre class="bb-code-pre">` so hydration matches, then the highlighted markup swaps in after mount:
|
|
566
|
+
|
|
567
|
+
```html
|
|
568
|
+
<!-- before hydration -->
|
|
569
|
+
<div class="bb-code">
|
|
570
|
+
<pre class="bb-code-pre"><code>const x: number = 1;</code></pre>
|
|
571
|
+
</div>
|
|
572
|
+
|
|
573
|
+
<!-- after -->
|
|
574
|
+
<div class="bb-code">
|
|
575
|
+
<div>
|
|
576
|
+
<pre class="shiki github-dark" style="…"><code>…</code></pre>
|
|
577
|
+
</div>
|
|
578
|
+
</div>
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
If Shiki fails to load, the plain `<pre>` simply stays. Language values are mapped to Shiki grammar ids (`objectivec` → `objective-c`, `fortran` → `fortran-free-form`, `vbnet` → `vb`, …); an unknown or missing language falls back to `plaintext`, rendered themed but unhighlighted, so a stray value never breaks the page.
|
|
582
|
+
|
|
583
|
+
Two props control the defaults:
|
|
584
|
+
|
|
585
|
+
| Prop | Default | Description |
|
|
586
|
+
| ---------------- | --------------- | -------------------------------------------------------------- |
|
|
587
|
+
| `codeTheme` | `'github-dark'` | Any bundled Shiki theme (`github-light`, `dracula`, `nord`, …) |
|
|
588
|
+
| `codeCopyButton` | `false` | Adds a "Copy" button in the top-right corner |
|
|
589
|
+
|
|
590
|
+
```jsx
|
|
591
|
+
<BlocksRenderer content={content} codeTheme="github-light" codeCopyButton />
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
Because the pre-hydration `<pre>` has no theme colors of its own, it defaults to the `github-dark` palette. If you change `codeTheme`, set the fallback colors to match so the swap isn't jarring:
|
|
595
|
+
|
|
596
|
+
```css
|
|
597
|
+
:root {
|
|
598
|
+
--bb-code-fallback-bg: #fff;
|
|
599
|
+
--bb-code-fallback-fg: #24292f;
|
|
600
|
+
}
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
Both props are ignored when you supply your own `code` renderer, which receives the raw editor `language` (not the Shiki grammar id) so it can map it however it likes:
|
|
604
|
+
|
|
605
|
+
```jsx
|
|
606
|
+
<BlocksRenderer
|
|
607
|
+
content={content}
|
|
608
|
+
blocks={{
|
|
609
|
+
code: ({ plainText, language }) => <Prism code={plainText} language={language} />,
|
|
610
|
+
}}
|
|
611
|
+
/>
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
### Astro
|
|
615
|
+
|
|
616
|
+
`BlocksRenderer` works in [Astro](https://astro.build/) via the [`@astrojs/react`](https://docs.astro.build/en/guides/integrations-guide/react/) integration. Because the renderer is purely presentational and KaTeX renders to a string on the server (see [Math (KaTeX)](#math-katex)), you can render it as a static [Astro island](https://docs.astro.build/en/concepts/islands/) with **no client directive** — Astro outputs plain HTML and ships zero JavaScript:
|
|
617
|
+
|
|
618
|
+
```astro
|
|
619
|
+
---
|
|
620
|
+
import { BlocksRenderer } from '@qkix/better-blocks-react-renderer';
|
|
621
|
+
// Import the KaTeX stylesheet once (e.g. in a shared layout) so math displays correctly.
|
|
622
|
+
import 'katex/dist/katex.min.css';
|
|
623
|
+
|
|
624
|
+
const { blocks } = Astro.props;
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
<BlocksRenderer content={blocks} />
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
You only need a client directive (`client:load`, `client:visible`, etc.) if you pass **interactive** custom renderers — for example a to-do `list-item` with a working checkbox, or a custom `math` renderer that hydrates on the client. Static content (including server-rendered KaTeX) needs no hydration:
|
|
631
|
+
|
|
632
|
+
```astro
|
|
633
|
+
---
|
|
634
|
+
import { BlocksRenderer } from '@qkix/better-blocks-react-renderer';
|
|
635
|
+
const { blocks } = Astro.props;
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
<!-- Use a client directive only when your custom renderers need to run in the browser -->
|
|
639
|
+
<BlocksRenderer content={blocks} client:visible />
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
> **Note:** When you hydrate with a client directive, custom renderers passed as props must be serializable references (e.g. imported components), since Astro serializes island props. Keep inline closures for the static (no-directive) case.
|
|
643
|
+
|
|
644
|
+
## Supported Blocks
|
|
645
|
+
|
|
646
|
+
| Block | Default element | Source |
|
|
647
|
+
| ------------------------------- | ------------------------------- | --------------------------- |
|
|
648
|
+
| `paragraph` | `<p>` | Strapi core |
|
|
649
|
+
| `heading` (1–6) | `<h1>`–`<h6>` | Strapi core |
|
|
650
|
+
| `list` (ordered/unordered/todo) | `<ol>` / `<ul>` | Strapi core + Better Blocks |
|
|
651
|
+
| `list-item` | `<li>` | Strapi core |
|
|
652
|
+
| `link` | `<a>` | Strapi core |
|
|
653
|
+
| `quote` | `<blockquote class="bb-quote">` | Strapi core |
|
|
654
|
+
| `code` | `<pre><code>` (Shiki) | Strapi core |
|
|
655
|
+
| `image` | `<figure><img>` | Strapi core |
|
|
656
|
+
| `horizontal-line` | `<hr>` | Better Blocks |
|
|
657
|
+
| `table` | `<table class="bb-table">` | Better Blocks |
|
|
658
|
+
| `table-header-cell` | `<th scope="col">` | Better Blocks |
|
|
659
|
+
| `table-cell` | `<td>` | Better Blocks |
|
|
660
|
+
| `media-embed` (deprecated) | `<iframe>` (16:9) | Better Blocks |
|
|
661
|
+
| `embed` (iframe) | `<figure><iframe>` | Better Blocks |
|
|
662
|
+
| `video` | `<figure><video>` | Better Blocks |
|
|
663
|
+
| `math` (inline/block) | `<span>` / `<div>` | Better Blocks |
|
|
664
|
+
| `diagram` (mermaid) | `<div>` (SVG) | Better Blocks |
|
|
665
|
+
| `callout` (admonition) | `<aside>` | Better Blocks |
|
|
666
|
+
| `details` (collapsible) | `<details>` | Better Blocks |
|
|
667
|
+
| `button` (CTA / file download) | `<a>` | Better Blocks |
|
|
668
|
+
| `social-embed` | `<figure>` | Better Blocks |
|
|
669
|
+
| `audio` | `<figure><audio>` | Better Blocks |
|
|
670
|
+
|
|
671
|
+
### Block properties
|
|
672
|
+
|
|
673
|
+
| Property | Applies to | Description |
|
|
674
|
+
| ------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
675
|
+
| `textAlign` | paragraph, heading, quote | Text alignment (`left`, `center`, `right`, `justify`) |
|
|
676
|
+
| `lineHeight` | paragraph, heading, quote | CSS line-height value (e.g. `1.5`, `2.0`) |
|
|
677
|
+
| `indent` | paragraph, heading, quote | Block indentation level (`marginLeft: N * 2rem`) |
|
|
678
|
+
| `indentLevel` | list | Cycling list-style-type per nesting depth |
|
|
679
|
+
| `format` | list | `ordered`, `unordered`, or `todo` |
|
|
680
|
+
| `checked` | list-item (in todo lists) | Checkbox state (`true`/`false`) |
|
|
681
|
+
| `target` | link | `_blank` for new-tab links |
|
|
682
|
+
| `rel` | link | `noopener noreferrer` for new-tab links |
|
|
683
|
+
| `caption` | image | Text displayed below the image |
|
|
684
|
+
| `imageAlign` | image | Image alignment (`left`, `center`, `right`) |
|
|
685
|
+
| `language` | code | Shiki grammar for syntax highlighting (e.g. `typescript`, `python`); falls back to `plaintext` |
|
|
686
|
+
| `align` | table-cell, table-header-cell | Cell text alignment (`left` when absent, `center`, `right`) |
|
|
687
|
+
| `colSpan` | table-cell, table-header-cell | HTML `colspan` (`1` when absent) |
|
|
688
|
+
| `rowSpan` | table-cell, table-header-cell | HTML `rowspan` (`1` when absent) |
|
|
689
|
+
| `url` | media-embed | Embed URL (YouTube/Vimeo iframe src) |
|
|
690
|
+
| `originalUrl` | media-embed | Original user-provided URL |
|
|
691
|
+
| `format` | math | `inline` (`<span>`) or `block` (`<div>`) |
|
|
692
|
+
| `value` | math | LaTeX source rendered with KaTeX |
|
|
693
|
+
| `format` | diagram | `mermaid` |
|
|
694
|
+
| `value` | diagram | Mermaid source rendered to SVG |
|
|
695
|
+
| `summary` | details | Plain-text label for the `<summary>` |
|
|
696
|
+
| `defaultOpen` | details | Open on initial render (HTML `open` attribute) |
|
|
697
|
+
| `buttonType` | button | `link` or `file` (download) mode |
|
|
698
|
+
| `label` | button | Visible button text |
|
|
699
|
+
| `alignment` | button | `left`, `center`, `right`, or `none` (inline) |
|
|
700
|
+
| `link` | button (link mode) | `{ url, target, rel, ariaLabel }` |
|
|
701
|
+
| `file` | button (file mode) | `{ url, name, size, ext, mime }` for download |
|
|
702
|
+
| `showFileIcon` | button (file mode) | Prefix the label with a file-type icon |
|
|
703
|
+
| `showFileSize` | button (file mode) | Suffix the label with a human-readable size |
|
|
704
|
+
| `filePreview` | button (file mode) | `true` opens the file in a new tab instead of downloading |
|
|
705
|
+
| `style` | button | Inline CSS + hover custom properties |
|
|
706
|
+
| `cssClass` | button | Extra class appended to `bb-button` |
|
|
707
|
+
| `platform` | social-embed | `twitter`, `instagram`, `facebook`, `tiktok`, `linkedin`, `pinterest` |
|
|
708
|
+
| `url` | social-embed | Original post URL, optional (used by the fallback link card) |
|
|
709
|
+
| `embedCode` | social-embed | Optional manual HTML override (highest priority) |
|
|
710
|
+
| `oembed` | social-embed | Fetched oEmbed payload `{ html, title, author, authorUrl, thumbnailUrl, providerName, width, height }` |
|
|
711
|
+
| `alignment` | social-embed | `left`, `center` (default), or `right` |
|
|
712
|
+
| `caption` | social-embed | Optional caption rendered in a `<figcaption>` |
|
|
713
|
+
| `file` | audio | `{ url, name, ext, hash, mime, size, provider, duration }` — `url` is rendered as the `src` |
|
|
714
|
+
| `player` | audio | `{ controls (default true), autoplay, loop, preload }` — mapped 1:1 onto `<audio>` |
|
|
715
|
+
| `title` | audio | Optional title rendered above the player (also used as the `aria-label`) |
|
|
716
|
+
| `caption` | audio | Optional caption rendered below the player in a `<figcaption>` |
|
|
717
|
+
| `alignment` | audio | `left`, `center` (default), `right`, or `none` (full-width) |
|
|
718
|
+
| `embedHtml` | embed | Plugin-sanitized iframe markup — the only field needed to render |
|
|
719
|
+
| `embedSrc` | embed | The iframe's `src`, hoisted for host/CSP checks |
|
|
720
|
+
| `provider` | embed | `youtube`, `vimeo`, `loom`, `wistia`, `dailymotion`, `api-video`, or `generic` |
|
|
721
|
+
| `thumbnail` | embed | Poster image, when the provider exposes one (used by custom renderers) |
|
|
722
|
+
| `source` | embed | `url` or `iframe` — which input the author used |
|
|
723
|
+
| `url` | embed | Original share URL, also the fallback link when `embedHtml` is absent |
|
|
724
|
+
| `title` | embed | Accessible name (already baked into `embedHtml` for URL-derived embeds) |
|
|
725
|
+
| `provider` | video | `local`, `mux`, `api-video`, `cloudinary`, or `custom` |
|
|
726
|
+
| `url` | video | Playback URL — a direct file, or an HLS/DASH manifest |
|
|
727
|
+
| `playbackId` | video | Provider playback id (`<mux-player playback-id>` for Mux) |
|
|
728
|
+
| `poster` | video | Thumbnail shown before playback |
|
|
729
|
+
| `transcript` | video | WebVTT URL rendered as `<track kind="captions">` |
|
|
730
|
+
| `player` | video | `{ controls (default true), autoplay, loop, muted }` — mapped 1:1 onto `<video>` |
|
|
731
|
+
| `alignment` | embed, video | `left`, `center` (default), `right`, or `none` (full-width) |
|
|
732
|
+
| `aspectRatio` | embed, video | `16:9`, `21:9`, `4:3`, `1:1`, or `custom` — CSS `aspect-ratio` on the frame |
|
|
733
|
+
| `customAspectRatio` | embed, video | Free-form `width / height`, used when `aspectRatio` is `custom` |
|
|
734
|
+
| `caption` | embed, video | Optional caption rendered in a `<figcaption>` |
|
|
735
|
+
|
|
736
|
+
## Supported Modifiers
|
|
737
|
+
|
|
738
|
+
| Modifier | Default element | Source |
|
|
739
|
+
| ----------------- | ---------------------------------- | ------------- |
|
|
740
|
+
| `bold` | `<strong>` | Strapi core |
|
|
741
|
+
| `italic` | `<em>` | Strapi core |
|
|
742
|
+
| `underline` | `<span>` | Strapi core |
|
|
743
|
+
| `strikethrough` | `<del>` | Strapi core |
|
|
744
|
+
| `code` | `<code>` | Strapi core |
|
|
745
|
+
| `uppercase` | `<span style={{textTransform}}>` | Better Blocks |
|
|
746
|
+
| `superscript` | `<sup>` | Better Blocks |
|
|
747
|
+
| `subscript` | `<sub>` | Better Blocks |
|
|
748
|
+
| `color` | `<span style={{color}}>` | Better Blocks |
|
|
749
|
+
| `backgroundColor` | `<span style={{backgroundColor}}>` | Better Blocks |
|
|
750
|
+
| `fontFamily` | `<span style={{fontFamily}}>` | Better Blocks |
|
|
751
|
+
| `fontSize` | `<span style={{fontSize}}>` | Better Blocks |
|
|
752
|
+
|
|
753
|
+
## Custom Renderers
|
|
754
|
+
|
|
755
|
+
### Custom block renderers
|
|
756
|
+
|
|
757
|
+
Override any block type with your own component:
|
|
758
|
+
|
|
759
|
+
```tsx
|
|
760
|
+
<BlocksRenderer
|
|
761
|
+
content={blocks}
|
|
762
|
+
blocks={{
|
|
763
|
+
paragraph: ({ children, style }) => (
|
|
764
|
+
<p className="my-paragraph" style={style}>
|
|
765
|
+
{children}
|
|
766
|
+
</p>
|
|
767
|
+
),
|
|
768
|
+
heading: ({ children, level, style }) => {
|
|
769
|
+
const Tag = `h${level}`;
|
|
770
|
+
return <Tag style={style}>{children}</Tag>;
|
|
771
|
+
},
|
|
772
|
+
link: ({ children, url, target, rel }) => (
|
|
773
|
+
<a href={url} target={target} rel={rel}>
|
|
774
|
+
{children}
|
|
775
|
+
</a>
|
|
776
|
+
),
|
|
777
|
+
image: ({ image, caption, imageAlign }) => (
|
|
778
|
+
<figure style={{ textAlign: imageAlign }}>
|
|
779
|
+
<img src={image.url} alt={image.alternativeText || ''} loading="lazy" />
|
|
780
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
781
|
+
</figure>
|
|
782
|
+
),
|
|
783
|
+
'list-item': ({ children, checked }) =>
|
|
784
|
+
checked !== undefined ? (
|
|
785
|
+
<li style={{ listStyle: 'none' }}>
|
|
786
|
+
<input type="checkbox" checked={checked} readOnly /> {children}
|
|
787
|
+
</li>
|
|
788
|
+
) : (
|
|
789
|
+
<li>{children}</li>
|
|
790
|
+
),
|
|
791
|
+
'horizontal-line': () => <hr className="my-divider" />,
|
|
792
|
+
quote: ({ children, style }) => (
|
|
793
|
+
<blockquote className="my-quote" style={style}>
|
|
794
|
+
{children}
|
|
795
|
+
</blockquote>
|
|
796
|
+
),
|
|
797
|
+
// `language` is the raw editor value, not the Shiki grammar id
|
|
798
|
+
code: ({ plainText, language }) => <Prism code={plainText} language={language} />,
|
|
799
|
+
table: ({ children }) => <table className="my-table">{children}</table>,
|
|
800
|
+
'table-header-cell': ({ children, colSpan, rowSpan, style }) => (
|
|
801
|
+
<th className="my-th" scope="col" colSpan={colSpan} rowSpan={rowSpan} style={style}>
|
|
802
|
+
{children}
|
|
803
|
+
</th>
|
|
804
|
+
),
|
|
805
|
+
'table-cell': ({ children, colSpan, rowSpan, style }) => (
|
|
806
|
+
<td className="my-td" colSpan={colSpan} rowSpan={rowSpan} style={style}>
|
|
807
|
+
{children}
|
|
808
|
+
</td>
|
|
809
|
+
),
|
|
810
|
+
'media-embed': ({ url }) => (
|
|
811
|
+
<div className="video-wrapper">
|
|
812
|
+
<iframe src={url} allowFullScreen title="Embedded media" />
|
|
813
|
+
</div>
|
|
814
|
+
),
|
|
815
|
+
// Bring your own math engine (e.g. MathJax) instead of the built-in KaTeX
|
|
816
|
+
math: ({ formula, inline }) =>
|
|
817
|
+
inline ? <MyInlineMath formula={formula} /> : <MyBlockMath formula={formula} />,
|
|
818
|
+
// Bring your own diagram engine instead of the built-in Mermaid
|
|
819
|
+
diagram: ({ code, format }) => <MyDiagram code={code} format={format} />,
|
|
820
|
+
'social-embed': ({ platform, url, oembed, caption }) => (
|
|
821
|
+
<MySocialEmbed platform={platform} url={url} oembed={oembed} caption={caption} />
|
|
822
|
+
),
|
|
823
|
+
// Render the parsed parts instead of injecting the stored embed HTML
|
|
824
|
+
embed: ({ embedSrc, provider, thumbnail, title }) => (
|
|
825
|
+
<MyClickToPlay src={embedSrc} provider={provider} poster={thumbnail} title={title} />
|
|
826
|
+
),
|
|
827
|
+
// Bring your own streaming player instead of the built-in detection
|
|
828
|
+
video: ({ provider, url, playbackId, poster }) => (
|
|
829
|
+
<MyVideoPlayer provider={provider} src={url} playbackId={playbackId} poster={poster} />
|
|
830
|
+
),
|
|
831
|
+
}}
|
|
832
|
+
/>
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
### Custom modifier renderers
|
|
836
|
+
|
|
837
|
+
Override any text modifier with your own component:
|
|
838
|
+
|
|
839
|
+
```tsx
|
|
840
|
+
<BlocksRenderer
|
|
841
|
+
content={blocks}
|
|
842
|
+
modifiers={{
|
|
843
|
+
bold: ({ children }) => <strong className="font-bold">{children}</strong>,
|
|
844
|
+
color: ({ children, color }) => <span style={{ color }}>{children}</span>,
|
|
845
|
+
backgroundColor: ({ children, backgroundColor }) => (
|
|
846
|
+
<mark style={{ backgroundColor }}>{children}</mark>
|
|
847
|
+
),
|
|
848
|
+
}}
|
|
849
|
+
/>
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
## TypeScript
|
|
853
|
+
|
|
854
|
+
All types are exported:
|
|
855
|
+
|
|
856
|
+
```ts
|
|
857
|
+
import type {
|
|
858
|
+
BlocksContent,
|
|
859
|
+
BlocksRendererProps,
|
|
860
|
+
BlockNode,
|
|
861
|
+
TextNode,
|
|
862
|
+
LinkNode,
|
|
863
|
+
ListNode,
|
|
864
|
+
ListItemNode,
|
|
865
|
+
ParagraphNode,
|
|
866
|
+
HeadingNode,
|
|
867
|
+
QuoteNode,
|
|
868
|
+
CodeNode,
|
|
869
|
+
ImageNode,
|
|
870
|
+
HorizontalLineNode,
|
|
871
|
+
TableNode,
|
|
872
|
+
TableRowNode,
|
|
873
|
+
TableCellNode,
|
|
874
|
+
TableHeaderCellNode,
|
|
875
|
+
TableCellAlign,
|
|
876
|
+
MediaEmbedNode,
|
|
877
|
+
MathNode,
|
|
878
|
+
DiagramNode,
|
|
879
|
+
SocialEmbedNode,
|
|
880
|
+
SocialPlatform,
|
|
881
|
+
SocialEmbedAlignment,
|
|
882
|
+
SocialEmbedOembed,
|
|
883
|
+
AudioNode,
|
|
884
|
+
AudioFile,
|
|
885
|
+
AudioPlayer,
|
|
886
|
+
AudioPreload,
|
|
887
|
+
AudioAlignment,
|
|
888
|
+
EmbedNode,
|
|
889
|
+
EmbedProvider,
|
|
890
|
+
VideoNode,
|
|
891
|
+
VideoProvider,
|
|
892
|
+
VideoPlayer,
|
|
893
|
+
VideoFile,
|
|
894
|
+
MediaAlignment,
|
|
895
|
+
AspectRatio,
|
|
896
|
+
TextAlign,
|
|
897
|
+
CustomBlocksConfig,
|
|
898
|
+
CustomModifiersConfig,
|
|
899
|
+
} from '@qkix/better-blocks-react-renderer';
|
|
900
|
+
```
|
|
901
|
+
|
|
902
|
+
## Contributing
|
|
903
|
+
|
|
904
|
+
Contributions are welcome! This package lives in the
|
|
905
|
+
[strapi-plugin-better-blocks](https://github.com/qkix/strapi-plugin-better-blocks)
|
|
906
|
+
monorepo, next to the Strapi plugin and the Astro renderer. The easiest way to
|
|
907
|
+
get started is with Docker:
|
|
908
|
+
|
|
909
|
+
```bash
|
|
910
|
+
git clone https://github.com/qkix/strapi-plugin-better-blocks.git
|
|
911
|
+
cd strapi-plugin-better-blocks
|
|
912
|
+
|
|
913
|
+
docker compose up --build
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
That brings up a Strapi v5 instance running the Better Blocks plugin, seeded
|
|
917
|
+
with the showcase articles, plus both renderers displaying the same content.
|
|
918
|
+
|
|
919
|
+
- **Strapi admin:** http://localhost:1337/admin (login: `admin@example.com` / `admin12#`)
|
|
920
|
+
- **React example:** http://localhost:5173
|
|
921
|
+
- **Astro example:** http://localhost:4321
|
|
922
|
+
|
|
923
|
+
### Development workflow
|
|
924
|
+
|
|
925
|
+
1. Make changes to the renderer source in `packages/better-blocks-react-renderer/src/`
|
|
926
|
+
2. Rebuild with `docker compose up --build` — the renderer is compiled into the image
|
|
927
|
+
3. Editing `examples/react-app/src/` hot-reloads on its own, with no rebuild
|
|
928
|
+
|
|
929
|
+
### Without Docker
|
|
930
|
+
|
|
931
|
+
```bash
|
|
932
|
+
pnpm install
|
|
933
|
+
pnpm build # the core, this renderer and the plugin
|
|
934
|
+
|
|
935
|
+
pnpm --filter @qkix/example-strapi-app develop
|
|
936
|
+
pnpm --filter @qkix/example-react-app dev # in another terminal
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
### Running tests
|
|
940
|
+
|
|
941
|
+
```bash
|
|
942
|
+
pnpm test # every package, from the repo root
|
|
943
|
+
pnpm typecheck
|
|
944
|
+
pnpm lint
|
|
945
|
+
|
|
946
|
+
pnpm --filter @qkix/better-blocks-react-renderer test # just this one
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
## Community & Support
|
|
950
|
+
|
|
951
|
+
- [GitHub Issues](https://github.com/qkix/strapi-plugin-better-blocks/issues) — Bug reports and feature requests
|
|
952
|
+
|
|
953
|
+
## Support this project
|
|
954
|
+
|
|
955
|
+
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:
|
|
956
|
+
|
|
957
|
+
<a href="https://buymeacoffee.com/k11k">
|
|
958
|
+
<img alt="Buy Me a Coffee" src="https://img.shields.io/badge/Buy%20Me%20a%20Coffee-support-FFDD00?logo=buymeacoffee&logoColor=black&style=for-the-badge" />
|
|
959
|
+
</a>
|
|
960
|
+
|
|
961
|
+
Every coffee goes toward fixing bugs, reviewing PRs, writing docs, and shipping the features you ask for. Thank you! ☕
|
|
962
|
+
|
|
963
|
+
## Related
|
|
964
|
+
|
|
965
|
+
- [@qkix/strapi-plugin-better-blocks](https://github.com/qkix/strapi-plugin-better-blocks) — Strapi plugin that extends the Blocks editor with colors, tables, to-do lists, media embeds, and more
|
|
966
|
+
- [@strapi/blocks-react-renderer](https://github.com/strapi/blocks-react-renderer) — Official Strapi renderer (standard blocks only)
|
|
967
|
+
|
|
968
|
+
## License
|
|
969
|
+
|
|
970
|
+
[MIT License](LICENSE) © [qkix](https://github.com/qkix)
|