feat(client, server) #11: add AntV Infographic renderer

Design spec: docs/superpowers/specs/2026-05-11-infographic-renderer-design.md
pull/8001/head
Tayeb Chlyah 4 weeks ago
parent 9473a49837
commit dafb6ac7d4

@ -231,6 +231,10 @@ import mermaid from 'mermaid'
// Vega
import { hydrateVega } from '../vega/hydrate'
// AntV Infographic
import { hydrateInfographic } from '../infographic/hydrate'
import infographicPlugin from '../../../server/modules/rendering/markdown-infographic/plugin'
// Helpers
import katexHelper from './common/katex'
import tabsetHelper from './markdown/tabset'
@ -285,6 +289,7 @@ const md = new MarkdownIt({
.use(mdMark)
.use(mdFootnote)
.use(mdImsize)
.use(infographicPlugin)
// DOMPurify fix for draw.io
DOMPurify.addHook('uponSanitizeElement', (elm) => {
@ -423,6 +428,7 @@ export default {
this.$nextTick(() => {
this.renderMermaidDiagrams()
hydrateVega(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark })
hydrateInfographic(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark })
Prism.highlightAllUnder(this.$refs.editorPreview)
Array.from(this.$refs.editorPreview.querySelectorAll('pre.line-numbers')).forEach(pre => pre.classList.add('prismjs'))
})
@ -477,6 +483,7 @@ export default {
tabsetHelper.format()
this.renderMermaidDiagrams()
hydrateVega(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark })
hydrateInfographic(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark })
Prism.highlightAllUnder(this.$refs.editorPreview)
Array.from(this.$refs.editorPreview.querySelectorAll('pre.line-numbers')).forEach(pre => pre.classList.add('prismjs'))
this.scrollSync(this.cm)

@ -0,0 +1,167 @@
const SPEC_ATTR = 'data-infographic-spec'
const OPTS_ATTR = 'data-opts'
const RENDERED_ATTR = 'data-infographic-rendered'
const INFOGRAPHIC_VERSION = '0.2.19'
const CDN_URL = `https://cdn.jsdelivr.net/npm/@antv/infographic@${INFOGRAPHIC_VERSION}/dist/infographic.min.js`
const GLOBAL_NAME = 'AntVInfographic'
const ALLOWED_OPT_KEYS = new Set(['width', 'height', 'theme'])
let libraryPromise = null
function loadScript(url) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(`script[data-infographic-cdn="${url}"]`)
if (existing) {
if (existing.dataset.loaded === 'true') {
resolve()
return
}
existing.addEventListener('load', () => resolve())
existing.addEventListener('error', () => reject(new Error(`Failed to load ${url}`)))
return
}
const script = document.createElement('script')
script.src = url
script.async = false
script.dataset.infographicCdn = url
script.addEventListener('load', () => {
script.dataset.loaded = 'true'
resolve()
})
script.addEventListener('error', () => reject(new Error(`Failed to load ${url}`)))
document.head.appendChild(script)
})
}
async function loadInfographicLib() {
if (libraryPromise) {
return libraryPromise
}
libraryPromise = (async () => {
await loadScript(CDN_URL)
const ns = window[GLOBAL_NAME]
if (!ns || typeof ns.Infographic !== 'function') {
throw new Error(`Loaded ${CDN_URL} but window.${GLOBAL_NAME}.Infographic is missing`)
}
return ns.Infographic
})().catch(err => {
libraryPromise = null
throw err
})
return libraryPromise
}
function readSpecText(el) {
if (el.hasAttribute(SPEC_ATTR)) {
return el.getAttribute(SPEC_ATTR)
}
const text = el.textContent
el.setAttribute(SPEC_ATTR, text)
return text
}
function parseOpts(raw) {
const out = {}
if (!raw) {
return out
}
raw.trim().split(/\s+/).forEach(pair => {
const eq = pair.indexOf('=')
if (eq <= 0) {
return
}
const key = pair.slice(0, eq)
const value = pair.slice(eq + 1)
if (ALLOWED_OPT_KEYS.has(key)) {
out[key] = value
}
})
return out
}
function coerceDimension(v, fallback) {
if (v === undefined || v === null || v === '') {
return fallback
}
// Allow numeric pixels ("400"), CSS strings ("100%", "30rem").
if (/^\d+$/.test(v)) {
return Number(v)
}
return v
}
function renderError(el, message) {
const sourceText = el.getAttribute(SPEC_ATTR) || el.textContent
el.classList.add('infographic-error')
el.replaceChildren()
const heading = document.createElement('strong')
heading.textContent = 'Infographic render error'
const messageEl = document.createElement('p')
messageEl.textContent = message
const sourceEl = document.createElement('pre')
sourceEl.textContent = sourceText
el.appendChild(heading)
el.appendChild(messageEl)
el.appendChild(sourceEl)
}
export async function hydrateInfographic(rootEl, { darkMode = false } = {}) {
const root = rootEl || document
const elements = Array.from(root.querySelectorAll(`.infographic:not([${RENDERED_ATTR}])`))
if (elements.length === 0) {
return
}
// Capture spec text before any DOM mutation so re-runs stay idempotent.
elements.forEach(el => readSpecText(el))
let Infographic
try {
Infographic = await loadInfographicLib()
} catch (err) {
elements.forEach(el => renderError(el, `Failed to load infographic library: ${err.message}`))
// eslint-disable-next-line no-console
console.error('[infographic] failed to load library from CDN', err)
return
}
await Promise.all(elements.map(async el => {
const spec = readSpecText(el)
let opts
try {
opts = parseOpts(el.getAttribute(OPTS_ATTR))
} catch (err) {
renderError(el, `Invalid data-opts: ${err.message}`)
return
}
const finalOpts = {
container: el,
width: coerceDimension(opts.width, '100%'),
height: coerceDimension(opts.height, 480),
theme: opts.theme || (darkMode ? 'dark' : undefined),
editable: false
}
el.setAttribute(RENDERED_ATTR, '1')
el.replaceChildren()
el.classList.remove('infographic-error')
try {
const inst = new Infographic(finalOpts)
const result = inst.render(spec)
if (result && typeof result.then === 'function') {
await result
}
} catch (err) {
el.removeAttribute(RENDERED_ATTR)
renderError(el, err && err.message ? err.message : String(err))
}
}))
}

