GrMarkdown

  • editor
  • editor
  • API · 18 props
import { GrMarkdown } from '@feugene/granularity-editor/components/GrMarkdown'

Overview

When to take it

  • the text came as a markdown string and has to be shown formatted — headings, lists, tables and code turn into markup that obeys the theme of the page rather than into someone else’s HTML;
  • the source is untrusted — a comment, a description from a supplier, the answer of a model: the tree is drawn with Vue nodes, raw HTML is escaped, and a sanitiser is not needed because there is nowhere for markup to come from;
  • the document is long — an article or a README of hundreds of paragraphs: the layout is lifted off what is not on the screen, while search on the page, anchors and printing keep working;
  • the text arrives as a stream — the answer of a language model is appended piece by piece, and streaming makes the cost of one piece independent of how much has already been written;
  • the styling has to be your own — the components prop replaces the renderer of any node: the code block with a highlighting one, the link with a router one, the picture with a viewer;
  • there must be no pictures in the text — a comment, a review, a letter from a stranger: preset="minimal" leaves of a picture its caption, and a remote link turns neither into a tracking pixel nor into broken layout.

When to take something else

NeedTake
The text with markup is edited rather than readGrRichText
Show code with highlighting, and the markup literallyGrCodeBlock
Compare two versions of a textGrDiff
Plain text across several lines, with no markupGrTextarea
A single message to the user rather than a part of a documentGrAlert
A table of contents beside the documentGrScrollSpy

Examples 4 each with a live demo and its code

  • A release inside the docs page

    The page already has its h1 and h2, so heading-offset moves # of the release down to h3, and id-prefix keeps its anchors apart from the page’s own. The outline is the page’s: parsed hands over the headings with their ids, and the page scrolls its own panel to one — scrollToHeading would move the whole page, which suits a document that scrolls with it. Tables, > [!WARNING] alerts and code are built from the core’s components and tokens, and wrap-code keeps the JSON in sight.

    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

    Warning

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

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

    Removed

    Caution

    GET /v1/charges/legacy and /v1/refunds/bulk answer 410 Gone from 1 December.

    See the migration guide for the full list.

  • An answer that is still being written

    streaming says the source only grows at the end, so each chunk re-parses the tail rather than the whole answer, and the caret slot stands where the next words appear. link-click lets the admin open its own docs in place: an internal link is caught, an external one leaves as usual. density="compact" suits a reply, not an article.

    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
  • A reply nobody here wrote

    The tree is built from Vue nodes, so with the default html="escape" the <img onerror> stays text — there is no v-html to sanitise. preset="minimal" turns the remote image into its caption, so no tracking pixel loads, and the javascript: link fails the protocol check and becomes plain text. The real link opens in a new tab through 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

  • A handover note with tasks

    interactive-tasks makes the boxes real checkboxes, and task-toggle reports where each box sits in the source, so the page edits its own text and the next save keeps the tick. > [!NOTE] becomes the core’s alert with live="off": a note inside a document does not interrupt the screen reader.

    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
    Note

    Procurement answers on Tuesdays only.

API generated from the package · cannot drift

Props

PropTypedefaultDescription
sourcestring | undefined""The markdown source. An empty string by default — then the `empty` slot is rendered.
preset"gfm" | "commonmark" | "minimal" | undefined"gfm"`gfm` — tables, tasks, strikethrough; `commonmark` — without them; `minimal` — without images as well.
htmlGrMdHtmlMode | undefined"escape"What to do with raw HTML. It is never rendered as markup, whatever the value.
headingOffsetnumber | undefined0Shift of the heading levels: a document inside a page where `h1` is already taken.
anchorsboolean | undefinedtrueAn anchor link next to the heading.
idPrefixstring | undefined""Prefix for heading and footnote `id`s — when there are several documents on the page.
measureboolean | undefinedtrueLimit the width of text blocks to the line measure.
densityGrMarkdownDensity | undefinedundefinedDensity: `comfortable` — an article, `compact` — a comment.
sizeGrMarkdownSize | undefinedundefinedThe size step: the type size and line height of the text. `md` by default; taken from `GrConfigProvider`.
linkTargetstring | null | undefinednullThe `target` of external links, for example `_blank`. Not set by default.
linkRelstring | undefined"nofollow noopener noreferrer"The `rel` of external links. `nofollow noopener noreferrer` by default.
allowedProtocolsreadonly string[] | undefinedundefinedThe schemes allowed in link and image addresses; a link with any other scheme becomes text. `http`, `https`, `mailto`, `tel` by default.
interactiveTasksboolean | undefinedfalseTask checkboxes are clickable and emit `taskToggle` with the offset in the source.
wrapCodeboolean | undefinedfalseWrap long code lines instead of scrolling horizontally.

