GrMarkdown

  • editor
  • Редактор
  • API · 18 props

Берут, когда текст пришёл markdown-строкой и его надо показать оформленным.

import { GrMarkdown } from '@feugene/granularity-editor/components/GrMarkdown'

Обзор

Когда брать

  • текст пришёл 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 на виду.

    2026-10-01

    Invoices get a lifecycle you can rely on, and two endpoints retire.

    New

    • POST /v1/invoices/{id}/finalize locks the lines and assigns the number.
    • Webhooks carry invoice.voided alongside invoice.paid.
    EventWhen it firesRetries
    invoice.paidThe full amount is received5 over 24 h
    invoice.voidedA finalized invoice is voided5 over 24 h

    Changed

    Предупреждение

    amount is now an integer in minor units: 41250 means €412.50.

    json
    { "id": "in_9421", "amount": 41250, "currency": "EUR", "status": "open" }

    Removed

    Осторожно

    GET /v1/charges/legacy and /v1/refunds/bulk answer 410 Gone from 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.

    1. On the day you add a seat, the price is split by the days left in the period.
    2. The prorated amount goes onto the next invoice, not a separate one.
    3. 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:41

    Hi 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
    Northwind Traders — handover
    • Renewal call booked for 22 October
    • Price list 2027 sent to procurement
    • Credit note for INV-0402 approved
    • SEPA mandate re-signed after the bank change
    • New billing contact added in the portal
    Заметка

    Procurement answers on Tuesdays only.

API порождается из пакета · разойтись не может

Props

PropTypeпо умолчаниюОписание
sourcestring | undefined""Исходный markdown. По умолчанию пустая строка — тогда рисуется слот `empty`.
preset"gfm" | "commonmark" | "minimal" | undefined"gfm"`gfm` — таблицы, задачи, зачёркивание; `commonmark` — без них; `minimal` — ещё и без картинок.
htmlGrMdHtmlMode | undefined"escape"Что делать с сырым HTML. Рендера в разметку нет ни при каком значении.
headingOffsetnumber | undefined0Сдвиг уровней заголовков: документ внутри страницы, где `h1` уже занят.
anchorsboolean | undefinedtrueСсылка-якорь у заголовка.
idPrefixstring | undefined""Префикс `id` заголовков и сносок — когда документов на странице несколько.
measureboolean | undefinedtrueОграничивать ширину текстовых блоков мерой строки.
densityGrMarkdownDensity | undefinedundefinedПлотность: `comfortable` — статья, `compact` — комментарий.
sizeGrMarkdownSize | undefinedundefinedСтупень размера: кегль и межстрочный текста. По умолчанию `md`; берётся из `GrConfigProvider`.
linkTargetstring | null | undefinednull`target` внешних ссылок, например `_blank`. По умолчанию не задаётся.
linkRelstring | undefined"nofollow noopener noreferrer"`rel` внешних ссылок. По умолчанию `nofollow noopener noreferrer`.
allowedProtocolsreadonly string[] | undefinedundefinedСхемы, допустимые в адресах ссылок и картинок; ссылка с другой схемой становится текстом. По умолчанию `http`, `https`, `mailto`, `tel`.
interactiveTasksboolean | undefinedfalseЧекбоксы задач кликабельны и эмитят `taskToggle` с офсетом в исходнике.
wrapCodeboolean | undefinedfalseПереносить длинные строки кода вместо горизонтальной прокрутки.

Слоты

SlotTypeОписание
emptyanyПустой источник. По умолчанию не рисуется ничего.
caretanyКурсор в конце потока — режим `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СигнатураОписание
headingsGrMdHeading[]Оглавление документа: `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

grMarkdownConfigconst

Три ребра к ядру, и все объявлены: 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

GrMarkdownCodePropsinterface

Блок кода со своей рамкой. Отдельный компонент, а не ветка рендер-функции, ровно из-за состояния «скопировано»: живи оно в родителе, изменение этого флага инвалидировало бы кэш VNode **всех** блоков документа, и главный механизм перфоманса (renderNodes → кэш по ключу блока) перестал бы работать от нажатия кнопки.

interface GrMarkdownCodeProps {
    code: string;
    language?: string | null;
    /** Переносить длинные строки вместо горизонтальной прокрутки. */
    wrap?: boolean;
}

Файлdist/types/components/GrMarkdown/defaults.d.ts

GrMarkdownConfigurablePropsinterface

Пропы 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

estimateBlockSizefunction
function estimateBlockSize(node: GrMdBlockNode): string;

Файлdist/types/components/GrMarkdown/grMarkdownStyles.d.ts

GrMarkdownSizetype
type GrMarkdownSize = 'xs' | 'sm' | 'md' | 'lg';
GrMarkdownDensitytype
type GrMarkdownDensity = 'comfortable' | 'compact';
grMarkdownSafelistconst

