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
streamingmakes the cost of one piece independent of how much has already been written; - the styling has to be your own — the
componentsprop 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
| Need | Take |
|---|---|
| The text with markup is edited rather than read | GrRichText |
| Show code with highlighting, and the markup literally | GrCodeBlock |
| Compare two versions of a text | GrDiff |
| Plain text across several lines, with no markup | GrTextarea |
| A single message to the user rather than a part of a document | GrAlert |
| A table of contents beside the document | GrScrollSpy |
Examples 4 each with a live demo and its code
A release inside the docs page
The page already has its
h1andh2, soheading-offsetmoves#of the release down toh3, andid-prefixkeeps its anchors apart from the page’s own. The outline is the page’s:parsedhands over the headings with their ids, and the page scrolls its own panel to one —scrollToHeadingwould 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, andwrap-codekeeps the JSON in sight.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 Warningamountis now an integer in minor units:41250means €412.50.{ "id": "in_9421", "amount": 41250, "currency": "EUR", "status": "open" }CautionGET /v1/charges/legacyand/v1/refunds/bulkanswer410 Gonefrom 1 December.See the migration guide for the full list.
An answer that is still being written
streamingsays the source only grows at the end, so each chunk re-parses the tail rather than the whole answer, and thecaretslot stands where the next words appear.link-clicklets 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.
- 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 docsA reply nobody here wrote
The tree is built from Vue nodes, so with the default
html="escape"the<img onerror>stays text — there is nov-htmlto sanitise.preset="minimal"turns the remote image into its caption, so no tracking pixel loads, and thejavascript:link fails the protocol check and becomes plain text. The real link opens in a new tab throughlink-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
A handover note with tasks
interactive-tasksmakes the boxes real checkboxes, andtask-togglereports 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 withlive="off": a note inside a document does not interrupt the screen reader.Handover · Lena Novak → Ben Hartley2 of 5 done
API generated from the package · cannot drift
Props
| Prop | Type | default | Description |
|---|---|---|---|
source | string | 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. |
html | GrMdHtmlMode | undefined | "escape" | What to do with raw HTML. It is never rendered as markup, whatever the value. |
headingOffset | number | undefined | 0 | Shift of the heading levels: a document inside a page where `h1` is already taken. |
anchors | boolean | undefined | true | An anchor link next to the heading. |
idPrefix | string | undefined | "" | Prefix for heading and footnote `id`s — when there are several documents on the page. |
measure | boolean | undefined | true | Limit the width of text blocks to the line measure. |
density | GrMarkdownDensity | undefined | undefined | Density: `comfortable` — an article, `compact` — a comment. |
size | GrMarkdownSize | undefined | undefined | The size step: the type size and line height of the text. `md` by default; taken from `GrConfigProvider`. |
linkTarget | string | null | undefined | null | The `target` of external links, for example `_blank`. Not set by default. |
linkRel | string | undefined | "nofollow noopener noreferrer" | The `rel` of external links. `nofollow noopener noreferrer` by default. |
allowedProtocols | readonly string[] | undefined | undefined | The schemes allowed in link and image addresses; a link with any other scheme becomes text. `http`, `https`, `mailto`, `tel` by default. |
interactiveTasks | boolean | undefined | false | Task checkboxes are clickable and emit `taskToggle` with the offset in the source. |
wrapCode | boolean | undefined | false | Wrap long code lines instead of scrolling horizontally. |
streaming | boolean | undefined | false | The source is appended to at the end: only the tail is parsed. |
components | GrMarkdownRenderers | undefined | undefined | Substitute for a node renderer: `{ code, link, image, heading, table }`. |
engine | GrMarkdownEngine | undefined | undefined | The parsing engine. By default the built-in one on `marked`; custom syntax is added by `createMarkedEngine({ extensions })`. |
ariaLabel | string | undefined | undefined | Name of the region. Unset — the document is not announced as a landmark. |
Slots
| Slot | Type | Description |
|---|---|---|
empty | any | An empty source. By default nothing is rendered. |
caret | any | The cursor at the end of the stream — `streaming` mode. |
Events
| Event | Signature | Description |
|---|---|---|
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
| Methods | Signature | Description |
|---|---|---|
headings | GrMdHeading[] | The table of contents of the document: the `id`, level and text of every heading. |
scrollToHeading | (id: string) => void | Scroll to a heading by `id` with `idPrefix`; smoothly, unless reduced motion was requested. |
plainText | () => string | The 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 type { GrMarkdownCodeProps, GrMarkdownConfigurableProps, GrMarkdownSize } from '@feugene/granularity-editor/components/GrMarkdown'Filedist/types/components/GrMarkdown/config.d.ts
const grMarkdownConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrMarkdown">;Filedist/types/components/GrMarkdown/GrMarkdownCode.vue.d.ts
interface GrMarkdownCodeProps {
code: string;
language?: string | null;
wrap?: boolean;
}Filedist/types/components/GrMarkdown/defaults.d.ts
interface GrMarkdownConfigurableProps {
size: GrMarkdownSize;
density: GrMarkdownDensity;
}Filedist/types/components/GrMarkdown/estimateBlockSize.d.ts
function estimateBlockSize(node: GrMdBlockNode): string;Filedist/types/components/GrMarkdown/grMarkdownStyles.d.ts
type GrMarkdownSize = 'xs' | 'sm' | 'md' | 'lg';type GrMarkdownDensity = 'comfortable' | 'compact';const grMarkdownSafelist: string[];Filedist/types/components/GrMarkdown/GrMarkdown.vue.d.ts
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;
}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
interface GrMarkdownRenderers {
code?: Component;
link?: Component;
image?: Component;
heading?: Component;
table?: Component;
[name: string]: Component | undefined;
}interface GrMarkdownRenderContext {
t: (key: string, fallback: string) => string;Filedist/types/markdown/types.d.ts
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 {
key: string;
offset: number;
end: number;
node: GrMdBlockNode;
}interface GrMdHeading {
id: string;
level: number;
text: string;
}type GrMdHtmlMode = 'escape' | 'strip';interface GrMarkdownParseOptions {
html?: GrMdHtmlMode;
allowedProtocols?: readonly string[];
idPrefix?: string;
preset?: 'gfm' | 'commonmark' | 'minimal';
}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.
The address of a link is checked rather than substituted
javascript:, data:, vbscript: and everything that is not in allowedProtocols do not reach the
markup — the link becomes plain text. The check runs after the entities are expanded and the
insignificant characters are cleaned out: javascript: and a tab in the middle of the scheme
are executed by the browser, and a naive startsWith does not catch them.
An external link gets rel="nofollow noopener noreferrer"; target is not imposed — it is set by
linkTarget.
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
| Syntax | Component | What it is parameterised with |
|---|---|---|
- [ ] and - [x] | GrCheckbox | modelValue — the state of the task, readonly — until interactiveTasks is switched on |
> [!NOTE] | GrAlert | tone="info" |
> [!TIP] | GrAlert | tone="success" |
> [!IMPORTANT] | GrAlert | tone="primary" |
> [!WARNING] | GrAlert | tone="warning" |
> [!CAUTION] | GrAlert | tone="danger" |
| a | b | | GrTable | regionLabel 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:
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:
<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:
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>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:
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):
| Session | Loaded | First paint |
|---|---|---|
the core: import + initialize | 179 KB | — |
the core + one flowchart, layout: 'dagre' | 231 KB | 93 ms |
| the same without that line | 661 KB | 532 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 in | With what | Where it is taken from |
|---|---|---|
| a diagram type of someone else’s | mermaid.registerExternalDiagrams([…]) | a separate package from npm |
| icons of architecture diagrams | mermaid.registerIconPacks([…]) | the @iconify-json/* packages |
| a layout of your own instead of dagre | mermaid.registerLayoutLoaders([…]) | your own or @mermaid-js/layout-elk |
The primary sources, which are also the answer to “and how do I configure this”:
- the syntax of all diagram types;
- the configuration in full —
securityLevelandlayoutare there too; - the theme and
themeVariables— with them the diagram sits on the tokens of the design system; - layouts and ELK;
- the
renderandinitializeAPI.
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 isGrCodeEditorfromgranularity-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.codeprop — your own orGrCodeBlock; - no maths and no diagrams included:
$…$and amermaidblock 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.
Props in this example