Slots

SlotTypeDescription
emptyanyAn empty source. By default nothing is rendered.
caretanyThe cursor at the end of the stream — `streaming` mode.

Events

EventSignatureDescription
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

MethodsSignatureDescription
headingsGrMdHeading[]The table of contents of the document: the `id`, level and text of every heading.
scrollToHeading(id: string) => voidScroll to a heading by `id` with `idPrefix`; smoothly, unless reduced motion was requested.
plainText() => stringThe text of the document without markup — for search, previews and the accessible name.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrMarkdownCodeProps, GrMarkdownConfigurableProps, GrMarkdownSize } from '@feugene/granularity-editor/components/GrMarkdown'

Filedist/types/components/GrMarkdown/config.d.ts

grMarkdownConfigconst
const grMarkdownConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrMarkdown">;

Filedist/types/components/GrMarkdown/GrMarkdownCode.vue.d.ts

GrMarkdownCodePropsinterface
interface GrMarkdownCodeProps {
    code: string;
    language?: string | null;
    wrap?: boolean;
}

Filedist/types/components/GrMarkdown/defaults.d.ts

GrMarkdownConfigurablePropsinterface
interface GrMarkdownConfigurableProps {
    size: GrMarkdownSize;
    density: GrMarkdownDensity;
}

Filedist/types/components/GrMarkdown/estimateBlockSize.d.ts

estimateBlockSizefunction
function estimateBlockSize(node: GrMdBlockNode): string;

Filedist/types/components/GrMarkdown/grMarkdownStyles.d.ts

GrMarkdownSizetype
type GrMarkdownSize = 'xs' | 'sm' | 'md' | 'lg';
GrMarkdownDensitytype
type GrMarkdownDensity = 'comfortable' | 'compact';
grMarkdownSafelistconst
const grMarkdownSafelist: string[];

Filedist/types/components/GrMarkdown/GrMarkdown.vue.d.ts

GrMarkdownPropsinterface
interface GrMarkdownProps {
    source?: string;
    preset?: 'gfm' | 'commonmark' | 'minimal';
    html?: GrMdHtmlMode;
    headingOffset?: number;
    anchors?: boolean;
    idPrefix?: string;
    measure?: boolean;
    density?: GrMarkdownDensity;
    size?: GrMarkdownSize;
    linkTarget?: string | null;
    linkRel?: string;
    allowedProtocols?: readonly string[];
    interactiveTasks?: boolean;
    wrapCode?: boolean;
    streaming?: boolean;
    components?: GrMarkdownRenderers;
    engine?: GrMarkdownEngine;
    ariaLabel?: string;
}
GrMarkdownEmitsinterface
interface GrMarkdownEmits {
    (e: 'parsed', headings: GrMdHeading[], blocks: number): void;
    (e: 'linkClick', event: MouseEvent, href: string | null): void;
    (e: 'taskToggle', offset: number, checked: boolean): void;
    (e: 'copy', language: string | null): void;
    (e: 'error', error: unknown): void;
}

Filedist/types/components/GrMarkdown/renderNodes.d.ts

