Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/markdown-live-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@dskripchenko/ui": minor
---

UidMarkdown: live blocks. A block renderer may return `mount(el, ctx)` instead of `html` — the host's widget (an API console, a chart with controls) is mounted into the block. A changed source goes to the widget's `update`, `destroy` runs when the block goes away; rendered markdown mounts with `editable: false`. New types `UidMarkdownBlockMountContext` and `UidMarkdownBlockMountHandle`; the contract matches the WYSIWYG editor's live blocks.
17 changes: 17 additions & 0 deletions docs/de/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid })
- **`html` wird unverändert eingefügt.** Für die Sicherheit ist der Renderer verantwortlich: nur selbst erzeugtes Markup mit maskiertem Benutzertext zurückgeben.
- Das Prop `blockRenderers` hat Vorrang vor bereitgestellten Renderern, je Sprache. App-weit: `app.provide(markdownBlockRenderersKey, renderers)`.

### Live-Blöcke

Statt `html` kann ein Renderer `mount` liefern — ein Widget wird in das Element des Blocks gemountet (eine API-Konsole, ein Diagramm mit Bedienelementen):

```ts
const api: UidMarkdownBlockRenderer = () => ({
mount: (el, { code }) => {
const widget = createWidget(el, code)
return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() }
},
})
```

- `mount(el, { code, language, editable, setCode })` gibt ein Handle `{ update?, destroy? }` oder eine Aufräumfunktion zurück. Gerendertes Markdown ist schreibgeschützt: `editable` ist `false`, `setCode` tut nichts.
- Ändert sich die Quelle und der Block bleibt an seiner Stelle, lebt das Widget weiter und erhält die neue Quelle über `update`; ohne `update` wird es neu gemountet. `destroy` läuft, wenn der Block verschwindet oder UidMarkdown ausgehängt wird.
- Der Vertrag ist derselbe wie bei den Live-Blöcken des WYSIWYG-Editors (`@dskripchenko/wysiwyg`): Ein einmal registrierter Renderer bedient beide.

## Der Renderer als Funktion

Auch `renderMarkdown(source, options)` und `splitMarkdown(source)` werden exportiert — für eine Vorschau außerhalb einer Komponente oder serverseitig. Optionen: `slugger`, `onHeading`, `linkBase`, `stripMdExtension`, `imageBase`, `internalLinksInPlace`, `calloutTitles`.
Expand Down
17 changes: 17 additions & 0 deletions docs/en/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid })
- **`html` is inserted as is.** The renderer is responsible for its safety: return only markup you built with every piece of user text escaped.
- The `blockRenderers` prop takes precedence over provided renderers, language by language. App-wide: `app.provide(markdownBlockRenderersKey, renderers)`.

### Live blocks

Instead of `html` a renderer may return `mount` — a widget is mounted into the block's element (an API console, a chart with controls):

```ts
const api: UidMarkdownBlockRenderer = () => ({
mount: (el, { code }) => {
const widget = createWidget(el, code)
return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() }
},
})
```

- `mount(el, { code, language, editable, setCode })` returns a handle `{ update?, destroy? }` or a cleanup function. Rendered markdown is read-only: `editable` is `false` and `setCode` does nothing.
- When the source changes and the block stays in place, the widget keeps living and gets the new source through `update`; without `update` it is mounted again. `destroy` runs when the block goes away or UidMarkdown unmounts.
- The contract is the one of the WYSIWYG editor's live blocks (`@dskripchenko/wysiwyg`), so a renderer registered once serves both.

## The renderer as a function

`renderMarkdown(source, options)` and `splitMarkdown(source)` are exported too — for a preview outside a component or for server-side use. Options: `slugger`, `onHeading`, `linkBase`, `stripMdExtension`, `imageBase`, `internalLinksInPlace`, `calloutTitles`.
Expand Down
17 changes: 17 additions & 0 deletions docs/ru/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid })
- **`html` вставляется как есть.** За его безопасность отвечает рендерер: возвращайте только разметку, собранную вами, с экранированным пользовательским текстом.
- Prop `blockRenderers` важнее переданных через provide — по каждому языку отдельно. На всё приложение: `app.provide(markdownBlockRenderersKey, renderers)`.

### Живые блоки

Вместо `html` рендерер может вернуть `mount` — в элемент блока монтируется виджет (консоль API, график с элементами управления):

```ts
const api: UidMarkdownBlockRenderer = () => ({
mount: (el, { code }) => {
const widget = createWidget(el, code)
return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() }
},
})
```

- `mount(el, { code, language, editable, setCode })` возвращает handle `{ update?, destroy? }` или функцию очистки. Отрисованный markdown только для чтения: `editable` равно `false`, `setCode` ничего не делает.
- Если исходник изменился, а блок остался на месте, виджет продолжает жить и получает новый исходник через `update`; без `update` он монтируется заново. `destroy` вызывается, когда блок исчезает или UidMarkdown размонтируется.
- Контракт тот же, что у живых блоков WYSIWYG-редактора (`@dskripchenko/wysiwyg`): один зарегистрированный рендерер обслуживает оба.