Safelist пуст. Кегль и цвет корня лежат здесь целыми литералами, и granum извлекает их сам: модуль уезжает в общий dist/chunks/, но сборка доходит до него по графу бандла компонента. Список, который здесь был, пережил UnoCSS-пресет — тот общий чанк не сканировал. Всё остальное оформление — настоящий CSS в styles.css, а не утилиты: оно держится на потомках разметки, собранной рендерером, а утилитой можно пометить только тот узел, который рендер-функция создаёт сама.

const grMarkdownSafelist: string[];

Файлdist/types/components/GrMarkdown/GrMarkdown.vue.d.ts

GrMarkdownPropsinterface

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;
}
GrMarkdownEmitsinterface
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

GrMarkdownRenderersinterface

Дерево → VNode. Чистая функция, а не рекурсивный компонент. Компонент создавал бы инстанс на каждый узел: у документа на 100 КБ это десятки тысяч инстансов со своими scope и хуками. У функции этой цены нет вовсе, а её результат можно придержать — на этом стоит кэш блоков. v-html здесь нет и не будет: экранирование делает Vue, поэтому санитайзер пакету не нужен.

interface GrMarkdownRenderers {
    code?: Component;
    link?: Component;
    image?: Component;
    heading?: Component;
    table?: Component;
    /**
     * Имя типа токена из расширения `marked` — так подключается своя разметка
     * для математики, эмодзи или директив. Ключ совпадает с `name` токена.
     */
    [name: string]: Component | undefined;
}
GrMarkdownRenderContextinterface
interface GrMarkdownRenderContext {
    t: (key: string, fallback: string) => string;

Файлdist/types/markdown/types.d.ts

GrMdAlertTonetype

Нормализованное дерево markdown — то, что видит рендерер. Своё, а не токены marked: движок объявлен подменяемым (GrMarkdownEngine), и дерево, повторяющее чужую форму, привязало бы к ней и рендерер, и тесты.

type GrMdAlertTone = 'note' | 'tip' | 'important' | 'warning' | 'caution';
GrMdInlinetype
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;
}
GrMdBlockNodetype
type GrMdBlockNode = {
    type: 'heading';
    depth: 1 | 2 | 3 | 4 | 5 | 6;
    id: string;
    text: string;
    children: GrMdInline[];
} | {
    type: 'paragraph';
    children: GrMdInline[];
}
GrMdBlockinterface
interface GrMdBlock {
    /**
     * Ключ кэша VNode. Считается от исходного текста блока, **не от индекса**:
     * вставка абзаца в начало документа не должна инвалидировать хвост.
     */
    key: string;
    /** Границы блока в исходнике — для `taskToggle` и синхронизации с редактором. */
    offset: number;
    end: number;
    node: GrMdBlockNode;
}
GrMdHeadinginterface
interface GrMdHeading {
    id: string;
    level: number;
    text: string;
}
GrMdHtmlModetype
type GrMdHtmlMode = 'escape' | 'strip';
GrMarkdownParseOptionsinterface
interface GrMarkdownParseOptions {
    /** Что делать с сырым HTML. Рендера в разметку нет ни при каком значении. */
    html?: GrMdHtmlMode;
    /** Разрешённые протоколы ссылок и картинок. */
    allowedProtocols?: readonly string[];
    /** Префикс `id` заголовков и сносок — когда документов на странице несколько. */
    idPrefix?: string;
    /** Выключить таблицы, картинки и блоки кода. */
    preset?: 'gfm' | 'commonmark' | 'minimal';
}
GrMarkdownEngineinterface

Движок разбора. Пакет объявляет потребность, а не поставщика: за интерфейсом стоит 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, до разметки не доходят — ссылка становится обычным текстом. Проверка идёт после развёртывания сущностей и вычистки незначащих символов: &#x6a;avascript: и табуляция посреди схемы браузером исполняются, а наивным startsWith не ловятся.

Внешняя ссылка получает rel="nofollow noopener noreferrer"; target не навязывается — его задаёт linkTarget.

Перерисовывается один блок, а не документ

Документ делится на блоки верхнего уровня, у каждого ключ от его же исходного текста. Блок, текст которого не изменился, отдаёт тот же объект VNode, и Vue пропускает его целиком, не сравнивая и не трогая DOM.

Отсюда два свойства: правка абзаца в статье на сто килобайт стоит одного абзаца, а вставка блока в начало не пересоздаёт то, что ниже — ключ считается от текста, а не от позиции.

Длинный документ: раскладка снимается, текст остаётся

Виртуализация — окно отрисовки — здесь неверна: вместе с невидимыми узлами она забирает поиск по странице, переход по якорю в непоказанный раздел, выделение через весь документ и печать. Документ, в котором Ctrl+F не находит слово, документом быть перестаёт.