GrMarkdownRenderersinterface
interface GrMarkdownRenderers {
    code?: Component;
    link?: Component;
    image?: Component;
    heading?: Component;
    table?: Component;
    [name: string]: Component | undefined;
}
GrMarkdownRenderContextinterface
interface GrMarkdownRenderContext {
    t: (key: string, fallback: string) => string;

Filedist/types/markdown/types.d.ts

GrMdAlertTonetype
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 {
    key: string;
    offset: number;
    end: number;
    node: GrMdBlockNode;
}
GrMdHeadinginterface
interface GrMdHeading {
    id: string;
    level: number;
    text: string;
}
GrMdHtmlModetype
type GrMdHtmlMode = 'escape' | 'strip';
GrMarkdownParseOptionsinterface
interface GrMarkdownParseOptions {
    html?: GrMdHtmlMode;
    allowedProtocols?: readonly string[];
    idPrefix?: string;
    preset?: 'gfm' | 'commonmark' | 'minimal';
}
GrMarkdownEngineinterface
interface GrMarkdownEngine {
    lex: (source: string, options: GrMarkdownParseOptions) => GrMdBlock[];

Usage from the library prose

Raw HTML is never rendered

The html prop has two values: escape (the default) shows the markup as text, strip throws it away. There is no third one and there will not be — it would require v-html, which does not occur in the repository even once.

This is not caution but a consequence of the design: the tree is assembled from Vue nodes rather than from an HTML string, and the escaping is done by Vue itself. There is no sanitiser in the package because there is no need for one — along with the class of defects it is usually put in for.

The flip side is named directly: a <div> inside markdown will remain text. Whoever needs HTML itself replaces the renderer of the html node through components and takes the responsibility on themselves.

One block is redrawn rather than the document

The document is divided into top-level blocks, each keyed by its own source text. A block whose text has not changed returns the same VNode object, and Vue skips it whole, neither comparing nor touching the DOM.

Hence two properties: an edit of a paragraph in an article of a hundred kilobytes costs one paragraph, and an insertion of a block at the beginning does not recreate what is below — the key is computed from the text rather than from the position.

A long document: the layout is lifted, the text stays

Virtualisation — a rendering window — is wrong here: along with the invisible nodes it takes away search on the page, the jump by an anchor into a section not shown, a selection across the whole document and printing. A document in which Ctrl+F does not find a word stops being a document.

So everything lives in the DOM, and only the layout and the painting are lifted off the blocks — through content-visibility. The height of each block is estimated by its content, otherwise the scrollbar would jump while scrolling.

Headings: the anchor is deterministic

The id of a heading is the slug of its text rather than useId(). This departure from the general rule is deliberate: useId would give different identifiers to two renders of the same document, and a link like #section from the address bar or from a letter would stop working.

There is nothing to tell two documents on one page apart with except idPrefix — so that is what is set. headingOffset shifts the levels when the document is embedded into a page where h1 is already taken.

Three edges to the core, and all of them declared
SyntaxComponentWhat it is parameterised with
- [ ] and - [x]GrCheckboxmodelValue — the state of the task, readonly — until interactiveTasks is switched on
> [!NOTE]GrAlerttone="info"
> [!TIP]GrAlerttone="success"
> [!IMPORTANT]GrAlerttone="primary"
> [!WARNING]GrAlerttone="warning"
> [!CAUTION]GrAlerttone="danger"
| a | b |GrTableregionLabel from the locale; the header and the rows go through the #header and default slots

The marker of an alert is parsed only into a tone. The look of the message — the fill, the border, the icon, the type size — comes from GrConfigProvider, and the component imposes no variant of its own: otherwise an alert in a document would look different from the same alert on the page next to it. The title of the tone (Note, Warning, …) is taken from the locale of the package — the tone must read as text, not only as colour.

These messages have no live-region role: live="off". role="alert" interrupts the reading of a screen reader, and a message inside an article has no such right.

The rest — heading, paragraph, list, quote, link, rule, code block — remains semantic markup on its own tokens: an edge mixes the whole CSS and safelist of the donor into the consumer, and a document of nothing but paragraphs must not pay for what is not in it.

The exceptions are made where a copy of its own would set a visual contract that belongs to the core: the tone and the icon of a message, the look of a check mark, the scroll area and the header of a table — decisions of the design system rather than of the document.

The link did not get here by measurement rather than by taste: it is the most frequent inline node, and on a document of three hundred links the GrLink instances give a fourfold render time. Whoever needs its full contract sets components.link and pays knowingly.

The alert at the same time goes live="off": role="alert" interrupts the reading of a screen reader, and a message inside an article has no such right. The meaning of the tone is carried by the caption in text, not only by the colour.

Syntax of your own — with a marked extension rather than a fork

The engine accepts extensions, and that is the only way to add syntax that is not in the set:

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

Only the tokenizer is taken from the extension. Its renderer is never called: the markup is built by renderNodes from Vue nodes, and no HTML string arises in the package at all. A new type of token arrives as a custom node, and it is drawn by the component from components by the name of the token:

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

The component receives name, raw and all the token’s own fields as props, and the parsed content as the default slot. If there is no component under that name, the source is printed as text: silently losing what the author wrote is not allowed.

That is how marked-katex-extension, marked-emoji, marked-directive are plugged in — without giving the package a single dependency.

Example: highlighting with a marker

==text== is highlighting with a marker, familiar from Obsidian and Pandoc. There is no such syntax in either CommonMark or GFM, so without an extension the parser does not recognise it and the equals signs themselves stay on the screen. Twenty lines close the question entirely:

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>

The name of the component in components matches the name of the token. The fields of the token arrive as props: here it is value, for a maths token it would be the formula. Remove the component — and ==text== becomes text again rather than disappearing.

Diagrams: the engine is brought by the application

A fence with the mermaid language is an ordinary code node, and it is drawn by whatever component lies in components.code. The package does not know a single line about mermaid and will not: the engine weighs more than the whole design system, and documents without a single diagram would be obliged to pay for it too.

The renderer decides by the language and hands everything else to the default renderer:

ts
const MermaidBlock = defineComponent({
  props: { code: String, language: String, wrap: Boolean },
  setup(props) {
    // ...the drawing in onMounted, see the demo on the showcase
    return () => props.language === 'mermaid'
      ? h('figure', { ref: host })
      : h(GrMarkdownCode, { code: props.code, language: props.language, wrap: props.wrap })
  },
})

Three things in this wiring are not cosmetics, and each is worth its money.

The import is dynamic only. A static import 'mermaid' puts the engine into the shared chunk, and it is downloaded by everyone who opened a page with a document.

layout: 'dagre' in the config. Without this line mermaid pulls in the ELK layout on the very first flowchart — even though it is dagre that draws it. The measurement (12.0.0, what actually went over the network, gzip):

SessionLoadedFirst paint
the core: import + initialize179 KB—
the core + one flowchart, layout: 'dagre'231 KB93 ms
the same without that line661 KB532 ms

One line of config — minus 430 KB and a first frame five times faster.

securityLevel: 'strict'. The engine builds the SVG itself, and the labels in it come from markdown, that is, from outside. strict cleans the markup out of them; this is exactly the boundary for the sake of which there is no v-html in the package itself.

Further on each type of diagram pays for itself separately: sequence — plus 31 KB, class — 16 KB, state — 14 KB, pie — 154 KB. A repeated paint of a type already loaded: flowchart — 84 ms, sequence — 28 ms, class — 71 ms, pie — 16 ms. A dozen diagrams on a page is a noticeable fraction of a second, so it is better to draw them as they enter the viewport rather than all at once.

The needed type of diagram does not have to be plugged in

The built-in types — flowchart, sequence, class, state, pie, gantt, er, journey and the rest — arrive on their own: render() pulls in the grammar of the type it met at the very first paint. Hence the spread of prices above — whoever drew pays.

Registration is needed only for what is not in the engine:

What is plugged inWith whatWhere it is taken from
a diagram type of someone else’smermaid.registerExternalDiagrams([…])a separate package from npm
icons of architecture diagramsmermaid.registerIconPacks([…])the @iconify-json/* packages
a layout of your own instead of dagremermaid.registerLayoutLoaders([…])your own or @mermaid-js/layout-elk

The primary sources, which are also the answer to “and how do I configure this”:

The colour for themeVariables is taken from a token in two steps, and that is not a whim: getPropertyValue returns the declared value, and part of the palette is declared through color-mix(); the computed value, in turn, Chrome returns as color(srgb …), which the colour parser inside mermaid does not understand either. The working trick is to assign the colour to a probe element, read the computed one and normalise it with a canvas; the ready code is in the demo on the showcase.

And the second trap is right there: themeVariables is obeyed only by the base theme. The built-in ones (default, neutral, forest, dark) set the palette themselves, and mixing tokens into them gives a blend of two decisions instead of either. Either base plus variables of your own — then the diagram switches along with the theme of the page — or a built-in theme in full.

The mermaid syntax is not the only one possible: the seam knows nothing about it. For comparison, nomnoml weighs 24 KB gzip and draws in 3.4 ms, but speaks its own language, and what was written for mermaid it will not understand. The choice between them is the application’s choice rather than the package’s; one component will have to be changed.

The preset cuts the set rather than the styling

Three values, and they differ in what counts as markup at all rather than in how it looks.

gfm (the default) — tables, task lists, strikethrough, autolinks. commonmark — without them: the string ~~text~~ will stay tildes, and | will not become a table. minimal — the same plus a picture turns into its caption.

The last is needed where the text is written by a stranger. A remote picture in a comment is a request to someone else’s server, that is, a tracking pixel and a leak of the reader’s IP; and its size the consumer does not control, and the layout of the page slides from a single line. Throwing the picture away silently would be worse: a vanished illustration leaves the reader guessing what was there. So what remains of it is its alt — as plain text in the flow.

The preset does not switch off the code block and does not touch the styling: both are the work of components and tokens.

Tasks can be more than shown

interactiveTasks switches on the checkboxes of a task list. The taskToggle emit carries the offset of the marker in the source rather than the number of the item: matching tasks by order means missing on the very first nested list. The application rewrites the markdown by the offset and gives a new source.

The offset is correct at any nesting — an item inside an item, a list inside a quote, indentation with a tab. Where there is no footing in the source at all (a task inside a footnote definition: it is parsed outside the main flow), the checkbox stays non-interactive and there will be no emit — a number that points to the wrong place is worse than none.

They are drawn by the GrCheckbox of the core. A copy of its look of its own would diverge from the design system silently, and a native <input> is drawn by the browser itself: in the disabled state it dims in its own way and is stretched by flex into a non-square.

Without interactiveTasks the checkbox goes readonly rather than disabled: the document shows the state of the task rather than forbidding it, and the check mark stays in full colour.

The name of the checkbox is the text of its item: aria-labelledby points at the first block of the item. A shared “Task” on every one would be read out the same by a screen reader, and the tasks could not be told apart. A nested list does not enter the parent’s name — those are other tasks with names of their own. An item without text — an empty inline, a picture without a caption — is named with the translated “Task” rather than an empty name. The ids of the labels come from the instance’s useId(), so two identical documents on one page do not share them.

A task block under interactiveTasks is the only one that an edit above forces to redraw. The cache key is computed from the text, and a shifted block is recognised as the same one, while the offset of the marker is sealed into the checkbox handler: from the cache it would come from the previous source, and toggling a task would rewrite someone else’s characters. With streaming the tail is parsed separately, and the offsets of its tasks are shifted to the start of the source — taskToggle always points into the full text.

The stream caret sits at the tail of the document

The caret slot under streaming goes inside the last inline row rather than on a line of its own below the block: that way it reads as “typing continues here” rather than as a typo. The tail is what the stream is appending right now — the last list item, the last table cell, a heading, the last block of a quote or an alert. The code block and renderers replaced through components do not take the caret: they have markup of their own, and putting someone else’s node into it is not allowed. The caret wrapper is aria-hidden: it is decorative, and a screen reader reads it neither in a paragraph nor in the name of a task.

Limits
  • not an editor: the component shows and changes nothing in the source itself. Editing formatted text is GrRichText, editing markdown as code is GrCodeEditor from granularity-code;
  • no code highlighting included: the code block is drawn with its own frame, language label and copying, but is not coloured. Highlighting comes through the components.code prop — your own or GrCodeBlock;
  • no maths and no diagrams included: $…$ and a mermaid block stay text out of the box. Both pull in hundreds of kilobytes, and their place is in an extension — they are plugged in a section above, and the package does not pay for them;
  • a frequency table of entities: the numeric forms and about forty names are expanded. A rare name will remain as written — a full table weighs more than the component itself.