@k11k/better-blocks-astro-renderer 0.2.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 +332 -0
- package/env.d.ts +1 -0
- package/index.ts +29 -0
- package/package.json +86 -0
- package/src/Block.astro +156 -0
- package/src/BlocksRenderer.astro +11 -0
- package/src/Inline.astro +41 -0
- package/src/List.astro +49 -0
- package/src/ListItem.astro +34 -0
- package/src/Math.astro +42 -0
- package/src/Table.astro +45 -0
- package/src/Text.astro +45 -0
- package/src/types.ts +229 -0
- package/src/utils.ts +112 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 k11k-labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
<h1 align="center">Better Blocks Astro Renderer</h1>
|
|
2
|
+
|
|
3
|
+
<p align="center">Native Astro 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. Zero client-side JavaScript.</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/@k11k/better-blocks-astro-renderer">
|
|
7
|
+
<img alt="npm version" src="https://img.shields.io/npm/v/@k11k/better-blocks-astro-renderer.svg" />
|
|
8
|
+
</a>
|
|
9
|
+
<a href="https://www.npmjs.com/package/@k11k/better-blocks-astro-renderer">
|
|
10
|
+
<img alt="npm downloads" src="https://img.shields.io/npm/dm/@k11k/better-blocks-astro-renderer.svg" />
|
|
11
|
+
</a>
|
|
12
|
+
<a href="https://github.com/k11k-labs/better-blocks-astro-renderer/blob/main/LICENSE">
|
|
13
|
+
<img alt="license" src="https://img.shields.io/npm/l/@k11k/better-blocks-astro-renderer.svg" />
|
|
14
|
+
</a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
<p align="center">
|
|
18
|
+
<img src="./docs/playground-showcase.png" alt="Strapi editor (left) and rendered output (right)" width="800" />
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Table of Contents
|
|
24
|
+
|
|
25
|
+
1. [Why?](#why)
|
|
26
|
+
2. [Compatibility](#compatibility)
|
|
27
|
+
3. [Installation](#installation)
|
|
28
|
+
4. [Usage](#usage)
|
|
29
|
+
5. [Supported Blocks](#supported-blocks)
|
|
30
|
+
6. [Supported Modifiers](#supported-modifiers)
|
|
31
|
+
7. [Custom Renderers](#custom-renderers)
|
|
32
|
+
8. [TypeScript](#typescript)
|
|
33
|
+
9. [Contributing](#contributing)
|
|
34
|
+
10. [License](#license)
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Why?
|
|
39
|
+
|
|
40
|
+
The official Strapi blocks renderers are built for React. If your site is built with [Astro](https://astro.build/), you _can_ render Strapi blocks through the [`@astrojs/react`](https://docs.astro.build/en/guides/integrations-guide/react/) integration — but that pulls React into your build for what is purely presentational content.
|
|
41
|
+
|
|
42
|
+
This package is a **native Astro renderer**. It renders Strapi v5 Blocks content — including every feature the [Better Blocks](https://github.com/k11k-labs/strapi-plugin-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)](#math-katex)).
|
|
43
|
+
|
|
44
|
+
It is a **drop-in renderer** that handles all Better Blocks features out of the box — no configuration needed.
|
|
45
|
+
|
|
46
|
+
## Compatibility
|
|
47
|
+
|
|
48
|
+
| Strapi Version | Renderer Version | Astro Version |
|
|
49
|
+
| -------------- | ---------------- | ------------- |
|
|
50
|
+
| v5.x | v0.x | ≥ 4 |
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Using yarn
|
|
56
|
+
yarn add @k11k/better-blocks-astro-renderer
|
|
57
|
+
|
|
58
|
+
# Using npm
|
|
59
|
+
npm install @k11k/better-blocks-astro-renderer
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**Peer dependencies:** `astro >= 4`
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```astro
|
|
67
|
+
---
|
|
68
|
+
import { BlocksRenderer } from '@k11k/better-blocks-astro-renderer';
|
|
69
|
+
|
|
70
|
+
const { blocks } = Astro.props;
|
|
71
|
+
---
|
|
72
|
+
|
|
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, and the component renders to static HTML (no hydration, no client directive).
|
|
77
|
+
|
|
78
|
+
A typical page that fetches from Strapi:
|
|
79
|
+
|
|
80
|
+
```astro
|
|
81
|
+
---
|
|
82
|
+
import { BlocksRenderer, type BlocksContent } from '@k11k/better-blocks-astro-renderer';
|
|
83
|
+
// Import the KaTeX stylesheet once (e.g. in a shared layout) so math displays correctly.
|
|
84
|
+
import 'katex/dist/katex.min.css';
|
|
85
|
+
|
|
86
|
+
const res = await fetch('https://your-strapi.example.com/api/articles?status=published');
|
|
87
|
+
const { data } = await res.json();
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
data.map((article: { content: BlocksContent }) => (
|
|
92
|
+
<article>
|
|
93
|
+
<BlocksRenderer content={article.content} />
|
|
94
|
+
</article>
|
|
95
|
+
))
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Math (KaTeX)
|
|
100
|
+
|
|
101
|
+
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` on the server, so it works during SSR and static builds with **no client-side hydration step**.
|
|
102
|
+
|
|
103
|
+
KaTeX needs its stylesheet to display correctly. Import it **once** in your app (for example in a shared layout):
|
|
104
|
+
|
|
105
|
+
```astro
|
|
106
|
+
---
|
|
107
|
+
import 'katex/dist/katex.min.css';
|
|
108
|
+
---
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`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.
|
|
112
|
+
|
|
113
|
+
## Supported Blocks
|
|
114
|
+
|
|
115
|
+
| Block | Default element | Source |
|
|
116
|
+
| ------------------------------- | ------------------- | --------------------------- |
|
|
117
|
+
| `paragraph` | `<p>` | Strapi core |
|
|
118
|
+
| `heading` (1–6) | `<h1>`–`<h6>` | Strapi core |
|
|
119
|
+
| `list` (ordered/unordered/todo) | `<ol>` / `<ul>` | Strapi core + Better Blocks |
|
|
120
|
+
| `list-item` | `<li>` | Strapi core |
|
|
121
|
+
| `link` | `<a>` | Strapi core |
|
|
122
|
+
| `quote` | `<blockquote>` | Strapi core |
|
|
123
|
+
| `code` | `<pre><code>` | Strapi core |
|
|
124
|
+
| `image` | `<figure><img>` | Strapi core |
|
|
125
|
+
| `horizontal-line` | `<hr>` | Better Blocks |
|
|
126
|
+
| `table` | `<table>` | Better Blocks |
|
|
127
|
+
| `media-embed` | `<iframe>` (16:9) | Better Blocks |
|
|
128
|
+
| `math` (inline/block) | `<span>` / `<div>` | Better Blocks |
|
|
129
|
+
|
|
130
|
+
### Block properties
|
|
131
|
+
|
|
132
|
+
| Property | Applies to | Description |
|
|
133
|
+
| ------------- | ------------------------- | ----------------------------------------------------- |
|
|
134
|
+
| `textAlign` | paragraph, heading, quote | Text alignment (`left`, `center`, `right`, `justify`) |
|
|
135
|
+
| `lineHeight` | paragraph, heading, quote | CSS line-height value (e.g. `1.5`, `2.0`) |
|
|
136
|
+
| `indent` | paragraph, heading, quote | Block indentation level (`marginLeft: N * 2rem`) |
|
|
137
|
+
| `indentLevel` | list | Cycling list-style-type per nesting depth |
|
|
138
|
+
| `format` | list | `ordered`, `unordered`, or `todo` |
|
|
139
|
+
| `checked` | list-item (in todo lists) | Checkbox state (`true`/`false`) |
|
|
140
|
+
| `target` | link | `_blank` for new-tab links |
|
|
141
|
+
| `rel` | link | `noopener noreferrer` for new-tab links |
|
|
142
|
+
| `caption` | image | Text displayed below the image |
|
|
143
|
+
| `imageAlign` | image | Image alignment (`left`, `center`, `right`) |
|
|
144
|
+
| `url` | media-embed | Embed URL (YouTube/Vimeo iframe src) |
|
|
145
|
+
| `originalUrl` | media-embed | Original user-provided URL |
|
|
146
|
+
| `format` | math | `inline` (`<span>`) or `block` (`<div>`) |
|
|
147
|
+
| `value` | math | LaTeX source rendered with KaTeX |
|
|
148
|
+
|
|
149
|
+
## Supported Modifiers
|
|
150
|
+
|
|
151
|
+
| Modifier | Default element | Source |
|
|
152
|
+
| ----------------- | --------------------------------- | ------------- |
|
|
153
|
+
| `bold` | `<strong>` | Strapi core |
|
|
154
|
+
| `italic` | `<em>` | Strapi core |
|
|
155
|
+
| `underline` | `<span>` | Strapi core |
|
|
156
|
+
| `strikethrough` | `<del>` | Strapi core |
|
|
157
|
+
| `code` | `<code>` | Strapi core |
|
|
158
|
+
| `uppercase` | `<span style="text-transform">` | Better Blocks |
|
|
159
|
+
| `superscript` | `<sup>` | Better Blocks |
|
|
160
|
+
| `subscript` | `<sub>` | Better Blocks |
|
|
161
|
+
| `color` | `<span style="color">` | Better Blocks |
|
|
162
|
+
| `backgroundColor` | `<span style="background-color">` | Better Blocks |
|
|
163
|
+
| `fontFamily` | `<span style="font-family">` | Better Blocks |
|
|
164
|
+
| `fontSize` | `<span style="font-size">` | Better Blocks |
|
|
165
|
+
|
|
166
|
+
## Custom Renderers
|
|
167
|
+
|
|
168
|
+
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 />`.
|
|
169
|
+
|
|
170
|
+
### Custom block renderers
|
|
171
|
+
|
|
172
|
+
```astro
|
|
173
|
+
---
|
|
174
|
+
import { BlocksRenderer } from '@k11k/better-blocks-astro-renderer';
|
|
175
|
+
import MyParagraph from '../components/MyParagraph.astro';
|
|
176
|
+
import MyImage from '../components/MyImage.astro';
|
|
177
|
+
import MyTable from '../components/MyTable.astro';
|
|
178
|
+
|
|
179
|
+
const { blocks } = Astro.props;
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
<BlocksRenderer
|
|
183
|
+
content={blocks}
|
|
184
|
+
blocks={{
|
|
185
|
+
paragraph: MyParagraph,
|
|
186
|
+
image: MyImage,
|
|
187
|
+
table: MyTable,
|
|
188
|
+
}}
|
|
189
|
+
/>
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```astro
|
|
193
|
+
---
|
|
194
|
+
// src/components/MyImage.astro
|
|
195
|
+
const { image, caption, imageAlign } = Astro.props;
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
<figure style={{ textAlign: imageAlign }}>
|
|
199
|
+
<img src={image.url} alt={image.alternativeText || ''} loading="lazy" />
|
|
200
|
+
{caption && <figcaption>{caption}</figcaption>}
|
|
201
|
+
</figure>
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The props each custom block component receives:
|
|
205
|
+
|
|
206
|
+
| Block | Props (plus `<slot />` for children where applicable) |
|
|
207
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------- |
|
|
208
|
+
| `paragraph` | `{ style?}` |
|
|
209
|
+
| `heading` | `{ level: 1–6; style? }` |
|
|
210
|
+
| `list` | `{ format: 'ordered' \| 'unordered' \| 'todo'; indentLevel }` |
|
|
211
|
+
| `list-item` | `{ checked? }` |
|
|
212
|
+
| `link` | `{ url; target?; rel? }` |
|
|
213
|
+
| `quote` | `{ style? }` |
|
|
214
|
+
| `code` | `{ plainText }` (also via `<slot />`) |
|
|
215
|
+
| `image` | `{ image; caption?; imageAlign? }` (no slot) |
|
|
216
|
+
| `horizontal-line` | _none_ |
|
|
217
|
+
| `table` / `table-row` / `table-cell` / `table-header-cell` | children via `<slot />` |
|
|
218
|
+
| `media-embed` | `{ url; originalUrl? }` (no slot) |
|
|
219
|
+
| `math` | `{ formula; inline }` (no slot) — bring your own math engine |
|
|
220
|
+
|
|
221
|
+
### Custom modifier renderers
|
|
222
|
+
|
|
223
|
+
```astro
|
|
224
|
+
---
|
|
225
|
+
import { BlocksRenderer } from '@k11k/better-blocks-astro-renderer';
|
|
226
|
+
import Highlight from '../components/Highlight.astro';
|
|
227
|
+
|
|
228
|
+
const { blocks } = Astro.props;
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
<BlocksRenderer content={blocks} modifiers={{ backgroundColor: Highlight }} />
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
```astro
|
|
235
|
+
---
|
|
236
|
+
// src/components/Highlight.astro
|
|
237
|
+
const { backgroundColor } = Astro.props;
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
<mark style={{ backgroundColor }}><slot /></mark>
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The color/size/font modifiers receive a value prop (`color`, `backgroundColor`, `fontFamily`, `fontSize`); the rest receive only their `<slot />`.
|
|
244
|
+
|
|
245
|
+
## TypeScript
|
|
246
|
+
|
|
247
|
+
All types are exported:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import type {
|
|
251
|
+
BlocksContent,
|
|
252
|
+
BlocksRendererProps,
|
|
253
|
+
BlockNode,
|
|
254
|
+
TextNode,
|
|
255
|
+
LinkNode,
|
|
256
|
+
ListNode,
|
|
257
|
+
ListItemNode,
|
|
258
|
+
ParagraphNode,
|
|
259
|
+
HeadingNode,
|
|
260
|
+
QuoteNode,
|
|
261
|
+
CodeNode,
|
|
262
|
+
ImageNode,
|
|
263
|
+
HorizontalLineNode,
|
|
264
|
+
TableNode,
|
|
265
|
+
TableRowNode,
|
|
266
|
+
TableCellNode,
|
|
267
|
+
TableHeaderCellNode,
|
|
268
|
+
MediaEmbedNode,
|
|
269
|
+
MathNode,
|
|
270
|
+
TextAlign,
|
|
271
|
+
CustomBlocksConfig,
|
|
272
|
+
CustomModifiersConfig,
|
|
273
|
+
} from '@k11k/better-blocks-astro-renderer';
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Contributing
|
|
277
|
+
|
|
278
|
+
Contributions are welcome! The easiest way to get started is with Docker:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# Clone the repository
|
|
282
|
+
git clone https://github.com/k11k-labs/better-blocks-astro-renderer.git
|
|
283
|
+
cd better-blocks-astro-renderer
|
|
284
|
+
|
|
285
|
+
# Start the playground with Docker
|
|
286
|
+
cd playground
|
|
287
|
+
docker compose up
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
This will start a Strapi v5 instance with the Better Blocks plugin and an Astro app that renders the content — all pre-configured with a showcase article.
|
|
291
|
+
|
|
292
|
+
- **Strapi admin:** http://localhost:1337/admin (login: `admin@example.com` / `admin12#`)
|
|
293
|
+
- **Astro app:** http://localhost:4321
|
|
294
|
+
|
|
295
|
+
### Development workflow
|
|
296
|
+
|
|
297
|
+
1. Edit the `.astro` components in `src/`
|
|
298
|
+
2. The Astro app picks up the change automatically — there is no build step
|
|
299
|
+
|
|
300
|
+
### Without Docker
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
# Install dependencies (no build step — the renderer ships .astro source)
|
|
304
|
+
yarn install
|
|
305
|
+
|
|
306
|
+
# Start Strapi
|
|
307
|
+
cd playground/strapi && cp .env.example .env && npm install && npm run dev
|
|
308
|
+
|
|
309
|
+
# Start the Astro app (in another terminal)
|
|
310
|
+
cd playground/astro-app && npm install && npm run dev
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Running tests
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
yarn test # Run tests (Astro container API + Vitest)
|
|
317
|
+
yarn test:ts # Type check (astro check)
|
|
318
|
+
yarn lint # Check formatting
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Community & Support
|
|
322
|
+
|
|
323
|
+
- [GitHub Issues](https://github.com/k11k-labs/better-blocks-astro-renderer/issues) — Bug reports and feature requests
|
|
324
|
+
|
|
325
|
+
## Related
|
|
326
|
+
|
|
327
|
+
- [@k11k/better-blocks-react-renderer](https://github.com/k11k-labs/better-blocks-react-renderer) — React renderer with the same Better Blocks support
|
|
328
|
+
- [@k11k/strapi-plugin-better-blocks](https://github.com/k11k-labs/strapi-plugin-better-blocks) — Strapi plugin that extends the Blocks editor with colors, tables, to-do lists, media embeds, and more
|
|
329
|
+
|
|
330
|
+
## License
|
|
331
|
+
|
|
332
|
+
[MIT License](LICENSE) © [k11k-labs](https://github.com/k11k-labs)
|
package/env.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="astro/client" />
|
package/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { default as BlocksRenderer } from './src/BlocksRenderer.astro';
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
BlocksContent,
|
|
5
|
+
BlocksRendererProps,
|
|
6
|
+
BlockNode,
|
|
7
|
+
TextNode,
|
|
8
|
+
LinkNode,
|
|
9
|
+
InlineNode,
|
|
10
|
+
ParagraphNode,
|
|
11
|
+
HeadingNode,
|
|
12
|
+
ListNode,
|
|
13
|
+
ListItemNode,
|
|
14
|
+
QuoteNode,
|
|
15
|
+
CodeNode,
|
|
16
|
+
ImageNode,
|
|
17
|
+
HorizontalLineNode,
|
|
18
|
+
TableNode,
|
|
19
|
+
TableRowNode,
|
|
20
|
+
TableCellNode,
|
|
21
|
+
TableHeaderCellNode,
|
|
22
|
+
MediaEmbedNode,
|
|
23
|
+
MathNode,
|
|
24
|
+
TextAlign,
|
|
25
|
+
StyleValue,
|
|
26
|
+
CustomBlocksConfig,
|
|
27
|
+
CustomModifiersConfig,
|
|
28
|
+
AstroComponentFactory,
|
|
29
|
+
} from './src/types';
|
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@k11k/better-blocks-astro-renderer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"types": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./index.ts",
|
|
10
|
+
"default": "./index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./BlocksRenderer.astro": "./src/BlocksRenderer.astro",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"index.ts",
|
|
18
|
+
"env.d.ts"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "astro check",
|
|
22
|
+
"lint": "prettier --check .",
|
|
23
|
+
"format": "prettier --write .",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"test:ts": "astro check",
|
|
27
|
+
"prepare": "husky || true"
|
|
28
|
+
},
|
|
29
|
+
"lint-staged": {
|
|
30
|
+
"*.{js,jsx,ts,tsx,astro,json,md,yml,yaml,css}": "prettier --write"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"katex": "^0.16.11"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"astro": ">=4.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@astrojs/check": "^0.9.4",
|
|
40
|
+
"@changesets/changelog-github": "^0.6.0",
|
|
41
|
+
"@changesets/cli": "^2.30.0",
|
|
42
|
+
"@types/katex": "^0.16.7",
|
|
43
|
+
"astro": "^5.0.0",
|
|
44
|
+
"husky": "^9.1.7",
|
|
45
|
+
"linkedom": "^0.18.12",
|
|
46
|
+
"lint-staged": "^16.3.3",
|
|
47
|
+
"prettier": "^3.4.2",
|
|
48
|
+
"prettier-plugin-astro": "^0.14.1",
|
|
49
|
+
"typescript": "^5.7.2",
|
|
50
|
+
"vitest": "^3.0.0"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"strapi",
|
|
54
|
+
"strapi-v5",
|
|
55
|
+
"blocks",
|
|
56
|
+
"blocks-renderer",
|
|
57
|
+
"astro",
|
|
58
|
+
"renderer",
|
|
59
|
+
"rich-text",
|
|
60
|
+
"better-blocks",
|
|
61
|
+
"color",
|
|
62
|
+
"highlight",
|
|
63
|
+
"text-color",
|
|
64
|
+
"background-color",
|
|
65
|
+
"katex",
|
|
66
|
+
"latex",
|
|
67
|
+
"math",
|
|
68
|
+
"cms",
|
|
69
|
+
"headless-cms"
|
|
70
|
+
],
|
|
71
|
+
"license": "MIT",
|
|
72
|
+
"author": "k11k-labs",
|
|
73
|
+
"repository": {
|
|
74
|
+
"type": "git",
|
|
75
|
+
"url": "https://github.com/k11k-labs/better-blocks-astro-renderer.git"
|
|
76
|
+
},
|
|
77
|
+
"bugs": {
|
|
78
|
+
"url": "https://github.com/k11k-labs/better-blocks-astro-renderer/issues"
|
|
79
|
+
},
|
|
80
|
+
"homepage": "https://github.com/k11k-labs/better-blocks-astro-renderer#readme",
|
|
81
|
+
"engines": {
|
|
82
|
+
"node": ">=20.0.0 <=24.x.x",
|
|
83
|
+
"npm": ">=6.0.0"
|
|
84
|
+
},
|
|
85
|
+
"packageManager": "yarn@1.22.22"
|
|
86
|
+
}
|
package/src/Block.astro
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { BlockNode, CustomBlocksConfig, CustomModifiersConfig } from './types';
|
|
3
|
+
import Inline from './Inline.astro';
|
|
4
|
+
import List from './List.astro';
|
|
5
|
+
import Table from './Table.astro';
|
|
6
|
+
import Math from './Math.astro';
|
|
7
|
+
import { getBlockStyle, getPlainText } from './utils';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
block: BlockNode;
|
|
11
|
+
blocks?: CustomBlocksConfig;
|
|
12
|
+
modifiers?: CustomModifiersConfig;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { block, blocks, modifiers } = Astro.props;
|
|
16
|
+
|
|
17
|
+
const ParagraphComp = blocks?.paragraph as any;
|
|
18
|
+
const HeadingComp = blocks?.heading as any;
|
|
19
|
+
const QuoteComp = blocks?.quote as any;
|
|
20
|
+
const CodeComp = blocks?.code as any;
|
|
21
|
+
const ImageComp = blocks?.image as any;
|
|
22
|
+
const HrComp = blocks?.['horizontal-line'] as any;
|
|
23
|
+
const EmbedComp = blocks?.['media-embed'] as any;
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
{
|
|
27
|
+
block.type === 'paragraph' && (
|
|
28
|
+
<Fragment>
|
|
29
|
+
{(() => {
|
|
30
|
+
const style = getBlockStyle(block);
|
|
31
|
+
return ParagraphComp ? (
|
|
32
|
+
<ParagraphComp style={style}>
|
|
33
|
+
<Inline nodes={block.children} blocks={blocks} modifiers={modifiers} />
|
|
34
|
+
</ParagraphComp>
|
|
35
|
+
) : (
|
|
36
|
+
<p style={style}>
|
|
37
|
+
<Inline nodes={block.children} blocks={blocks} modifiers={modifiers} />
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
})()}
|
|
41
|
+
</Fragment>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
block.type === 'heading' && (
|
|
47
|
+
<Fragment>
|
|
48
|
+
{(() => {
|
|
49
|
+
const style = getBlockStyle(block);
|
|
50
|
+
const HeadingTag = `h${block.level}` as any;
|
|
51
|
+
return HeadingComp ? (
|
|
52
|
+
<HeadingComp level={block.level} style={style}>
|
|
53
|
+
<Inline nodes={block.children} blocks={blocks} modifiers={modifiers} />
|
|
54
|
+
</HeadingComp>
|
|
55
|
+
) : (
|
|
56
|
+
<HeadingTag style={style}>
|
|
57
|
+
<Inline nodes={block.children} blocks={blocks} modifiers={modifiers} />
|
|
58
|
+
</HeadingTag>
|
|
59
|
+
);
|
|
60
|
+
})()}
|
|
61
|
+
</Fragment>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
{block.type === 'list' && <List node={block} blocks={blocks} modifiers={modifiers} />}
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
block.type === 'quote' && (
|
|
69
|
+
<Fragment>
|
|
70
|
+
{(() => {
|
|
71
|
+
const style = getBlockStyle(block);
|
|
72
|
+
return QuoteComp ? (
|
|
73
|
+
<QuoteComp style={style}>
|
|
74
|
+
<Inline nodes={block.children} blocks={blocks} modifiers={modifiers} />
|
|
75
|
+
</QuoteComp>
|
|
76
|
+
) : (
|
|
77
|
+
<blockquote style={style}>
|
|
78
|
+
<Inline nodes={block.children} blocks={blocks} modifiers={modifiers} />
|
|
79
|
+
</blockquote>
|
|
80
|
+
);
|
|
81
|
+
})()}
|
|
82
|
+
</Fragment>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
block.type === 'code' && (
|
|
88
|
+
<Fragment>
|
|
89
|
+
{(() => {
|
|
90
|
+
const plainText = getPlainText(block.children);
|
|
91
|
+
return CodeComp ? (
|
|
92
|
+
<CodeComp plainText={plainText}>{plainText}</CodeComp>
|
|
93
|
+
) : (
|
|
94
|
+
<pre>
|
|
95
|
+
<code>{plainText}</code>
|
|
96
|
+
</pre>
|
|
97
|
+
);
|
|
98
|
+
})()}
|
|
99
|
+
</Fragment>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
block.type === 'image' && (
|
|
105
|
+
<Fragment>
|
|
106
|
+
{(() => {
|
|
107
|
+
const align = block.imageAlign || 'center';
|
|
108
|
+
return ImageComp ? (
|
|
109
|
+
<ImageComp image={block.image} caption={block.caption} imageAlign={block.imageAlign} />
|
|
110
|
+
) : (
|
|
111
|
+
<figure style={{ textAlign: align }}>
|
|
112
|
+
<img
|
|
113
|
+
src={block.image.url}
|
|
114
|
+
alt={block.image.alternativeText || ''}
|
|
115
|
+
width={block.image.width}
|
|
116
|
+
height={block.image.height}
|
|
117
|
+
/>
|
|
118
|
+
{block.caption && <figcaption>{block.caption}</figcaption>}
|
|
119
|
+
</figure>
|
|
120
|
+
);
|
|
121
|
+
})()}
|
|
122
|
+
</Fragment>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
{block.type === 'horizontal-line' && <Fragment>{HrComp ? <HrComp /> : <hr />}</Fragment>}
|
|
127
|
+
|
|
128
|
+
{block.type === 'table' && <Table node={block} blocks={blocks} modifiers={modifiers} />}
|
|
129
|
+
|
|
130
|
+
{
|
|
131
|
+
block.type === 'media-embed' && (
|
|
132
|
+
<Fragment>
|
|
133
|
+
{EmbedComp ? (
|
|
134
|
+
<EmbedComp url={block.url} originalUrl={block.originalUrl} />
|
|
135
|
+
) : (
|
|
136
|
+
<div style={{ position: 'relative', paddingBottom: '56.25%', height: '0' }}>
|
|
137
|
+
<iframe
|
|
138
|
+
src={block.url}
|
|
139
|
+
style={{
|
|
140
|
+
position: 'absolute',
|
|
141
|
+
top: '0',
|
|
142
|
+
left: '0',
|
|
143
|
+
width: '100%',
|
|
144
|
+
height: '100%',
|
|
145
|
+
border: '0',
|
|
146
|
+
}}
|
|
147
|
+
allowfullscreen
|
|
148
|
+
title="Embedded media"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</Fragment>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
{block.type === 'math' && <Math node={block} blocks={blocks} />}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { BlocksRendererProps } from './types';
|
|
3
|
+
import Block from './Block.astro';
|
|
4
|
+
|
|
5
|
+
type Props = BlocksRendererProps;
|
|
6
|
+
|
|
7
|
+
const { content, blocks, modifiers } = Astro.props;
|
|
8
|
+
const isValid = Array.isArray(content) && content.length > 0;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
{isValid && content.map((block) => <Block block={block} blocks={blocks} modifiers={modifiers} />)}
|
package/src/Inline.astro
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CustomBlocksConfig, CustomModifiersConfig, InlineNode } from './types';
|
|
3
|
+
import Text from './Text.astro';
|
|
4
|
+
import Math from './Math.astro';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
nodes: InlineNode[];
|
|
8
|
+
blocks?: CustomBlocksConfig;
|
|
9
|
+
modifiers?: CustomModifiersConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { nodes, blocks, modifiers } = Astro.props;
|
|
13
|
+
const LinkComp = blocks?.link as any;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
nodes.map((child) => {
|
|
18
|
+
if (child.type === 'text') {
|
|
19
|
+
return <Text node={child} modifiers={modifiers} />;
|
|
20
|
+
}
|
|
21
|
+
if (child.type === 'math') {
|
|
22
|
+
return <Math node={child} blocks={blocks} />;
|
|
23
|
+
}
|
|
24
|
+
if (child.type === 'link') {
|
|
25
|
+
return LinkComp ? (
|
|
26
|
+
<LinkComp url={child.url} target={child.target} rel={child.rel}>
|
|
27
|
+
{child.children.map((textNode) => (
|
|
28
|
+
<Text node={textNode} modifiers={modifiers} />
|
|
29
|
+
))}
|
|
30
|
+
</LinkComp>
|
|
31
|
+
) : (
|
|
32
|
+
<a href={child.url} target={child.target} rel={child.rel}>
|
|
33
|
+
{child.children.map((textNode) => (
|
|
34
|
+
<Text node={textNode} modifiers={modifiers} />
|
|
35
|
+
))}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
})
|
|
41
|
+
}
|
package/src/List.astro
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CustomBlocksConfig, CustomModifiersConfig, ListNode, StyleValue } from './types';
|
|
3
|
+
import ListItem from './ListItem.astro';
|
|
4
|
+
import { getListStyleType } from './utils';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
node: ListNode;
|
|
8
|
+
blocks?: CustomBlocksConfig;
|
|
9
|
+
modifiers?: CustomModifiersConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { node, blocks, modifiers } = Astro.props;
|
|
13
|
+
const indentLevel = node.indentLevel || 0;
|
|
14
|
+
const isTodo = node.format === 'todo';
|
|
15
|
+
const ListComp = blocks?.list as any;
|
|
16
|
+
|
|
17
|
+
// Pick the wrapper element + props; the children map is identical across all
|
|
18
|
+
// cases, so render it once as slot content.
|
|
19
|
+
let Wrapper: unknown;
|
|
20
|
+
let wrapperProps: Record<string, unknown> = {};
|
|
21
|
+
if (ListComp) {
|
|
22
|
+
Wrapper = ListComp;
|
|
23
|
+
wrapperProps = { format: node.format, indentLevel };
|
|
24
|
+
} else if (isTodo) {
|
|
25
|
+
Wrapper = 'ul';
|
|
26
|
+
const style: StyleValue = {
|
|
27
|
+
listStyle: 'none',
|
|
28
|
+
paddingLeft: indentLevel > 0 ? '1.5em' : '0',
|
|
29
|
+
};
|
|
30
|
+
wrapperProps = { style };
|
|
31
|
+
} else {
|
|
32
|
+
Wrapper = node.format === 'ordered' ? 'ol' : 'ul';
|
|
33
|
+
const listStyleType = getListStyleType(node.format as 'ordered' | 'unordered', indentLevel);
|
|
34
|
+
wrapperProps = { style: { listStyleType } satisfies StyleValue };
|
|
35
|
+
}
|
|
36
|
+
const Tag = Wrapper as any;
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<Tag {...wrapperProps}>
|
|
40
|
+
{
|
|
41
|
+
node.children.map((child) =>
|
|
42
|
+
child.type === 'list-item' ? (
|
|
43
|
+
<ListItem node={child} isTodo={isTodo} blocks={blocks} modifiers={modifiers} />
|
|
44
|
+
) : (
|
|
45
|
+
<Astro.self node={child} blocks={blocks} modifiers={modifiers} />
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
</Tag>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CustomBlocksConfig, CustomModifiersConfig, ListItemNode } from './types';
|
|
3
|
+
import Inline from './Inline.astro';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: ListItemNode;
|
|
7
|
+
isTodo: boolean;
|
|
8
|
+
blocks?: CustomBlocksConfig;
|
|
9
|
+
modifiers?: CustomModifiersConfig;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { node, isTodo, blocks, modifiers } = Astro.props;
|
|
13
|
+
const ListItemComp = blocks?.['list-item'] as any;
|
|
14
|
+
const checked = node.checked ?? false;
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
ListItemComp ? (
|
|
19
|
+
<ListItemComp checked={isTodo ? node.checked : undefined}>
|
|
20
|
+
<Inline nodes={node.children} blocks={blocks} modifiers={modifiers} />
|
|
21
|
+
</ListItemComp>
|
|
22
|
+
) : isTodo ? (
|
|
23
|
+
<li style={{ listStyle: 'none' }}>
|
|
24
|
+
<input type="checkbox" checked={checked} readonly style={{ marginRight: '0.5em' }} />
|
|
25
|
+
<span style={checked ? { textDecoration: 'line-through', opacity: '0.6' } : undefined}>
|
|
26
|
+
<Inline nodes={node.children} blocks={blocks} modifiers={modifiers} />
|
|
27
|
+
</span>
|
|
28
|
+
</li>
|
|
29
|
+
) : (
|
|
30
|
+
<li>
|
|
31
|
+
<Inline nodes={node.children} blocks={blocks} modifiers={modifiers} />
|
|
32
|
+
</li>
|
|
33
|
+
)
|
|
34
|
+
}
|
package/src/Math.astro
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
import katex from 'katex';
|
|
3
|
+
import type { CustomBlocksConfig, MathNode } from './types';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: MathNode;
|
|
7
|
+
blocks?: CustomBlocksConfig;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { node, blocks } = Astro.props;
|
|
11
|
+
|
|
12
|
+
const isBlock = node.format === 'block';
|
|
13
|
+
const formula = node.value ?? '';
|
|
14
|
+
const MathComp = blocks?.math;
|
|
15
|
+
|
|
16
|
+
const Tag = isBlock ? 'div' : 'span';
|
|
17
|
+
const className = isBlock ? 'katex-block' : 'katex-inline';
|
|
18
|
+
|
|
19
|
+
// KaTeX renders to an HTML string on the server (no client hydration needed).
|
|
20
|
+
// With `throwOnError: false` it renders parse errors inline instead of throwing;
|
|
21
|
+
// the try/catch is a last-resort guard that falls back to the raw LaTeX source.
|
|
22
|
+
let html: string | null = null;
|
|
23
|
+
if (!MathComp) {
|
|
24
|
+
try {
|
|
25
|
+
html = katex.renderToString(formula, { displayMode: isBlock, throwOnError: false });
|
|
26
|
+
} catch {
|
|
27
|
+
html = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const Wrapper = Tag as any;
|
|
31
|
+
const Custom = MathComp as any;
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
Custom ? (
|
|
36
|
+
<Custom formula={formula} inline={!isBlock} />
|
|
37
|
+
) : html !== null ? (
|
|
38
|
+
<Wrapper class={className} set:html={html} />
|
|
39
|
+
) : (
|
|
40
|
+
<Wrapper class={className}>{formula}</Wrapper>
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/Table.astro
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CustomBlocksConfig, CustomModifiersConfig, TableNode } from './types';
|
|
3
|
+
import Inline from './Inline.astro';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: TableNode;
|
|
7
|
+
blocks?: CustomBlocksConfig;
|
|
8
|
+
modifiers?: CustomModifiersConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { node, blocks, modifiers } = Astro.props;
|
|
12
|
+
const TableComp = blocks?.table as any;
|
|
13
|
+
const RowComp = blocks?.['table-row'] as any;
|
|
14
|
+
const CellComp = blocks?.['table-cell'] as any;
|
|
15
|
+
const HeaderCellComp = blocks?.['table-header-cell'] as any;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
(() => {
|
|
20
|
+
const rows = node.children.map((row) => {
|
|
21
|
+
const cells = row.children.map((cell) => {
|
|
22
|
+
const isHeader = cell.type === 'table-header-cell';
|
|
23
|
+
const content = <Inline nodes={cell.children} blocks={blocks} modifiers={modifiers} />;
|
|
24
|
+
|
|
25
|
+
if (isHeader && HeaderCellComp) {
|
|
26
|
+
return <HeaderCellComp>{content}</HeaderCellComp>;
|
|
27
|
+
}
|
|
28
|
+
if (!isHeader && CellComp) {
|
|
29
|
+
return <CellComp>{content}</CellComp>;
|
|
30
|
+
}
|
|
31
|
+
const CellTag = isHeader ? 'th' : 'td';
|
|
32
|
+
return <CellTag>{content}</CellTag>;
|
|
33
|
+
});
|
|
34
|
+
return RowComp ? <RowComp>{cells}</RowComp> : <tr>{cells}</tr>;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return TableComp ? (
|
|
38
|
+
<TableComp>{rows}</TableComp>
|
|
39
|
+
) : (
|
|
40
|
+
<table>
|
|
41
|
+
<tbody>{rows}</tbody>
|
|
42
|
+
</table>
|
|
43
|
+
);
|
|
44
|
+
})()
|
|
45
|
+
}
|
package/src/Text.astro
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { CustomModifiersConfig, TextNode } from './types';
|
|
3
|
+
import { buildTextMarks, getDefaultMarkRender, getModifierProps, type Mark } from './utils';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: TextNode;
|
|
7
|
+
modifiers?: CustomModifiersConfig;
|
|
8
|
+
/** Remaining marks to apply, outer → inner. Built from `node` on first call. */
|
|
9
|
+
marks?: Mark[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const { node, modifiers, marks } = Astro.props;
|
|
13
|
+
const activeMarks = marks ?? buildTextMarks(node);
|
|
14
|
+
const hasMarks = activeMarks.length > 0;
|
|
15
|
+
|
|
16
|
+
const [current, ...rest] = activeMarks;
|
|
17
|
+
|
|
18
|
+
// Choose the wrapper for the outermost remaining mark: a custom modifier
|
|
19
|
+
// component if one is configured, otherwise the default HTML element. The inner
|
|
20
|
+
// content recurses through the remaining marks via `Astro.self`.
|
|
21
|
+
let Tag: unknown = null;
|
|
22
|
+
let tagProps: Record<string, unknown> = {};
|
|
23
|
+
if (hasMarks) {
|
|
24
|
+
const Custom = modifiers?.[current.name as keyof CustomModifiersConfig];
|
|
25
|
+
if (Custom) {
|
|
26
|
+
Tag = Custom;
|
|
27
|
+
tagProps = getModifierProps(current);
|
|
28
|
+
} else {
|
|
29
|
+
const { tag, style } = getDefaultMarkRender(current);
|
|
30
|
+
Tag = tag;
|
|
31
|
+
tagProps = style ? { style } : {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const Wrapper = Tag as any;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
hasMarks ? (
|
|
39
|
+
<Wrapper {...tagProps}>
|
|
40
|
+
<Astro.self node={node} modifiers={modifiers} marks={rest} />
|
|
41
|
+
</Wrapper>
|
|
42
|
+
) : (
|
|
43
|
+
node.text
|
|
44
|
+
)
|
|
45
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// ── Text & Inline Nodes ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export type TextNode = {
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
bold?: boolean;
|
|
7
|
+
italic?: boolean;
|
|
8
|
+
underline?: boolean;
|
|
9
|
+
strikethrough?: boolean;
|
|
10
|
+
code?: boolean;
|
|
11
|
+
uppercase?: boolean;
|
|
12
|
+
superscript?: boolean;
|
|
13
|
+
subscript?: boolean;
|
|
14
|
+
color?: string;
|
|
15
|
+
backgroundColor?: string;
|
|
16
|
+
fontFamily?: string;
|
|
17
|
+
fontSize?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type LinkNode = {
|
|
21
|
+
type: 'link';
|
|
22
|
+
url: string;
|
|
23
|
+
target?: '_blank' | '_self';
|
|
24
|
+
rel?: string;
|
|
25
|
+
children: TextNode[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MathNode = {
|
|
29
|
+
type: 'math';
|
|
30
|
+
format: 'inline' | 'block';
|
|
31
|
+
value: string;
|
|
32
|
+
children: [{ type: 'text'; text: '' }];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type InlineNode = TextNode | LinkNode | MathNode;
|
|
36
|
+
|
|
37
|
+
// ── Text Alignment ──────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export type TextAlign = 'left' | 'center' | 'right' | 'justify';
|
|
40
|
+
|
|
41
|
+
// ── Block Nodes ──────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export type ListItemNode = {
|
|
44
|
+
type: 'list-item';
|
|
45
|
+
checked?: boolean;
|
|
46
|
+
children: InlineNode[];
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type ParagraphNode = {
|
|
50
|
+
type: 'paragraph';
|
|
51
|
+
textAlign?: TextAlign;
|
|
52
|
+
lineHeight?: string;
|
|
53
|
+
indent?: number;
|
|
54
|
+
children: InlineNode[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type HeadingNode = {
|
|
58
|
+
type: 'heading';
|
|
59
|
+
level: 1 | 2 | 3 | 4 | 5 | 6;
|
|
60
|
+
textAlign?: TextAlign;
|
|
61
|
+
lineHeight?: string;
|
|
62
|
+
indent?: number;
|
|
63
|
+
children: InlineNode[];
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type ListNode = {
|
|
67
|
+
type: 'list';
|
|
68
|
+
format: 'ordered' | 'unordered' | 'todo';
|
|
69
|
+
indentLevel?: number;
|
|
70
|
+
children: (ListItemNode | ListNode)[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type QuoteNode = {
|
|
74
|
+
type: 'quote';
|
|
75
|
+
textAlign?: TextAlign;
|
|
76
|
+
lineHeight?: string;
|
|
77
|
+
indent?: number;
|
|
78
|
+
children: InlineNode[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type CodeNode = {
|
|
82
|
+
type: 'code';
|
|
83
|
+
children: InlineNode[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type ImageNode = {
|
|
87
|
+
type: 'image';
|
|
88
|
+
image: {
|
|
89
|
+
url: string;
|
|
90
|
+
alternativeText?: string | null;
|
|
91
|
+
width?: number;
|
|
92
|
+
height?: number;
|
|
93
|
+
};
|
|
94
|
+
caption?: string;
|
|
95
|
+
imageAlign?: 'left' | 'center' | 'right';
|
|
96
|
+
children: [{ type: 'text'; text: '' }];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type HorizontalLineNode = {
|
|
100
|
+
type: 'horizontal-line';
|
|
101
|
+
children: [{ type: 'text'; text: '' }];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type TableCellNode = {
|
|
105
|
+
type: 'table-cell';
|
|
106
|
+
children: InlineNode[];
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type TableHeaderCellNode = {
|
|
110
|
+
type: 'table-header-cell';
|
|
111
|
+
children: InlineNode[];
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type TableRowNode = {
|
|
115
|
+
type: 'table-row';
|
|
116
|
+
children: (TableCellNode | TableHeaderCellNode)[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type TableNode = {
|
|
120
|
+
type: 'table';
|
|
121
|
+
children: TableRowNode[];
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export type MediaEmbedNode = {
|
|
125
|
+
type: 'media-embed';
|
|
126
|
+
url: string;
|
|
127
|
+
originalUrl?: string;
|
|
128
|
+
children: [{ type: 'text'; text: '' }];
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type BlockNode =
|
|
132
|
+
| ParagraphNode
|
|
133
|
+
| HeadingNode
|
|
134
|
+
| ListNode
|
|
135
|
+
| QuoteNode
|
|
136
|
+
| CodeNode
|
|
137
|
+
| ImageNode
|
|
138
|
+
| HorizontalLineNode
|
|
139
|
+
| TableNode
|
|
140
|
+
| MediaEmbedNode
|
|
141
|
+
| MathNode;
|
|
142
|
+
|
|
143
|
+
export type BlocksContent = BlockNode[];
|
|
144
|
+
|
|
145
|
+
// ── Style ────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Inline style value accepted by Astro elements — either a CSS string or a
|
|
149
|
+
* record of property/value pairs (Astro serializes the object to a string).
|
|
150
|
+
*/
|
|
151
|
+
export type StyleValue = string | Record<string, string | number | undefined>;
|
|
152
|
+
|
|
153
|
+
// ── Custom Renderers Config ──────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Any Astro component — the default export of a `.astro` file (or any
|
|
157
|
+
* framework component Astro can render). Custom renderers receive their props
|
|
158
|
+
* via `Astro.props` and their inner content via the default `<slot />`.
|
|
159
|
+
*/
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
161
|
+
export type AstroComponentFactory = (...args: any[]) => any;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Map of block type → custom Astro component. Each component receives the props
|
|
165
|
+
* documented below plus, where applicable, its rendered children via `<slot />`.
|
|
166
|
+
*
|
|
167
|
+
* - `paragraph` — `{ style?: StyleValue }`
|
|
168
|
+
* - `heading` — `{ level: 1 | 2 | 3 | 4 | 5 | 6; style?: StyleValue }`
|
|
169
|
+
* - `list` — `{ format: 'ordered' | 'unordered' | 'todo'; indentLevel: number }`
|
|
170
|
+
* - `list-item` — `{ checked?: boolean }`
|
|
171
|
+
* - `link` — `{ url: string; target?: string; rel?: string }`
|
|
172
|
+
* - `quote` — `{ style?: StyleValue }`
|
|
173
|
+
* - `code` — `{ plainText: string }` (also available via `<slot />`)
|
|
174
|
+
* - `image` — `{ image; caption?: string; imageAlign?: 'left' | 'center' | 'right' }`
|
|
175
|
+
* - `horizontal-line` — no props
|
|
176
|
+
* - `table` / `table-row` / `table-cell` / `table-header-cell` — children via `<slot />`
|
|
177
|
+
* - `media-embed` — `{ url: string; originalUrl?: string }`
|
|
178
|
+
* - `math` — `{ formula: string; inline: boolean }`
|
|
179
|
+
*/
|
|
180
|
+
export type CustomBlocksConfig = Partial<{
|
|
181
|
+
paragraph: AstroComponentFactory;
|
|
182
|
+
heading: AstroComponentFactory;
|
|
183
|
+
list: AstroComponentFactory;
|
|
184
|
+
'list-item': AstroComponentFactory;
|
|
185
|
+
link: AstroComponentFactory;
|
|
186
|
+
quote: AstroComponentFactory;
|
|
187
|
+
code: AstroComponentFactory;
|
|
188
|
+
image: AstroComponentFactory;
|
|
189
|
+
'horizontal-line': AstroComponentFactory;
|
|
190
|
+
table: AstroComponentFactory;
|
|
191
|
+
'table-row': AstroComponentFactory;
|
|
192
|
+
'table-cell': AstroComponentFactory;
|
|
193
|
+
'table-header-cell': AstroComponentFactory;
|
|
194
|
+
'media-embed': AstroComponentFactory;
|
|
195
|
+
math: AstroComponentFactory;
|
|
196
|
+
}>;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Map of text modifier (mark) → custom Astro component. Each component receives
|
|
200
|
+
* its inner content via the default `<slot />`. The color/size/font modifiers
|
|
201
|
+
* additionally receive a value prop:
|
|
202
|
+
*
|
|
203
|
+
* - `color` — `{ color: string }`
|
|
204
|
+
* - `backgroundColor` — `{ backgroundColor: string }`
|
|
205
|
+
* - `fontFamily` — `{ fontFamily: string }`
|
|
206
|
+
* - `fontSize` — `{ fontSize: string }`
|
|
207
|
+
*/
|
|
208
|
+
export type CustomModifiersConfig = Partial<{
|
|
209
|
+
bold: AstroComponentFactory;
|
|
210
|
+
italic: AstroComponentFactory;
|
|
211
|
+
underline: AstroComponentFactory;
|
|
212
|
+
strikethrough: AstroComponentFactory;
|
|
213
|
+
code: AstroComponentFactory;
|
|
214
|
+
uppercase: AstroComponentFactory;
|
|
215
|
+
superscript: AstroComponentFactory;
|
|
216
|
+
subscript: AstroComponentFactory;
|
|
217
|
+
color: AstroComponentFactory;
|
|
218
|
+
backgroundColor: AstroComponentFactory;
|
|
219
|
+
fontFamily: AstroComponentFactory;
|
|
220
|
+
fontSize: AstroComponentFactory;
|
|
221
|
+
}>;
|
|
222
|
+
|
|
223
|
+
// ── Component Props ──────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export type BlocksRendererProps = {
|
|
226
|
+
content: BlocksContent;
|
|
227
|
+
blocks?: CustomBlocksConfig;
|
|
228
|
+
modifiers?: CustomModifiersConfig;
|
|
229
|
+
};
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { InlineNode, StyleValue, TextNode } from './types';
|
|
2
|
+
|
|
3
|
+
// ── Block Style ──────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the inline style object for a block that supports alignment,
|
|
7
|
+
* line-height, and indentation. Returns `undefined` when no style applies so
|
|
8
|
+
* the element renders without a `style` attribute.
|
|
9
|
+
*/
|
|
10
|
+
export function getBlockStyle(block: {
|
|
11
|
+
textAlign?: string;
|
|
12
|
+
lineHeight?: string;
|
|
13
|
+
indent?: number;
|
|
14
|
+
}): StyleValue | undefined {
|
|
15
|
+
const style: Record<string, string> = {};
|
|
16
|
+
if (block.textAlign) style.textAlign = block.textAlign;
|
|
17
|
+
if (block.lineHeight) style.lineHeight = block.lineHeight;
|
|
18
|
+
if (block.indent) style.marginLeft = `${block.indent * 2}rem`;
|
|
19
|
+
return Object.keys(style).length > 0 ? style : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Plain Text Extraction ────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export function getPlainText(children: InlineNode[]): string {
|
|
25
|
+
return children
|
|
26
|
+
.map((child) => {
|
|
27
|
+
if (child.type === 'text') return child.text;
|
|
28
|
+
if (child.type === 'link') return child.children.map((t) => t.text).join('');
|
|
29
|
+
return '';
|
|
30
|
+
})
|
|
31
|
+
.join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── List Style Cycling ───────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const orderedStyles = ['decimal', 'lower-alpha', 'upper-roman'];
|
|
37
|
+
const unorderedStyles = ['disc', 'circle', 'square'];
|
|
38
|
+
|
|
39
|
+
export function getListStyleType(format: 'ordered' | 'unordered', indentLevel: number): string {
|
|
40
|
+
const styles = format === 'ordered' ? orderedStyles : unorderedStyles;
|
|
41
|
+
return styles[indentLevel % styles.length];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Text Modifiers (Marks) ───────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export type Mark = { name: string; value?: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the active marks for a text node in outer → inner order. This mirrors
|
|
50
|
+
* the React renderer, which applies modifiers inside-out (`fontSize` ends up the
|
|
51
|
+
* outermost wrapper and `code` the innermost), so the visual nesting matches.
|
|
52
|
+
*/
|
|
53
|
+
export function buildTextMarks(node: TextNode): Mark[] {
|
|
54
|
+
const marks: Mark[] = [];
|
|
55
|
+
if (node.fontSize) marks.push({ name: 'fontSize', value: node.fontSize });
|
|
56
|
+
if (node.fontFamily) marks.push({ name: 'fontFamily', value: node.fontFamily });
|
|
57
|
+
if (node.backgroundColor) marks.push({ name: 'backgroundColor', value: node.backgroundColor });
|
|
58
|
+
if (node.color) marks.push({ name: 'color', value: node.color });
|
|
59
|
+
if (node.bold) marks.push({ name: 'bold' });
|
|
60
|
+
if (node.italic) marks.push({ name: 'italic' });
|
|
61
|
+
if (node.uppercase) marks.push({ name: 'uppercase' });
|
|
62
|
+
if (node.underline) marks.push({ name: 'underline' });
|
|
63
|
+
if (node.strikethrough) marks.push({ name: 'strikethrough' });
|
|
64
|
+
if (node.superscript) marks.push({ name: 'superscript' });
|
|
65
|
+
if (node.subscript) marks.push({ name: 'subscript' });
|
|
66
|
+
if (node.code) marks.push({ name: 'code' });
|
|
67
|
+
return marks;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The default HTML tag and inline style used to render a given mark when no
|
|
72
|
+
* custom modifier component is supplied.
|
|
73
|
+
*/
|
|
74
|
+
export function getDefaultMarkRender(mark: Mark): { tag: string; style?: StyleValue } {
|
|
75
|
+
switch (mark.name) {
|
|
76
|
+
case 'code':
|
|
77
|
+
return { tag: 'code' };
|
|
78
|
+
case 'subscript':
|
|
79
|
+
return { tag: 'sub' };
|
|
80
|
+
case 'superscript':
|
|
81
|
+
return { tag: 'sup' };
|
|
82
|
+
case 'strikethrough':
|
|
83
|
+
return { tag: 'del' };
|
|
84
|
+
case 'underline':
|
|
85
|
+
return { tag: 'span', style: { textDecoration: 'underline' } };
|
|
86
|
+
case 'uppercase':
|
|
87
|
+
return { tag: 'span', style: { textTransform: 'uppercase' } };
|
|
88
|
+
case 'italic':
|
|
89
|
+
return { tag: 'em' };
|
|
90
|
+
case 'bold':
|
|
91
|
+
return { tag: 'strong' };
|
|
92
|
+
case 'color':
|
|
93
|
+
return { tag: 'span', style: { color: mark.value } };
|
|
94
|
+
case 'backgroundColor':
|
|
95
|
+
return { tag: 'span', style: { backgroundColor: mark.value } };
|
|
96
|
+
case 'fontFamily':
|
|
97
|
+
return { tag: 'span', style: { fontFamily: mark.value } };
|
|
98
|
+
case 'fontSize':
|
|
99
|
+
return { tag: 'span', style: { fontSize: mark.value } };
|
|
100
|
+
default:
|
|
101
|
+
return { tag: 'span' };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The prop object passed to a custom modifier component for a given mark. Value
|
|
107
|
+
* marks (color/background/font) forward their value; boolean marks pass nothing.
|
|
108
|
+
*/
|
|
109
|
+
export function getModifierProps(mark: Mark): Record<string, string> {
|
|
110
|
+
if (mark.value === undefined) return {};
|
|
111
|
+
return { [mark.name]: mark.value };
|
|
112
|
+
}
|