Поэтому в DOM живёт всё, а с блоков снимается только раскладка и отрисовка — через content-visibility. Высота каждого блока оценивается по его содержимому, иначе полоса прокрутки дёргалась бы при пролистывании.

Заголовки: якорь детерминирован

id заголовка — слаг его текста, а не useId(). Это отступление от общего правила сделано сознательно: useId выдал бы разные идентификаторы двум рендерам одного документа, и ссылка вида #раздел из адресной строки или письма перестала бы работать.

Два документа на одной странице развести нечем, кроме idPrefix, — его и задают. headingOffset сдвигает уровни, когда документ встраивают в страницу, где h1 уже занят.

Три ребра к ядру, и все объявлены
СинтаксисКомпонентЧем параметризован
- [ ] и - [x]GrCheckboxmodelValue — состояние задачи, readonly — пока не включён interactiveTasks
> [!NOTE]GrAlerttone="info"
> [!TIP]GrAlerttone="success"
> [!IMPORTANT]GrAlerttone="primary"
> [!WARNING]GrAlerttone="warning"
> [!CAUTION]GrAlerttone="danger"
| a | b |GrTableregionLabel из локали; шапка и строки идут слотами #header и default

Маркер алерта разбирается только в тон. Вид сообщения — заливка, рамка, иконка, кегль — приходит из GrConfigProvider, и своего variant компонент не навязывает: иначе алерт в документе выглядел бы иначе, чем такой же алерт на странице рядом. Заголовок тона (Note, Warning, …) берётся из локали пакета — тон обязан читаться текстом, а не только цветом.

Роли живого региона у этих сообщений нет: live="off". role="alert" прерывает чтение диктором, и сообщение внутри статьи такого права не имеет.

Остальное — заголовок, абзац, список, цитата, ссылка, разделитель, блок кода — остаётся семантической разметкой на своих токенах: ребро подмешивает потребителю весь CSS и safelist донора, и документ из одних абзацев не должен платить за то, чего в нём нет.

Исключения сделаны там, где своя копия задавала бы визуальный контракт, принадлежащий ядру: тон и иконка сообщения, вид отметки, скролл-область и шапка таблицы — решения дизайн-системы, а не документа.

Ссылка сюда не попала по замеру, а не по вкусу: это самый частый инлайновый узел, и на документе в триста ссылок инстансы GrLink дают четырёхкратное время рендера. Кому нужен его полный контракт — ставит components.link и платит осознанно.

Алерт при этом идёт live="off": role="alert" прерывает чтение диктором, а сообщение внутри статьи такого права не имеет. Смысл тона несёт подпись текстом, а не только цвет.

Свой синтаксис — расширением marked, а не форком

Движок принимает расширения, и это единственный способ добавить синтаксис, которого нет в наборе:

ts
const engine = createMarkedEngine({ extensions: [katexExtension] })

Берётся у расширения только токенайзер. Его renderer не вызывается никогда: разметку строит renderNodes из узлов Vue, и строки HTML в пакете не возникает вовсе. Новый тип токена приезжает узлом custom, а рисует его компонент из components по имени токена:

vue
<GrMarkdown :source="text" :engine="engine" :components="{ inlineMath: Formula }" />

Компонент получает name, raw и все собственные поля токена пропами, а разобранное содержимое — слотом по умолчанию. Компонента под это имя нет — исходник печатается текстом: молча терять то, что автор написал, нельзя.

Так подключаются marked-katex-extension, marked-emoji, marked-directive — не заводя пакету ни одной зависимости.

Пример: выделение маркером

==текст== — выделение маркером, знакомое по Obsidian и Pandoc. Ни в CommonMark, ни в GFM такого синтаксиса нет, поэтому без расширения парсер его не узнаёт и на экране остаются сами знаки равно. Двадцать строк закрывают вопрос целиком:

ts
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] })
vue
<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 не знает ни строкой и знать не будет: движок весит больше всей дизайн-системы, а платить за него обязаны были бы и документы без единой диаграммы.

Рендерер решает по языку и всё чужое отдаёт рендереру по умолчанию:

ts
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 + initialize179 КБ—
ядро + один 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/*
своя раскладка вместо dagremermaid.registerLayoutLoaders([…])своя или @mermaid-js/layout-elk

Первоисточники, они же ответ на «а как настроить вот это»:

Цвет для 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 из коробки остаются текстом. Обе вещи тянут сотни килобайт, и место им в расширении — подключаются они разделом выше, а пакет за них не платит;
  • частотная таблица сущностей: развёрнуты числовые формы и около сорока имён. Редкое имя останется записью — полная таблица весит больше самого компонента.