Markdown support in Scribegate¶
Scribegate renders markdown on two surfaces:
| Surface | Engine | Where |
|---|---|---|
| Client (SPA preview, document view, proposal preview, share view) | marked v15 + DOMPurify + Prism + Mermaid |
sg-markdown-view |
| Server (static-site export zip) | Markdig 0.38 + curated extension set | SiteEndpoints.RenderMarkdown |
Both are driven by the same raw markdown string — authors do not choose a renderer. This page documents which features work on both surfaces ("Core" — the safe path), and which work only on one ("Server-only" or "Client-only").
Core (works on both)¶
These features should be safe to use in any document. Authors should expect them to render the same way in the SPA as in exported static sites.
| Feature | Notes |
|---|---|
Headings #..###### |
h1 and h2 get a bottom border on both surfaces |
| Paragraphs, hard breaks | breaks: true on client via marked.use({breaks: true}) |
| Bold, italic, ~~strikethrough~~ | GFM defaults on both |
Inline code |
Rendered as <code> with theme-aware background |
| Fenced code blocks with language | Prism highlighting on both (same palette via --sg-syn-* CSS vars) |
| Blockquotes | |
| Bullet lists, numbered lists | |
GFM task lists - [ ] - [x] |
Rendered as disabled checkboxes on both |
| Pipe tables | UsePipeTables() server / GFM default client |
Autolinks <https://…> and bare URLs |
|
Inline images  |
Bare filenames resolve to MediaAsset via /media/by-name/{fileName} on the SPA. Static-site export bundles referenced media under assets/media/ and rewrites URLs at AST time. |
Footnotes ([^1], [^1]: …) |
Markdig UseFootnotes() server / marked-footnote client |
Definition lists (term / : definition) |
Markdig UseDefinitionLists() server / custom marked extension client |
Emoji shortcodes (:rocket:, :sparkles:) |
Markdig UseEmojiAndSmiley(enableSmileys: false) server / node-emoji via a custom marked extension client |
Mermaid diagrams (```mermaid) |
SPA only (see below). Static-site export leaves the block as code. |
| Relative links between documents | Treated as bare paths on both; behaviour depends on the host page URL. |
Server-only (static-site export)¶
Markdig enables several extensions that marked does not ship by default. These render in exported zip sites but show as plain markdown (or the closest GFM interpretation) in the SPA:
- Abbreviations (
*[HTML]: HyperText Markup Language) - Citations (
""quoted""→<cite>) - Custom containers (
:::fenced blocks) - Figures (blockquotes with attribution become
<figure>) - Grid tables (multi-line column tables)
- Media links via
UseMediaLinks—on the server emits<video>; on the client, marked treats it as an image and it renders broken - Auto-identifiers on headings (
## Foo→<h2 id="foo">) - Emphasis extras (
++inserted++,==marked==,~sub~,^sup^) - List extras (Roman/alpha numeric lists)
Authors who export static sites may use these; authors who work primarily in the web UI should stick to Core.
Client-only¶
- Math (KaTeX) (
$inline$,$$block$$) — the SPA usesmarked-katex-extension+ KaTeX CSS. Markdig'sUseMathematics()is deliberately not enabled on the server because MathML is out of scope for the zip export; math blocks render as literal$…$in the exported HTML. - Mermaid diagrams —
```mermaidblocks are rendered as inline SVG via a lazily-imported Mermaid runtime. The static-site export does not bundle Mermaid (~3 MB per zip) and leaves the block as code. - Syntax highlighting at runtime — Prism runs after the SPA's DOMPurify pass. The static-site export bundles the same Prism core + language set, so the end result is visually equivalent.
Security posture¶
Both surfaces treat document content as untrusted and apply defence in depth:
| Vector | Server handling | Client handling |
|---|---|---|
| Raw HTML in markdown | DisableHtml() on the Markdig pipeline |
DOMPurify with an allow-list of tags and attrs |
javascript: / vbscript: / data: URLs in links |
AST walker rewrites to # after parse |
DOMPurify strips; only http(s)://, mailto:, and relative URLs pass |
onclick / inline event handlers |
UseGenericAttributes() deliberately not enabled |
DOMPurify strips |
| External links | Rendered as-is | afterSanitizeAttributes hook adds target="_blank" + rel="noopener noreferrer" |
| Task-list checkboxes | — | disabled attribute forced on |
| Mermaid SVG output | N/A (server does not render diagrams) | DOMPurify re-sanitises with USE_PROFILES: { svg: true, svgFilters: true } |
UseGenericAttributes is the notable Markdig extension that is deliberately excluded from the server pipeline, because its {#id .class attr=value} syntax lets authors attach arbitrary attributes to generated elements, which would bypass DisableHtml().
Known divergences worth fixing later¶
- ~~
UseMediaLinkson server, plain<img>on client~~ Fixed in M8.sg-markdown-viewwalks the rendered DOM after sanitisation and replaces<img src="*.{mp4,webm,ogg,mov}">with<video controls preload="metadata">(alt text becomesaria-label). Query strings and fragments after the extension are tolerated;data:andblob:URIs are skipped. - ~~Share-link pages (
/s/{token}) do not resolve relative media references~~ Fixed in M8.GET /api/v1/shares/{token}now exposesrepositoryOwnerandrepositorySlug, and a new anonymousGET /api/v1/shares/{token}/media/by-name/{fileName}streams media scoped to the share's repository.sg-markdown-viewaccepts ashareTokenproperty and rewrites relative<img>src values to the share-scoped route when set. - ~~Cross-pipeline parity is still incomplete~~ Fixed in M8.
tests/Scribegate.Web.Tests/Markdown/ParityTheoryTests.csnow asserts byte equality between the two committed goldens for everycorpus.jsonentry taggedparity: "exact". Entries that legitimately diverge (heading auto-ids, GFM task-list classes, XHTML self-close style, table whitespace, raw<tag>escaping) are taggedparity: "diverges"and excluded from the cross check. - ~~KaTeX is eagerly bundled~~ Fixed in M8.
sg-markdown-viewnow importskatex,marked-katex-extension, and the KaTeX CSS lazily vialib/katex-lazy.ts— only whenhasMath(content)matches$…$or$$…$$in the source. The Vite build splits KaTeX into its own chunk (~77 KB gzip JS plus its CSS); math-free pages never load it. The registration is idempotent across content changes.