@ -16,6 +16,7 @@
@import 'components/v-form';
@import 'components/v-tabs';
@import 'components/vega';
@import 'components/infographic';
// @import '../libs/twemoji/twemoji-awesome';
// @import '../libs/prism/prism.css';

@ -0,0 +1,34 @@
.infographic {
display: block;
margin: 1rem 0;
overflow: auto;
}
.infographic-error {
border: 1px solid #c62828;
background: rgba(198, 40, 40, 0.05);
color: #c62828;
padding: 12px 16px;
border-radius: 4px;
font-size: 0.9rem;
strong {
display: block;
margin-bottom: 4px;
}
p {
margin: 4px 0 8px;
}
pre {
background: rgba(0, 0, 0, 0.05);
padding: 8px;
border-radius: 2px;
overflow-x: auto;
font-size: 0.8rem;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
}

@ -383,6 +383,7 @@ import NavSidebar from './nav-sidebar.vue'
import Prism from 'prismjs'
import mermaid from 'mermaid'
import { hydrateVega } from '../../../components/vega/hydrate'
import { hydrateInfographic } from '../../../components/infographic/hydrate'
import { get, sync } from 'vuex-pathify'
import _ from 'lodash'
import ClipboardJS from 'clipboard'
@ -649,6 +650,9 @@ export default {
// -> Render Vega / Vega-Lite charts
hydrateVega(this.$refs.container, { darkMode: this.$vuetify.theme.dark })
// -> Render AntV Infographic diagrams
hydrateInfographic(this.$refs.container, { darkMode: this.$vuetify.theme.dark })
// -> Handle anchor scrolling
if (window.location.hash && window.location.hash.length > 1) {
if (document.readyState === 'complete') {

@ -0,0 +1,259 @@
# AntV Infographic Renderer — Design Spec
**Date:** 2026-05-11
**Status:** Approved
**Author:** Tayeb Chlyah
---
## Goal
Add support for [AntV Infographic](https://github.com/antvis/infographic) diagrams in Wiki.js pages via fenced code blocks, using the official `@antv/infographic` library. Infographics render in both the live page view and the markdown editor preview.
---
## Context
Wiki.js already supports several embedded visualization syntaxes (Mermaid, Vega, Vega-Lite) via the same pattern: server-side renderer rewrites fenced blocks into a placeholder `<div>`, and the client mounts a JS library on those divs after the page loads.
This spec follows the [Vega/Vega-Lite renderer spec](./2026-05-09-vega-renderer-design.md) wherever the two libraries are similar, and deviates where AntV Infographic differs:
- The body is a custom indentation-sensitive DSL (not JSON).
- The library constructor requires explicit `width` and `height` (no auto-sizing from spec).
- The library exposes built-in themes (`default`, `light`, `dark`, `gradient`, `pattern`, `hand-drawn`) selectable via a string option.
- The output is SVG and the library is fault-tolerant to partial input (supports streaming render).
---
## Markdown Syntax
A single fenced-code language with optional whitespace-separated `key=value` parameters in the info string:
````markdown
```infographic
infographic list-row-simple-horizontal-arrow
data
lists
- label Step 1
desc Start
- label Step 2
desc Complete
```
```infographic height=600 theme=hand-drawn
infographic ...
```
````
Recognized info-string keys: `height`, `width`, `theme`. Unknown keys are ignored. Body whitespace is preserved verbatim (the DSL is indentation-sensitive).
---
## Architecture
### Markdown preprocessor module
A markdown-it preprocessor — modeled on `server/modules/rendering/markdown-kroki/` — captures the fenced block before the default `fence` rule sees it, so the info string is preserved.
- `server/modules/rendering/markdown-infographic/`
- `definition.yml`: `key: markdownInfographic`, `dependsOn: markdownCore`, `enabledDefault: true`, no props.
- `renderer.js`: `init(mdinst, conf)` that calls `mdinst.use(require('./plugin'))`.
- `plugin.js`: markdown-it plugin. Registers a block rule with `md.block.ruler.before('fence', 'infographic', ...)` matching ` ```infographic [params]\n{body}\n``` `. Emits a custom `infographic` token whose renderer rule outputs:
```html
<div class="infographic" data-opts="{escaped params}">{escaped body text}</div>
```
- Body text is HTML-escaped (no script injection possible). The `data-opts` attribute value is also escaped.
No `html-infographic` cheerio post-processing module is needed — the preprocessor produces final markup directly.
### Editor preview parity
The editor preview uses its own in-browser `markdown-it` instance (see `client/components/editor/editor-markdown.vue`). The same `plugin.js` is imported and applied there so the editor and the live page produce identical markup for infographic blocks:
```
import infographicPlugin from '../../../server/modules/rendering/markdown-infographic/plugin'
md.use(infographicPlugin)
```
The cross-tree import (client file pulling from `server/`) is accepted to avoid a shared/ directory or duplicate plugin source. Webpack bundles the plugin into the client output normally. This single cross-tree import is documented here so future maintainers don't treat it as a mistake.
### Client hydrator
`client/components/infographic/hydrate.js` exports:
```
hydrateInfographic(rootEl, { darkMode })
```
Behavior:
1. Query `rootEl.querySelectorAll('.infographic:not([data-infographic-rendered])')`.
2. If empty, return immediately.
3. Lazily inject one `<script>` from `cdn.jsdelivr.net/npm/@antv/infographic@VERSION/dist/infographic.min.js`. The pinned version is a module-scope constant. The script registers `window.AntVInfographic`. The load promise is cached at module scope so subsequent calls reuse the same script.
4. For each matched element:
- On first hydration, copy the element's `textContent` into `data-infographic-spec`.
- Parse `data-opts`: split on whitespace, then on `=`. Allowed keys: `height`, `width`, `theme`. Other keys ignored.
- Mark `data-infographic-rendered="1"` and clear the element's children.
- Compute final options:
- `container: el`
- `width: opts.width || '100%'`
- `height: opts.height || 480`
- `theme: opts.theme || (darkMode ? 'dark' : undefined)`
- `editable: false`
- Construct `new AntVInfographic.Infographic(finalOpts)` and call `.render(spec)` where `spec` is the value of `data-infographic-spec`.
5. On any failure (opts parse error, constructor throw, render throw/reject, script load failure), replace the element's content with an inline error box.
### Hydration call sites
Two integrations, identical to the vega renderer pattern:
- **Live page view:** `client/themes/default/components/page.vue` mounted hook. Passes `darkMode = this.$vuetify.theme.dark`.
- **Editor preview:** `client/components/editor/editor-markdown.vue` preview render path (mounted, processContent, previewShown watcher). Hydration runs after each debounced markdown re-render.
### Markup uniformity
Because both server and editor use the same preprocessor plugin, both paths produce the same `<div class="infographic" data-opts="…">` markup. No `normalizeRawCodeBlocks` step is needed (unlike the vega renderer, which uses one to bridge the editor's default fenced-block markup).
---
## Bundle Strategy
`@antv/infographic` is **lazy-loaded from a public CDN** (jsdelivr) at runtime. No infographic code is bundled into the Wiki.js webpack output.
Rationale (same as vega):
- Webpack 4 has known issues with modern ESM packages. CDN avoids the toolchain question.
- The minified UMD bundle is ~870 KB (the library bundles ~200 templates plus SVG rendering). Too large to eagerly include in the main bundle.
- CDN keeps the cost off pages without infographics.
Tradeoffs accepted:
- **Airgapped / on-prem deployments without internet egress will not render infographics.** The error box will display a "Failed to load infographic library" message.
- A `Content-Security-Policy` configured to restrict `script-src` must allow `cdn.jsdelivr.net`.
- Third-party CDN is a runtime dependency. jsdelivr outages block rendering.
The first infographic block on a page incurs a one-time CDN load delay. Subsequent blocks reuse the cached script.
---
## Dependencies
- **No new `package.json` dependencies.** The library is loaded at runtime.
- The pinned version of `@antv/infographic` lives as a constant in `hydrate.js`.
- Version bumps require editing the helper module and rebuilding (no `yarn upgrade` flow).
- No additional server-side dependencies.
---
## Theming
The `theme` option resolves in priority order:
1. Per-block `data-opts theme=…` value (any string the library accepts).
2. Otherwise: `'dark'` when `$vuetify.theme.dark` is true, `undefined` (library default) when false.
Theme is read once at hydration time. Toggling dark/light mode after an infographic renders does **not** re-render it — the page must be reloaded. Matches existing Mermaid and Vega behavior.
Known built-in theme names (from inspection of the UMD bundle): `default`, `light`, `dark`, `gradient`, `pattern`, `hand-drawn`. The hydrator does not validate; it passes the string through.
---
## Security Model
Trusted-editor model — identical to Vega and Mermaid:
- The DSL body is passed straight to `Infographic.render()` without sanitization.
- Page editors are already trusted with raw HTML and dynamic content via existing renderers.
- The library renders to SVG and does not evaluate user-supplied JavaScript expressions, so the surface is narrower than Vega's.
This decision is recorded so future work can revisit if the trust model changes (e.g., introducing untrusted contributors).
---
## Error Handling
All failure modes surface a visible inline error box, replacing the chart container's content. The hydrator catches:
1. `data-opts` parse failure.
2. `Infographic` constructor throw.
3. `.render()` throw or returned-promise rejection.
4. CDN script load failure.
Error box markup (sketch — actual class names follow existing renderer conventions):
```
<div class="infographic-error">
<strong>Infographic render error</strong>
<p>{message}</p>
<pre>{original spec text}</pre>
</div>
```
Styling: red 1 px border, padding for readability, monospace `<pre>`. Lives in `client/scss/components/infographic.scss`.
For the CDN load failure case, the error is rendered on the first matched element and the failure is logged to `console.error`. Subsequent elements on the same page get their own error boxes.
The library is documented as fault-tolerant to partial input (it supports streaming render). Malformed-but-non-throwing input will render a partial result rather than trigger the error box.
---
## Idempotency
The editor preview re-renders markdown on each edit (debounced). Hydration is safe to run repeatedly:
- On first run, the element's `textContent` is copied into `data-infographic-spec`, and `data-infographic-rendered="1"` is set.
- Subsequent runs skip elements already marked rendered. They are re-found anew when the preview pane's innerHTML is replaced.
- This combination matches the vega renderer's defensive idempotency model.
---
## Testing
**Server-side unit tests:**
Add `server/test/rendering/infographic.test.js`. Confirms the preprocessor transforms:
- ` ```infographic\n{body}\n``` ` → `<div class="infographic" data-opts="">{escaped body}</div>`
- ` ```infographic height=600 theme=hand-drawn\n{body}\n``` ` → `<div class="infographic" data-opts="height=600 theme=hand-drawn">{escaped body}</div>`
- HTML-escaping of body and `data-opts` values is verified.
**Manual QA checklist:**
- Live page view: render `list-row-simple-horizontal-arrow` template, both light and dark mode, with and without `data-opts`.
- Editor preview: same content, confirm hydration after edit + debounce.
- Per-block theme: render with `theme=hand-drawn`, confirm override.
- Per-block sizing: render with `height=600 width=800`, confirm dimensions.
- Network tab: confirm CDN script does not load on a page without infographic blocks; loads exactly once on a page with one or more.
- Offline test: block `cdn.jsdelivr.net` and confirm error box appears.
---
## Out of Scope
- Live theme reactivity (re-render on dark/light toggle without reload). Matches Mermaid/Vega behavior.
- Editable infographics (`editable: false` is hardcoded; the built-in editor is not exposed in wiki pages).
- Custom AntV plugins or interactions (`plugins` / `interactions` constructor options not surfaced).
- Server-side pre-rendering. The library is browser-only.
- A shared/ directory or build-time codegen to avoid the cross-tree `plugin.js` import. Accepted as a single, documented exception.
---
## File Inventory
New:
- `server/modules/rendering/markdown-infographic/definition.yml`
- `server/modules/rendering/markdown-infographic/renderer.js`
- `server/modules/rendering/markdown-infographic/plugin.js`
- `client/components/infographic/hydrate.js`
- `client/scss/components/infographic.scss`
- `server/test/rendering/infographic.test.js`
Modified:
- `client/themes/default/components/page.vue` — call `hydrateInfographic` in mounted hook.
- `client/components/editor/editor-markdown.vue` — import `plugin.js`, call `md.use(infographicPlugin)`, call `hydrateInfographic` in the preview render path.
- `client/scss/app.scss` — import the new `components/infographic` partial.
Not modified:
- `package.json` — no new dependencies (CDN strategy).

@ -0,0 +1,8 @@
key: markdownInfographic
title: AntV Infographic
description: Render AntV Infographic diagrams from fenced code blocks
author: couchbase-ps
icon: mdi-chart-tree
enabledDefault: true
dependsOn: markdownCore
props: {}

@ -0,0 +1,126 @@
// Shared markdown-it plugin for AntV Infographic fenced blocks.
//
// Captures ```infographic [params]\n{body}\n``` before the default `fence`
// rule so the info-string params are preserved (`fence` discards them by
// default in this project's highlight() pipeline).
//
// Emits a single `infographic` block token that renders to:
// <div class="infographic" data-opts="{params}">{escaped body}</div>
//
// Used by both the server-side markdown pipeline and the client editor
// preview's markdown-it instance to keep markup symmetric.
const OPEN_MARKER = '```infographic'
const CLOSE_MARKER = '```'
function infographicRule(state, startLine, endLine, silent) {
let start = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// Quick first-char check.
if (state.src.charCodeAt(start) !== OPEN_MARKER.charCodeAt(0)) {
return false
}
// Match the full opening marker.
if (start + OPEN_MARKER.length > max) {
return false
}
for (let i = 0; i < OPEN_MARKER.length; i++) {
if (state.src[start + i] !== OPEN_MARKER[i]) {
return false
}
}
// What follows the marker must be end-of-line or whitespace then params.
const afterMarker = state.src.slice(start + OPEN_MARKER.length, max)
// Reject e.g. ```infographicfoo (must be exact lang).
if (afterMarker.length > 0 && !/^\s/.test(afterMarker)) {
return false
}
if (silent) {
return true
}
const params = afterMarker.trim()
// Find the closing fence.
let nextLine = startLine
let autoClosed = false
for (;;) {
nextLine++
if (nextLine >= endLine) {
break
}
start = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
if (start < max && state.sCount[nextLine] < state.blkIndent) {
break
}
if (state.src.charCodeAt(start) !== CLOSE_MARKER.charCodeAt(0)) {
continue
}
if (state.sCount[nextLine] > state.sCount[startLine]) {
continue
}
let closeMatched = true
for (let i = 0; i < CLOSE_MARKER.length; i++) {
if (state.src[start + i] !== CLOSE_MARKER[i]) {
closeMatched = false
break
}
}
if (!closeMatched) {
continue
}
if (state.skipSpaces(start + CLOSE_MARKER.length) < max) {
continue
}
autoClosed = true
break
}
const body = state.src
.split('\n')
.slice(startLine + 1, nextLine)
.join('\n')
const token = state.push('infographic', 'div', 0)
token.block = true
token.markup = OPEN_MARKER
token.info = params
token.content = body
token.map = [startLine, nextLine]
state.line = nextLine + (autoClosed ? 1 : 0)
return true
}
function escapeAttr(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function escapeText(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
function renderRule(tokens, idx) {
const token = tokens[idx]
const opts = escapeAttr(token.info || '')
const body = escapeText(token.content || '')
return `<div class="infographic" data-opts="${opts}">${body}</div>\n`
}
module.exports = function infographicPlugin(md) {
md.block.ruler.before('fence', 'infographic', infographicRule, {
alt: ['paragraph', 'reference', 'blockquote', 'list']
})
md.renderer.rules.infographic = renderRule
}

@ -0,0 +1,11 @@
// ------------------------------------
// Markdown - AntV Infographic Preprocessor
// ------------------------------------
const infographicPlugin = require('./plugin')
module.exports = {
init (mdinst, conf) {
mdinst.use(infographicPlugin)
}
}

@ -0,0 +1,62 @@
const MarkdownIt = require('markdown-it')
const infographicPlugin = require('../../modules/rendering/markdown-infographic/plugin')
function render(src) {
const md = new MarkdownIt({ html: true })
md.use(infographicPlugin)
return md.render(src)
}
describe('markdown-infographic plugin', () => {
test('rewrites a fenced infographic block to a div.infographic container', () => {
const src = '```infographic\ninfographic list-row-simple-horizontal-arrow\ndata\n lists\n - label Step 1\n```\n'
const out = render(src)
expect(out).toContain('<div class="infographic" data-opts="">')
expect(out).toContain('infographic list-row-simple-horizontal-arrow')
expect(out).toContain('- label Step 1')
expect(out).not.toContain('<pre')
expect(out).not.toContain('<code')
})
test('preserves info-string params as data-opts', () => {
const src = '```infographic height=600 theme=hand-drawn\nbody\n```\n'
const out = render(src)
expect(out).toContain('<div class="infographic" data-opts="height=600 theme=hand-drawn">')
expect(out).toContain('body')
})
test('does not touch unrelated fenced code blocks', () => {
const src = '```js\nconsole.log(1)\n```\n'
const out = render(src)
expect(out).toContain('<code')
expect(out).not.toContain('class="infographic"')
})
test('escapes HTML in body and data-opts', () => {
const src = '```infographic theme="><script>x</script>\n<script>alert(1)</script>\n```\n'
const out = render(src)
expect(out).not.toContain('<script>')
expect(out).toContain('&lt;script&gt;')
expect(out).toContain('&quot;')
})
test('preserves indentation in body verbatim', () => {
const src = '```infographic\nfoo\n bar\n baz\n```\n'
const out = render(src)
// The DSL is indentation-sensitive — leading spaces must survive.
expect(out).toContain('foo\n bar\n baz')
})
test('does not match a fence with a similar prefix', () => {
const src = '```infographicfoo\nbody\n```\n'
const out = render(src)
expect(out).not.toContain('class="infographic"')
})
test('handles a block at end-of-document without trailing newline closure', () => {
const src = '```infographic\nbody\n```'
const out = render(src)
expect(out).toContain('<div class="infographic"')
expect(out).toContain('body')
})
})
Loading…
Cancel
Save