GrMarkdown
Берут, когда текст пришёл markdown-строкой и его надо показать оформленным.
Обзор
Когда брать
- текст пришёл markdown-строкой и его надо показать оформленным — заголовки, списки, таблицы и код превращаются в вёрстку, слушающуюся темы страницы, а не в чужой HTML;
- источник недоверенный — комментарий, описание от поставщика, ответ модели: дерево рисуется узлами Vue, сырой HTML экранируется, и санитайзер не нужен, потому что разметке неоткуда взяться;
- документ длинный — статья или README на сотни абзацев: раскладка снимается с того, чего нет на экране, а поиск по странице, якоря и печать продолжают работать;
- текст приходит потоком — ответ языковой модели дописывается по кускам, и
streamingделает цену одного куска независимой от того, сколько уже написано; - оформление нужно своё — проп
componentsподменяет рендерер любого узла: блок кода на подсвечивающий, ссылку на роутерную, картинку на просмотрщик; - картинок в тексте быть не должно — комментарий, отзыв, письмо от постороннего:
preset="minimal"оставляет от картинки её подпись, и удалённая ссылка не превращается ни в пиксель слежения, ни в разъехавшуюся вёрстку.
Когда взять другое
| Нужно | Берите |
|---|---|
| Текст с разметкой правят, а не читают | GrRichText |
| Показать код с подсветкой, а разметку — буквально | GrCodeBlock |
| Сравнить две версии текста | GrDiff |
| Простой текст без разметки в несколько строк | GrTextarea |
| Одно сообщение пользователю, а не часть документа | GrAlert |
| Оглавление сбоку от документа | GrScrollSpy |
Примеры 4 у каждого — живое демо и его код
Выпуск внутри страницы документации
У страницы уже есть свои
h1иh2, поэтомуheading-offsetсдвигает#выпуска доh3, аid-prefixразводит его якоря с якорями страницы. Оглавление — забота страницы:parsedотдаёт заголовки с ихid, и страница прокручивает к нужному свою панель —scrollToHeadingсдвинул бы всю страницу, что уместно для документа, который прокручивается вместе с ней. Таблицы, алерты> [!WARNING]и код собраны из компонентов и токенов ядра, аwrap-codeдержит JSON на виду.Invoices get a lifecycle you can rely on, and two endpoints retire.
POST /v1/invoices/{id}/finalizelocks the lines and assigns the number.- Webhooks carry
invoice.voidedalongsideinvoice.paid.
Event When it fires Retries invoice.paidThe full amount is received 5 over 24 h invoice.voidedA finalized invoice is voided 5 over 24 h Предупреждениеamountis now an integer in minor units:41250means €412.50.{ "id": "in_9421", "amount": 41250, "currency": "EUR", "status": "open" }ОсторожноGET /v1/charges/legacyand/v1/refunds/bulkanswer410 Gonefrom 1 December.See the migration guide for the full list.
Ответ, который ещё пишется
streamingговорит, что источник растёт только в конце, поэтому каждый кусок переразбирает хвост, а не весь ответ, а слотcaretстоит там, где появятся следующие слова.link-clickдаёт админке открыть свою документацию на месте: внутренняя ссылка перехватывается, внешняя уходит как обычно.density="compact"подходит ответу, а не статье.How are seats added in the middle of a month billed?Seats added mid-cycle are prorated: you pay for the days left, not the whole month.
- On the day you add a seat, the price is split by the days left in the period.
- The prorated amount goes onto the next invoice, not a separate one.
- Removing a seat gives credit the same way.
For 3 seats added on 14 October on the Team plan at €12 a seat:
Amount 3 seats × €12 × 18/31 days €20.90 See Proration rules for annual plans.
Answered from the billing docsПисьмо, которое здесь никто не писал
Дерево собирается узлами Vue, поэтому при
html="escape"по умолчанию<img onerror>остаётся текстом — санировать нечего,v-htmlнет.preset="minimal"превращает удалённую картинку в её подпись, и пиксель слежения не грузится, а ссылкаjavascript:не проходит проверку схемы и становится обычным текстом. Настоящая ссылка открывается в новой вкладке черезlink-target.JWJonas Weber · Fabrikam InteriorsTicket #4182 · by email, 09:41Hi team,
the invoice INV-0418 shows the old address. Our new one:
Fabrikam Interiors, Hafenstraße 12, 20457 Hamburg
Here is a photo of the letter: scan
<img src=x onerror="this.src='https://tracker.example.net/open?u=8812'"> — please ignore, our signature tool adds this.
You can also update it yourself or use your portal.
Thanks, Jonas
Записка о передаче дел с задачами
interactive-tasksпревращает квадратики в настоящие флажки, аtask-toggleсообщает, где каждый стоит в исходнике, — страница правит свой текст, и следующее сохранение сохранит отметку.> [!NOTE]становится алертом ядра сlive="off": заметка внутри документа не прерывает диктора.Handover · Lena Novak → Ben Hartley2 of 5 done
API порождается из пакета · разойтись не может
Props
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
source | string | undefined | "" | Исходный markdown. По умолчанию пустая строка — тогда рисуется слот `empty`. |
preset | "gfm" | "commonmark" | "minimal" | undefined | "gfm" | `gfm` — таблицы, задачи, зачёркивание; `commonmark` — без них; `minimal` — ещё и без картинок. |
html | GrMdHtmlMode | undefined | "escape" | Что делать с сырым HTML. Рендера в разметку нет ни при каком значении. |
headingOffset | number | undefined | 0 | Сдвиг уровней заголовков: документ внутри страницы, где `h1` уже занят. |
anchors | boolean | undefined | true | Ссылка-якорь у заголовка. |
idPrefix | string | undefined | "" | Префикс `id` заголовков и сносок — когда документов на странице несколько. |
measure | boolean | undefined | true | Ограничивать ширину текстовых блоков мерой строки. |
density | GrMarkdownDensity | undefined | undefined | Плотность: `comfortable` — статья, `compact` — комментарий. |
size | GrMarkdownSize | undefined | undefined | Ступень размера: кегль и межстрочный текста. По умолчанию `md`; берётся из `GrConfigProvider`. |
linkTarget | string | null | undefined | null | `target` внешних ссылок, например `_blank`. По умолчанию не задаётся. |
linkRel | string | undefined | "nofollow noopener noreferrer" | `rel` внешних ссылок. По умолчанию `nofollow noopener noreferrer`. |
allowedProtocols | readonly string[] | undefined | undefined | Схемы, допустимые в адресах ссылок и картинок; ссылка с другой схемой становится текстом. По умолчанию `http`, `https`, `mailto`, `tel`. |
interactiveTasks | boolean | undefined | false | Чекбоксы задач кликабельны и эмитят `taskToggle` с офсетом в исходнике. |
wrapCode | boolean | undefined | false | Переносить длинные строки кода вместо горизонтальной прокрутки. |
streaming | boolean | undefined | false | Источник дописывается в конец: разбирается только хвост. |
components | GrMarkdownRenderers | undefined | undefined | Подмена рендерера узла: `{ code, link, image, heading, table }`. |
engine | GrMarkdownEngine | undefined | undefined | Движок разбора. По умолчанию встроенный на `marked`; свой синтаксис добавляет `createMarkedEngine({ extensions })`. |
ariaLabel | string | undefined | undefined | Имя области. Не задан — ориентиром документ не объявляется. |
Слоты
| Slot | Type | Описание |
|---|---|---|
empty | any | Пустой источник. По умолчанию не рисуется ничего. |
caret | any | Курсор в конце потока — режим `streaming`. |
События
| Event | Сигнатура | Описание |
|---|---|---|
parsed | (event: "parsed", headings: GrMdHeading[], blocks: number): void | — |
linkClick | (event: "linkClick", event: MouseEvent, href: string | null): void | — |
taskToggle | (event: "taskToggle", offset: number, checked: boolean): void | — |
copy | (event: "copy", language: string | null): void | — |
error | (event: "error", error: unknown): void | — |
Методы
| Methods | Сигнатура | Описание |
|---|---|---|
headings | GrMdHeading[] | Оглавление документа: `id`, уровень и текст каждого заголовка. |
scrollToHeading | (id: string) => void | Прокрутить к заголовку по `id` с `idPrefix`; плавно, если не просили меньше движения. |
plainText | () => string | Текст документа без разметки — для поиска, превью и доступного имени. |
Типы
Все экспортируемые типы подпути, по файлам, где они объявлены. Имена, дженерики и тела читаются из файлов деклараций пакета и разойтись не могут.
import type { GrMarkdownCodeProps, GrMarkdownConfigurableProps, GrMarkdownSize } from '@feugene/granularity-editor/components/GrMarkdown'Файлdist/types/components/GrMarkdown/config.d.ts
Три ребра к ядру, и все объявлены: GrCheckbox у списка задач, GrAlert
у алертов GitHub и GrTable у таблиц GFM.
Правило прежнее — ребро подмешивает потребителю **весь** CSS и safelist
донора, и документ из одних абзацев не должен платить за то, чего в нём нет.
Поэтому таблица, ссылка и цитата остаются семантической разметкой на своих
токенах, а кому нужен GrTable — ставит его пропом components.
Исключения сделаны там, где своя копия **визуального контракта** разошлась бы
с дизайн-системой молча: тон и иконка сообщения, вид чекбокса, скролл-область
и шапка таблицы задаются ядром, а не документом. Копия уже расходилась —
нативный <input> рисовался приглушённым и неквадратным. Цена посчитана:
3.6, 3.5 и 3.4 КБ gzip.
Ссылку сюда не берут по замеру, а не по вкусу: узел самый частый, и на
документе в 300 ссылок инстансы GrLink дают ×4.2 ко времени рендера.
Собственный CSS в cssFiles не объявлен, и объявлять его не нужно:
granumProvider() находит styles.css рядом с компонентом сам и пишет его
в манифест — оттуда его и забирает приложение. tokens.css в dist не
попадает намеренно: он из одних комментариев, hook-токены обязаны остаться
неприсвоенными.
const grMarkdownConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrMarkdown">;Файлdist/types/components/GrMarkdown/GrMarkdownCode.vue.d.ts
Блок кода со своей рамкой.
Отдельный компонент, а не ветка рендер-функции, ровно из-за состояния
«скопировано»: живи оно в родителе, изменение этого флага инвалидировало бы
кэш VNode **всех** блоков документа, и главный механизм перфоманса
(renderNodes → кэш по ключу блока) перестал бы работать от нажатия кнопки.
interface GrMarkdownCodeProps {
code: string;
language?: string | null;
/** Переносить длинные строки вместо горизонтальной прокрутки. */
wrap?: boolean;
}Файлdist/types/components/GrMarkdown/defaults.d.ts
Пропы GrMarkdown, настраиваемые глобально через
<GrConfigProvider :component-defaults="{ GrMarkdown: { … } }">.
Только оформление: источник, политика ссылок и режим HTML задаются на месте —
они про конкретный документ, а не про вид приложения.
Аугментация идёт по месту объявления — через
@feugene/granularity/composables/useGrComponentConfig, а не через реэкспорт
из корня пакета: иначе слияние отваливается молча.
interface GrMarkdownConfigurableProps {
size: GrMarkdownSize;
/**
* Плотность: `comfortable` — статья, `compact` — комментарий в ленте.
* Решение сквозное для приложения, поэтому настраивается глобально.
*/
density: GrMarkdownDensity;
}Файлdist/types/components/GrMarkdown/estimateBlockSize.d.ts
function estimateBlockSize(node: GrMdBlockNode): string;Файлdist/types/components/GrMarkdown/grMarkdownStyles.d.ts
type GrMarkdownSize = 'xs' | 'sm' | 'md' | 'lg';type GrMarkdownDensity = 'comfortable' | 'compact';Safelist пуст. Кегль и цвет корня лежат здесь целыми литералами, и granum
извлекает их сам: модуль уезжает в общий dist/chunks/, но сборка доходит
до него по графу бандла компонента. Список, который здесь был, пережил
UnoCSS-пресет — тот общий чанк не сканировал.
Всё остальное оформление — настоящий CSS в styles.css, а не утилиты: оно
держится на потомках разметки, собранной рендерером, а утилитой можно
пометить только тот узел, который рендер-функция создаёт сама.
const grMarkdownSafelist: string[];Файлdist/types/components/GrMarkdown/GrMarkdown.vue.d.ts
GrMarkdown — показать markdown.
Три несущих решения, и все три видно только в устройстве, а не на экране:
1. дерево рисуется **VNode**, а не строкой HTML. Экранирование делает Vue,
поэтому санитайзера в пакете нет за ненадобностью, а v-html не появится;
2. неизменившийся блок отдаёт **тот же объект VNode**, и patch замыкается
на n1 === n2, не трогая его DOM. Отсюда цена правки — один блок;
3. длинный документ живёт в DOM целиком, а раскладка снимается
content-visibility. Виртуализация убрала бы вместе с узлами поиск по
странице, якоря и печать — для документа это не размен, а поломка.
interface GrMarkdownProps {
/** Исходный markdown. По умолчанию пустая строка — тогда рисуется слот `empty`. */
source?: string;
/** `gfm` — таблицы, задачи, зачёркивание; `commonmark` — без них; `minimal` — ещё и без картинок. */
preset?: 'gfm' | 'commonmark' | 'minimal';
/** Что делать с сырым HTML. Рендера в разметку нет ни при каком значении. */
html?: GrMdHtmlMode;
/** Сдвиг уровней заголовков: документ внутри страницы, где `h1` уже занят. */
headingOffset?: number;
/** Ссылка-якорь у заголовка. */
anchors?: boolean;
/** Префикс `id` заголовков и сносок — когда документов на странице несколько. */
idPrefix?: string;
/** Ограничивать ширину текстовых блоков мерой строки. */
measure?: boolean;
/** Плотность: `comfortable` — статья, `compact` — комментарий. */
density?: GrMarkdownDensity;
/**
* Ступень размера: кегль и межстрочный текста. По умолчанию `md`; берётся из
* `GrConfigProvider`.
*/
size?: GrMarkdownSize;
/** `target` внешних ссылок, например `_blank`. По умолчанию не задаётся. */
linkTarget?: string | null;
/** `rel` внешних ссылок. По умолчанию `nofollow noopener noreferrer`. */
linkRel?: string;
/**
* Схемы, допустимые в адресах ссылок и картинок; ссылка с другой схемой становится текстом.
* По умолчанию `http`, `https`, `mailto`, `tel`.
*/
allowedProtocols?: readonly string[];
/** Чекбоксы задач кликабельны и эмитят `taskToggle` с офсетом в исходнике. */
interactiveTasks?: boolean;
/** Переносить длинные строки кода вместо горизонтальной прокрутки. */
wrapCode?: boolean;
/** Источник дописывается в конец: разбирается только хвост. */
streaming?: boolean;
/** Подмена рендерера узла: `{ code, link, image, heading, table }`. */
components?: GrMarkdownRenderers;
/**
* Движок разбора. По умолчанию встроенный на `marked`; свой синтаксис добавляет
* `createMarkedEngine({ extensions })`.
*/
engine?: GrMarkdownEngine;
/** Имя области. Не задан — ориентиром документ не объявляется. */
ariaLabel?: string;
}interface GrMarkdownEmits {
/** Источник разобран: оглавление и число блоков верхнего уровня. */
(e: 'parsed', headings: GrMdHeading[], blocks: number): void;
/** Клик по ссылке, до перехода: `preventDefault` оставляет навигацию приложению. */
(e: 'linkClick', event: MouseEvent, href: string | null): void;
/** Чекбокс задачи переключён (`interactiveTasks`): офсет задачи в исходнике и новое состояние. */
(e: 'taskToggle', offset: number, checked: boolean): void;
/** Код скопирован кнопкой у блока; `language` — язык блока или `null`. */
(e: 'copy', language: string | null): void;
/** Движок разбора бросил исключение; документ показан пустым. */
(e: 'error', error: unknown): void;
}Файлdist/types/components/GrMarkdown/renderNodes.d.ts
Дерево → VNode. Чистая функция, а не рекурсивный компонент.
Компонент создавал бы инстанс на каждый узел: у документа на 100 КБ это
десятки тысяч инстансов со своими scope и хуками. У функции этой цены нет
вовсе, а её результат можно придержать — на этом стоит кэш блоков.
v-html здесь нет и не будет: экранирование делает Vue, поэтому санитайзер
пакету не нужен.
interface GrMarkdownRenderers {
code?: Component;
link?: Component;
image?: Component;
heading?: Component;
table?: Component;
/**
* Имя типа токена из расширения `marked` — так подключается своя разметка
* для математики, эмодзи или директив. Ключ совпадает с `name` токена.
*/
[name: string]: Component | undefined;
}interface GrMarkdownRenderContext {
t: (key: string, fallback: string) => string;Файлdist/types/markdown/types.d.ts
Нормализованное дерево markdown — то, что видит рендерер.
Своё, а не токены marked: движок объявлен подменяемым (GrMarkdownEngine),
и дерево, повторяющее чужую форму, привязало бы к ней и рендерер, и тесты.
type GrMdAlertTone = 'note' | 'tip' | 'important' | 'warning' | 'caution';type GrMdInline = {
type: 'text';
value: string;
} | {
type: 'strong';
children: GrMdInline[];
} | {
type: 'em';
children: GrMdInline[];
} | {
type: 'del';
children: GrMdInline[];
} | {
type: 'codeSpan';
value: string;
} | {
type: 'link';
href: string | null;
title: string | null;
external: boolean;
children: GrMdInline[];
} | {
type: 'image';
src: string | null;
alt: string;
title: string | null;
} | {
type: 'br';
} | {
type: 'footnoteRef';
label: string;
index: number;
}type GrMdBlockNode = {
type: 'heading';
depth: 1 | 2 | 3 | 4 | 5 | 6;
id: string;
text: string;
children: GrMdInline[];
} | {
type: 'paragraph';
children: GrMdInline[];
}interface GrMdBlock {
/**
* Ключ кэша VNode. Считается от исходного текста блока, **не от индекса**:
* вставка абзаца в начало документа не должна инвалидировать хвост.
*/
key: string;
/** Границы блока в исходнике — для `taskToggle` и синхронизации с редактором. */
offset: number;
end: number;
node: GrMdBlockNode;
}interface GrMdHeading {
id: string;
level: number;
text: string;
}type GrMdHtmlMode = 'escape' | 'strip';interface GrMarkdownParseOptions {
/** Что делать с сырым HTML. Рендера в разметку нет ни при каком значении. */
html?: GrMdHtmlMode;
/** Разрешённые протоколы ссылок и картинок. */
allowedProtocols?: readonly string[];
/** Префикс `id` заголовков и сносок — когда документов на странице несколько. */
idPrefix?: string;
/** Выключить таблицы, картинки и блоки кода. */
preset?: 'gfm' | 'commonmark' | 'minimal';
}Движок разбора. Пакет объявляет потребность, а не поставщика: за интерфейсом
стоит marked, но remark или свой парсер встают на его место, не заставляя
трогать ни рендерер, ни компонент.
interface GrMarkdownEngine {
lex: (source: string, options: GrMarkdownParseOptions) => GrMdBlock[];Использование из прозы библиотеки
Сырой HTML не рендерится никогда
У пропа html два значения: escape (по умолчанию) показывает разметку текстом, strip выбрасывает
её. Третьего нет и не будет — оно потребовало бы v-html, которого в репозитории нет ни разу.
Это не осторожность, а следствие устройства: дерево собирается узлами Vue, а не строкой HTML, и экранирование делает сам Vue. Санитайзер в пакете отсутствует за ненадобностью — вместе с классом дефектов, ради которого его обычно ставят.
Обратная сторона названа прямо: <div> внутри markdown останется текстом. Кому нужен именно
HTML, подменяет рендерер узла html через components и берёт ответственность на себя.
Адрес ссылки проверяется, а не подставляется
javascript:, data:, vbscript: и всё, чего нет в allowedProtocols, до разметки не доходят —
ссылка становится обычным текстом. Проверка идёт после развёртывания сущностей и вычистки незначащих
символов: javascript: и табуляция посреди схемы браузером исполняются, а наивным
startsWith не ловятся.
Внешняя ссылка получает rel="nofollow noopener noreferrer"; target не навязывается — его задаёт
linkTarget.
Перерисовывается один блок, а не документ
Документ делится на блоки верхнего уровня, у каждого ключ от его же исходного текста. Блок, текст которого не изменился, отдаёт тот же объект VNode, и Vue пропускает его целиком, не сравнивая и не трогая DOM.
Отсюда два свойства: правка абзаца в статье на сто килобайт стоит одного абзаца, а вставка блока в начало не пересоздаёт то, что ниже — ключ считается от текста, а не от позиции.
Длинный документ: раскладка снимается, текст остаётся
Виртуализация — окно отрисовки — здесь неверна: вместе с невидимыми узлами она забирает поиск по
странице, переход по якорю в непоказанный раздел, выделение через весь документ и печать. Документ,
в котором Ctrl+F не находит слово, документом быть перестаёт.
Поэтому в DOM живёт всё, а с блоков снимается только раскладка и отрисовка — через
content-visibility. Высота каждого блока оценивается по его содержимому, иначе полоса прокрутки
дёргалась бы при пролистывании.
Заголовки: якорь детерминирован
id заголовка — слаг его текста, а не useId(). Это отступление от общего правила сделано
сознательно: useId выдал бы разные идентификаторы двум рендерам одного документа, и ссылка вида
#раздел из адресной строки или письма перестала бы работать.
Два документа на одной странице развести нечем, кроме idPrefix, — его и задают. headingOffset
сдвигает уровни, когда документ встраивают в страницу, где h1 уже занят.
Три ребра к ядру, и все объявлены
| Синтаксис | Компонент | Чем параметризован |
|---|---|---|
- [ ] и - [x] | GrCheckbox | modelValue — состояние задачи, readonly — пока не включён interactiveTasks |
> [!NOTE] | GrAlert | tone="info" |
> [!TIP] | GrAlert | tone="success" |
> [!IMPORTANT] | GrAlert | tone="primary" |
> [!WARNING] | GrAlert | tone="warning" |
> [!CAUTION] | GrAlert | tone="danger" |
| a | b | | GrTable | regionLabel из локали; шапка и строки идут слотами #header и default |
Маркер алерта разбирается только в тон. Вид сообщения — заливка, рамка, иконка, кегль — приходит
из GrConfigProvider, и своего variant компонент не навязывает: иначе алерт в документе выглядел
бы иначе, чем такой же алерт на странице рядом. Заголовок тона (Note, Warning, …) берётся из
локали пакета — тон обязан читаться текстом, а не только цветом.
Роли живого региона у этих сообщений нет: live="off". role="alert" прерывает чтение диктором,
и сообщение внутри статьи такого права не имеет.
Остальное — заголовок, абзац, список, цитата, ссылка, разделитель, блок кода — остаётся семантической разметкой на своих токенах: ребро подмешивает потребителю весь CSS и safelist донора, и документ из одних абзацев не должен платить за то, чего в нём нет.
Исключения сделаны там, где своя копия задавала бы визуальный контракт, принадлежащий ядру: тон и иконка сообщения, вид отметки, скролл-область и шапка таблицы — решения дизайн-системы, а не документа.
Ссылка сюда не попала по замеру, а не по вкусу: это самый частый инлайновый узел, и на документе в
триста ссылок инстансы GrLink дают четырёхкратное время рендера. Кому нужен его полный контракт —
ставит components.link и платит осознанно.
Алерт при этом идёт live="off": role="alert" прерывает чтение диктором, а сообщение внутри
статьи такого права не имеет. Смысл тона несёт подпись текстом, а не только цвет.
Свой синтаксис — расширением marked, а не форком
Движок принимает расширения, и это единственный способ добавить синтаксис, которого нет в наборе:
const engine = createMarkedEngine({ extensions: [katexExtension] })Берётся у расширения только токенайзер. Его renderer не вызывается
никогда: разметку строит renderNodes из узлов Vue, и строки HTML в пакете не
возникает вовсе. Новый тип токена приезжает узлом custom, а рисует его
компонент из components по имени токена:
<GrMarkdown :source="text" :engine="engine" :components="{ inlineMath: Formula }" />Компонент получает name, raw и все собственные поля токена пропами, а
разобранное содержимое — слотом по умолчанию. Компонента под это имя нет —
исходник печатается текстом: молча терять то, что автор написал, нельзя.
Так подключаются marked-katex-extension, marked-emoji, marked-directive —
не заводя пакету ни одной зависимости.
Пример: выделение маркером
==текст== — выделение маркером, знакомое по Obsidian и Pandoc. Ни в CommonMark, ни в GFM такого
синтаксиса нет, поэтому без расширения парсер его не узнаёт и на экране остаются сами знаки равно.
Двадцать строк закрывают вопрос целиком:
import { createMarkedEngine } from '@feugene/granularity-editor/markdown'
const highlightMark = {
name: 'highlightMark',
level: 'inline' as const,
start: (src: string) => src.indexOf('=='),
tokenizer(src: string) {
const match = /^==([^=\n]+)==/.exec(src)
return match ? { type: 'highlightMark', raw: match[0], value: match[1] } : undefined
},
}
const engine = createMarkedEngine({ extensions: [highlightMark] })<script setup lang="ts">
import { h } from 'vue'
const Mark = (props: { value: string }) => h('mark', props.value)
</script>
<template>
<GrMarkdown :source="text" :engine="engine" :components="{ highlightMark: Mark }" />
</template>Имя компонента в components совпадает с name токена. Поля токена приходят пропами: здесь это
value, у токена математики это была бы формула. Уберите компонент — и ==текст== снова станет
текстом, а не исчезнет.
Диаграммы: движок приносит приложение
Ограда с языком mermaid — это обычный узел code, и рисует его тот компонент, который лежит в
components.code. Пакет о mermaid не знает ни строкой и знать не будет: движок весит больше всей
дизайн-системы, а платить за него обязаны были бы и документы без единой диаграммы.
Рендерер решает по языку и всё чужое отдаёт рендереру по умолчанию:
const MermaidBlock = defineComponent({
props: { code: String, language: String, wrap: Boolean },
setup(props) {
// ...рисование в onMounted, см. демо на витрине
return () => props.language === 'mermaid'
? h('figure', { ref: host })
: h(GrMarkdownCode, { code: props.code, language: props.language, wrap: props.wrap })
},
})Три вещи в этой обвязке не косметика, и каждая стоит своих денег.
Импорт только динамический. Статический import 'mermaid' кладёт движок в общий кусок, и его
скачивает каждый, кто открыл страницу с документом.
layout: 'dagre' в конфиге. Без этой строки mermaid подтягивает раскладку ELK на первый же
flowchart — притом что рисует его dagre. Замер (12.0.0, что реально ушло по сети, gzip):
| Сеанс | Загружено | Первый показ |
|---|---|---|
ядро: import + initialize | 179 КБ | — |
ядро + один flowchart, layout: 'dagre' | 231 КБ | 93 мс |
| то же самое без этой строки | 661 КБ | 532 мс |
Одна строка конфига — минус 430 КБ и впятеро более быстрый первый кадр.
securityLevel: 'strict'. Движок строит SVG сам, и подписи в нём приходят из markdown, то есть
извне. strict вычищает из них разметку; это ровно та граница, ради которой в самом пакете нет
v-html.
Дальше каждый тип диаграммы платит за себя отдельно: sequence — плюс 31 КБ, class — 16 КБ,
state — 14 КБ, pie — 154 КБ. Повторный показ уже загруженного типа: flowchart — 84 мс,
sequence — 28 мс, class — 71 мс, pie — 16 мс. Десяток диаграмм на странице это заметная доля
секунды, поэтому рисовать их лучше по мере появления в зоне видимости, а не все разом.
Нужный тип диаграммы подключать не надо
Встроенные типы — flowchart, sequence, class, state, pie, gantt, er, journey и прочие — приезжают
сами: render() подтягивает грамматику того типа, который встретил, при первом же показе. Отсюда и
разброс цен выше — платит тот, кто нарисовал.
Регистрация нужна только для того, чего в движке нет:
| Что подключают | Чем | Откуда берут |
|---|---|---|
| чужой тип диаграммы | mermaid.registerExternalDiagrams([…]) | отдельным пакетом с npm |
| иконки архитектурных диаграмм | mermaid.registerIconPacks([…]) | пакеты @iconify-json/* |
| своя раскладка вместо dagre | mermaid.registerLayoutLoaders([…]) | своя или @mermaid-js/layout-elk |
Первоисточники, они же ответ на «а как настроить вот это»:
- синтаксис всех типов диаграмм;
- конфигурация целиком — там же
securityLevelиlayout; - тема и
themeVariables— ими диаграмма садится на токены дизайн-системы; - раскладки и ELK;
- API
renderиinitialize.
Цвет для themeVariables берётся из токена в два шага, и это не прихоть: getPropertyValue отдаёт
объявленное значение, а часть палитры объявлена через color-mix(); вычисленное же значение
Chrome отдаёт как color(srgb …), чего разборщик цветов внутри mermaid тоже не понимает.
Рабочий приём — присвоить цвет пробному элементу, прочитать вычисленный и нормализовать канвой;
готовый код есть в демо на витрине.
И вторая ловушка там же: themeVariables слушается только темой base. Встроенные
(default, neutral, forest, dark) задают палитру сами, и подмешивание к ним токенов даёт
смесь двух решений вместо любого из них. Либо base плюс свои переменные — тогда диаграмма
переключается вместе с темой страницы, — либо встроенная тема целиком.
Синтаксис mermaid — не единственный возможный: шов ничего о нём не знает. Для сравнения, nomnoml
весит 24 КБ gzip и рисует за 3.4 мс, но говорит на своём языке, и написанное для mermaid он не
поймёт. Выбор между ними — выбор приложения, а не пакета; менять придётся один компонент.
Пресет режет набор, а не оформление
Три значения, и отличаются они тем, что вообще считается разметкой, а не тем, как она выглядит.
gfm (по умолчанию) — таблицы, списки задач, зачёркивание, автоссылки. commonmark — без них:
строка ~~текст~~ останется тильдами, а | таблицей не станет. minimal — то же самое плюс
картинка превращается в свою подпись.
Последнее нужно там, где текст пишет посторонний. Удалённая картинка в комментарии — это запрос к
чужому серверу, то есть пиксель слежения и утечка IP читателя; а размер её потребитель не
контролирует, и вёрстка страницы едет от одной строки. Выбрасывать картинку молча было бы хуже: пропавшая
иллюстрация оставляет читателя гадать, что там было. Поэтому от неё остаётся её alt — обычным
текстом в потоке.
Пресет не отключает блок кода и не трогает оформление: и то и другое — работа components и
токенов.
Задачи можно не только показывать
interactiveTasks включает чекбоксы списка задач. Эмит taskToggle несёт офсет маркера в
исходнике, а не номер пункта: сопоставлять задачи по порядку значит промахнуться на первом же
вложенном списке. Приложение переписывает markdown по офсету и отдаёт новый source.
Офсет верен на любой вложенности — пункт внутри пункта, список внутри цитаты, отступ табуляцией. Там, где опоры в исходнике нет вовсе (задача внутри определения сноски: оно разбирается вне основного потока), чекбокс остаётся неинтерактивным и эмита не будет — число, указывающее не туда, хуже отсутствующего.
Рисует их GrCheckbox ядра. Своя копия его вида разошлась бы с дизайн-системой молча, а нативный
<input> браузер рисует сам: в состоянии disabled гасит по-своему и растягивается флексом в
неквадрат.
Без interactiveTasks чекбокс идёт readonly, а не disabled: документ показывает состояние
задачи, а не запрещает её, и отметка остаётся в полном цвете.
Имя чекбокса — текст его пункта: aria-labelledby указывает на первый блок пункта. Общее «Task» у
всех диктор читал бы одинаково, и задачи было бы не различить. Вложенный список в имя родителя не
входит — там чужие задачи со своими именами. Пункт без текста — пустой инлайн, картинка без
подписи — называется переведённым «Task», а не пустым именем. Идентификаторы подписей берутся из useId() экземпляра, поэтому два одинаковых
документа на странице их не делят.
Блок с задачами при interactiveTasks — единственный, кого правка выше заставляет
перерисоваться. Ключ кэша считается от текста, и сдвинутый блок узнаётся как тот же, а офсет
маркера запечатан в обработчике чекбокса: из кэша он пришёл бы от прежнего исходника, и
переключение задачи переписало бы чужие символы. При streaming хвост разбирается отдельно, и
офсеты его задач сдвигаются к началу исходника — taskToggle всегда указывает в полный текст.
Курсор потока — в хвосте документа
Слот caret при streaming встаёт внутрь последнего инлайнового ряда, а не отдельной строкой под
блоком: так он читается как «здесь продолжается набор», а не как опечатка. Хвостом считается то, что
поток дописывает прямо сейчас, — последний пункт списка, последняя ячейка таблицы, заголовок,
последний блок цитаты или алерта. Блок кода и подменённые через components рендереры курсор не
принимают: у них своя разметка, и вставлять в неё чужой узел нельзя. Обёртка курсора aria-hidden:
он декоративный, и диктор не читает его ни в абзаце, ни в имени задачи.
Границы
- не редактор: компонент показывает и ничего не меняет в источнике сам. Правка форматированного
текста —
GrRichText, правка markdown как кода —GrCodeEditorизgranularity-code; - без подсветки кода в составе: блок кода нарисован со своей рамкой, ярлыком языка и
копированием, но не раскрашен. Подсветка приходит пропом
components.code— своя илиGrCodeBlock; - без математики и диаграмм в составе:
$…$и блокmermaidиз коробки остаются текстом. Обе вещи тянут сотни килобайт, и место им в расширении — подключаются они разделом выше, а пакет за них не платит; - частотная таблица сущностей: развёрнуты числовые формы и около сорока имён. Редкое имя останется записью — полная таблица весит больше самого компонента.
Пропы этого примера