## Рендерер как функция

Экспортированы и `renderMarkdown(source, options)`, `splitMarkdown(source)` — для превью вне компонента или на сервере. Опции: `slugger`, `onHeading`, `linkBase`, `stripMdExtension`, `imageBase`, `internalLinksInPlace`, `calloutTitles`.
Expand Down
17 changes: 17 additions & 0 deletions docs/zh/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ provideMarkdownBlockRenderers({ mermaid })
- **`html` 原样插入。** 其安全性由渲染器负责:只返回自己生成、且所有用户文本均已转义的标记。
- `blockRenderers` prop 按语言优先于通过 provide 提供的渲染器。全应用范围:`app.provide(markdownBlockRenderersKey, renderers)`。

### 实时块

渲染器可以返回 `mount` 而不是 `html`——组件被挂载到块的元素中(API 控制台、带控件的图表):

```ts
const api: UidMarkdownBlockRenderer = () => ({
mount: (el, { code }) => {
const widget = createWidget(el, code)
return { update: (next) => widget.setSource(next), destroy: () => widget.unmount() }
},
})
```

- `mount(el, { code, language, editable, setCode })` 返回句柄 `{ update?, destroy? }` 或清理函数。渲染后的 markdown 是只读的:`editable` 为 `false`,`setCode` 不做任何事。
- 源码改变而块位置不变时,组件继续存在,并通过 `update` 收到新源码;没有 `update` 时重新挂载。块消失或 UidMarkdown 卸载时调用 `destroy`。
- 该契约与 WYSIWYG 编辑器(`@dskripchenko/wysiwyg`)的实时块相同:注册一次的渲染器可同时用于两者。

## 作为函数使用

同时导出 `renderMarkdown(source, options)` 和 `splitMarkdown(source)` —— 用于组件之外的预览或服务端。选项:`slugger`、`onHeading`、`linkBase`、`stripMdExtension`、`imageBase`、`internalLinksInPlace`、`calloutTitles`。
Expand Down
6 changes: 6 additions & 0 deletions src/components/Markdown/UidMarkdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@

.uid-markdown__block > svg { max-width: 100%; height: auto; }

/* A live block holds an interactive widget, laid out as the widget wants. */
.uid-markdown__block--live {
overflow: visible;
text-align: start;
}

.uid-markdown__block--pending {
padding: var(--uid-space-lg);
border: 1px dashed var(--uid-markdown-border);
Expand Down
45 changes: 45 additions & 0 deletions src/components/Markdown/UidMarkdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,51 @@ describe('UidMarkdown', () => {
expect(wrapper.find('.from-provide-b').exists()).toBe(false)
})

it('mounts a live block, updates it with a new source and destroys it', async () => {
const log: string[] = []
const live: UidMarkdownBlockRenderer = () => ({
mount: (el, ctx) => {
log.push(`mount:${ctx.code}:${ctx.editable}`)
el.innerHTML = '<button class="widget">Send</button>'
return {
update: (code) => log.push(`update:${code}`),
destroy: () => log.push('destroy'),
}
},
})
const wrapper = mount(UidMarkdown, { props: { source: fence('api', 'GET /a'), blockRenderers: { api: live } } })
await flushPromises()
expect(wrapper.find('.uid-markdown__block--live button.widget').exists()).toBe(true)
expect(log).toEqual(['mount:GET /a:false'])

// The widget stays and takes the new source.
await wrapper.setProps({ source: fence('api', 'GET /b') })
await flushPromises()
expect(log).toEqual(['mount:GET /a:false', 'update:GET /b'])

await wrapper.setProps({ source: 'No blocks' })
await flushPromises()
expect(log).toContain('destroy')
wrapper.unmount()
})

it('remounts a live block without update, and accepts a cleanup function', async () => {
const log: string[] = []
const live: UidMarkdownBlockRenderer = () => ({
mount: (_el, ctx) => {
log.push(`mount:${ctx.code}`)
return () => log.push('cleanup')
},
})
const wrapper = mount(UidMarkdown, { props: { source: fence('api', '1'), blockRenderers: { api: live } } })
await flushPromises()
await wrapper.setProps({ source: fence('api', '2') })
await flushPromises()
expect(log).toEqual(['mount:1', 'cleanup', 'mount:2'])
wrapper.unmount()
expect(log.at(-1)).toBe('cleanup')
})

it('counts the index among code blocks', () => {
const seen: number[] = []
mount(UidMarkdown, {
Expand Down
24 changes: 21 additions & 3 deletions src/components/Markdown/UidMarkdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import './UidMarkdown.css'
import { computed, nextTick, onMounted, shallowRef, watch } from 'vue'
import UidCode from '../Code/UidCode.vue'
import UidMarkdownLiveBlock from './UidMarkdownLiveBlock.vue'
import { useLocale } from '../../composables/useLocale.js'
import {
createSlugger,
Expand Down Expand Up @@ -78,9 +79,10 @@ type Block =
kind: 'custom'
code: string
language: string
state: 'pending' | 'done' | 'error'
state: 'pending' | 'done' | 'error' | 'live'
html?: string
error?: string
mount?: UidMarkdownBlockResult['mount']
}

const blocks = shallowRef<Block[]>([])
Expand All @@ -93,8 +95,9 @@ function settle(index: number, gen: number, result: UidMarkdownBlockResult): voi
const current = blocks.value[index]
if (!current || current.kind !== 'custom') return
const next = blocks.value.slice()
next[index] =
result.error !== undefined || result.html === undefined
next[index] = result.mount
? { ...current, state: 'live', mount: result.mount }
: result.error !== undefined || result.html === undefined
? { ...current, state: 'error', error: result.error ?? '' }
: { ...current, state: 'done', html: result.html }
blocks.value = next
Expand All @@ -112,6 +115,7 @@ function render(): void {
const titles = { ...locale.value.markdown, ...(props.calloutTitles ?? {}) }
const pending: { index: number; run: () => UidMarkdownBlockResult | Promise<UidMarkdownBlockResult> }[] = []
let codeIndex = 0
const previous = blocks.value

const out: Block[] = splitMarkdown(props.source).map((segment, index) => {
if (segment.kind === 'text') {
Expand All @@ -133,6 +137,12 @@ function render(): void {
if (!renderer) {
return { kind: 'code', code: segment.code, language: LANGUAGE_ALIASES[segment.language] ?? segment.language }
}
// A live block that stays where it was keeps its widget: the widget
// takes the new source (typing in a preview) instead of remounting.
const before = previous[index]
if (before?.kind === 'custom' && before.state === 'live' && before.language === segment.language) {
return { ...before, code: segment.code }
}
pending.push({ index, run: () => renderer(segment.code, ctx) })
return { kind: 'custom', code: segment.code, language: segment.language, state: 'pending' }
})
Expand Down Expand Up @@ -248,6 +258,14 @@ onMounted(() => {
:data-language="block.language"
v-html="block.html"
/>
<UidMarkdownLiveBlock
v-else-if="block.state === 'live' && block.mount"
class="uid-markdown__block uid-markdown__block--live"
:data-language="block.language"
:code="block.code"
:language="block.language"
:mount="block.mount"
/>
<div
v-else-if="block.state === 'pending'"
class="uid-markdown__block uid-markdown__block--pending"
Expand Down
62 changes: 62 additions & 0 deletions src/components/Markdown/UidMarkdownLiveBlock.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
/**
* A live fenced block of UidMarkdown: the renderer's widget mounted into a
* plain element. A new source goes to the widget's `update`; without one, the
* widget is mounted again.
*/
import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
import type { UidMarkdownBlockMountHandle, UidMarkdownBlockResult } from './blockRenderers.js'

const props = defineProps<{
code: string
language: string
mount: NonNullable<UidMarkdownBlockResult['mount']>
}>()

const el = shallowRef<HTMLElement | null>(null)
let handle: UidMarkdownBlockMountHandle | null = null

function mountWidget(): void {
if (!el.value) return
el.value.replaceChildren()
const out = props.mount(el.value, {
code: props.code,
language: props.language,
editable: false,
setCode: () => {},
})
handle = typeof out === 'function' ? { destroy: out } : (out ?? {})
}

function unmountWidget(): void {
handle?.destroy?.()
handle = null
}

onMounted(mountWidget)
onBeforeUnmount(unmountWidget)

watch(
() => props.code,
(code) => {
if (handle?.update) {
handle.update(code)
return
}
unmountWidget()
mountWidget()
},
)

watch(
() => props.mount,
() => {
unmountWidget()
mountWidget()
},
)
</script>

<template>
<div ref="el" />
</template>
26 changes: 26 additions & 0 deletions src/components/Markdown/blockRenderers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,32 @@ export interface UidMarkdownBlockResult {
html?: string
/** Why the block could not be rendered; the block is then shown as code with this message. */
error?: string
/**
* A live block: the host's widget (an API console, a chart with controls)
* is mounted into the block's element instead of static markup. Return a
* handle or a cleanup function: `update` takes a changed source without
* remounting, `destroy` runs when the block goes away.
*/
mount?: (
el: HTMLElement,
ctx: UidMarkdownBlockMountContext,
) => UidMarkdownBlockMountHandle | (() => void) | void
}

/** What a live block's `mount` is told. The contract is the WYSIWYG editor's too. */
export interface UidMarkdownBlockMountContext {
/** The block's source. */
code: string
language: string
/** Always false here: rendered markdown is read-only. */
editable: boolean
/** Ignored here — rendered markdown has no source to write to. */
setCode(code: string): void
}

export interface UidMarkdownBlockMountHandle {
update?(code: string): void
destroy?(): void
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/components/Markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ export type {
UidMarkdownBlockRenderer,
UidMarkdownBlockRenderers,
UidMarkdownBlockResult,
UidMarkdownBlockMountContext,
UidMarkdownBlockMountHandle,
} from './blockRenderers.js'
Loading