Files
reliquary/.claude/skills/markdown-embedded-svg/SKILL.md
T
Gabor Körber f396984315 my skills
2026-05-09 22:16:02 +02:00

13 KiB

name, description
name description
markdown-embedded-svg Use when authoring or editing a Markdown file that contains (or should contain) SVG diagrams, charts, or any graphical representation. Trigger whenever (1) writing a report/document/README in Markdown that needs a diagram, flowchart, chart, or other visual, (2) considering adding `<svg>` markup to a `.md` file, (3) editing existing SVG inside Markdown, (4) a user asks for a "diagram", "visual", "chart", or "flowchart" inside a Markdown document. Markdown renderers (GitHub, Gitea, Obsidian, VS Code preview) sanitize inline SVG very differently from each other — GitHub strips it entirely, Gitea allows only `<svg>` + `<path>`, Obsidian (via DOMPurify) allows most of it. Apply this skill proactively before producing the SVG, not after.

SVG in Markdown

Markdown renderers diverge dramatically on what SVG they allow. The safe default is not to embed SVG inline — it's to save the SVG as a separate .svg file and reference it with image-link syntax. Inline SVG should be treated as a special case the user explicitly asks for, not as the default.

This skill encodes the reasoning behind that choice plus the per-renderer rules for when inline SVG is what's wanted.

For any new diagram going into a Markdown document:

  1. Generate the SVG as a standalone .svg file. Common conventions:
    • docs/images/<slug>.svg, assets/<slug>.svg, or alongside the .md itself.
    • Match the project's existing convention if there is one.
  2. Reference it from the Markdown with image-link syntax:
    ![Architecture overview](./images/architecture.svg)
    
  3. The .svg file itself is a normal SVG document — <style>, <circle>, <g>, classes, gradients, animations, anything you'd write in any SVG. The renderer pipeline for standalone SVG files is much more permissive than the inline-Markdown sanitizer (especially on GitHub, where they're handled by entirely different code paths).

This recipe is the only approach that reliably produces a rendered diagram on GitHub (which strips inline SVG categorically — see below). It also works on Gitea and Obsidian and any other Markdown renderer that supports image links.

Use this default unless the user explicitly asks for inline SVG.

Why not inline SVG by default

Inline SVG fails silently and inconsistently across renderers. From source-code analysis (see research/2026-05-05-15-svg-sanitizer-allowlists/ if available locally):

  • GitHub Markdown (html-pipeline + rgrove/sanitize): the allow-list contains zero SVG elements. <svg>, <g>, <path>, <circle> — all stripped. Inline SVG never renders in a .md file on github.com. The element is removed; only its bare text-node children leak through.
  • Gitea (bluemonday default policy): allows only <svg> and <path>. No <rect>, no <circle>, no <g>, no <text>, no gradients, no fills or strokes (the fill and stroke attributes are also stripped). Self-hosted admins can extend the allow-list via app.ini's [markup.sanitizer.N] rules, but defaults are the rule.
  • Obsidian (DOMPurify defaults): allows most SVG — <circle>, <rect>, <ellipse>, <g>, <text>, <defs>, gradients, paths, filters. Empirically confirmed not working in Obsidian: <use>, <style> element, <foreignObject>, <title> element. <script>, event handlers, and javascript: URLs are stripped on every renderer including Obsidian.
  • VS Code Markdown preview (uses markdown-it + DOMPurify-like sanitization): permissive, similar to Obsidian for most things.

The strict cross-renderer intersection for inline SVG is effectively empty — there is no inline-SVG recipe that produces a rendered diagram on GitHub, Gitea, and Obsidian alike. This is why the default has to be the external file.

When the user does ask for inline SVG

If the user explicitly says "embed it inline", "put the SVG directly in the markdown", or similar, follow these rules. Pick the rule set that matches the target renderer. If the target is unknown, ask.

Rule 1: Embed as a raw HTML block, never as a fenced code block

The two paths look similar but produce completely different output.

  • Embed SVG as a raw HTML block. Place <svg …>…</svg> directly in the Markdown source, with a blank line before and after. CommonMark / GFM treat that as a raw HTML block and pass it through verbatim; the browser then parses what survives the sanitizer as real SVG.
  • Never wrap renderable content in ```svg (or ```html, ```xml) fences. Fenced code blocks are by definition "display this as text". The renderer wraps the contents in <pre><code>, HTML-escapes every <, >, and &, and hands the escaped text to a syntax highlighter. The browser sees a string, not markup — no SVG ever gets parsed.
  • Don't assume non-standard "render this code block" extensions exist. Some renderers (GitLab, certain Obsidian plugins, custom static-site setups) special-case ```mermaid or ```svg to render. This is non-standard. GitHub, VS Code's default preview, and CommonMark do not. Default to the raw-HTML-block path.
  • Same rule applies to other inline-renderable HTML. <details>/<summary>, <picture>, <video>, raw <table> with attributes Markdown can't express — all use the raw-HTML-block path, never a fence.

Diagnostic: "I see SVG source code as text" → fence problem. "I see blank space or default-styled shapes" → sanitizer/styling problem (see Rule 2).

Rule 2: Per-renderer element / attribute rules

Element / feature GitHub inline .md Gitea (default) Obsidian Notes
<svg> stripped
<path> The most portable shape primitive.
<rect> Substitute with <path> rectangle on Gitea.
<circle> Substitute with <ellipse rx=R ry=R> (Obsidian only) or <path> arc circle.
<ellipse>
<line>, <polyline>, <polygon>
<text>, <tspan>
<g> Group inheritance dies on Gitea — repeat presentation attrs per element if cross-renderer matters.
<defs>, <linearGradient>, <radialGradient>, <stop>
<marker> Arrowheads only work on Obsidian inline.
<use> (empirically confirmed) Strip on Obsidian was unexpected; treat as not portable.
<style> element (empirically confirmed) Don't use it anywhere. CSS rules don't have a clear scope boundary so sanitizers strip them as injection risk.
<title> element (empirically confirmed)
<foreignObject> (empirically confirmed)
<script>, on* event handlers, javascript: URLs Always stripped, every renderer. Don't use.
class= attribute (for styling) Even when class survives, the <style> block defining the rules doesn't, so styling is lost — use inline presentation attributes.
Inline presentation attributes (fill, stroke, stroke-width, font-size, font-family, text-anchor, stroke-dasharray, etc.) These survive on Obsidian and any sanitizer that allows SVG at all. They never apply on GitHub-inline / Gitea because the elements themselves are stripped.

Reading the table: an Obsidian-only target gives you most of SVG. A Gitea target restricts you to <svg> + <path> (no colors, no text, no groups). A GitHub-inline target gives you nothing — switch to the external-file recipe.

Rule 3: Surviving the sanitizer where SVG is allowed

When the renderer allows SVG (Obsidian, VS Code preview, some self-hosted Gitea with extended app.ini, and the standalone-.svg-file path on GitHub), structure the SVG to survive its sanitizer:

  • Use SVG presentation attributes inline on every element. fill, stroke, stroke-width, stroke-dasharray, font-size, font-weight, font-family, text-anchor. These can't escape the SVG and survive every sanitizer that allows SVG at all.
  • Don't rely on <style> blocks or class= for styling. <style> is stripped on every renderer's inline sanitizer (including Obsidian, empirically). Even if class= survives, the rules are gone, so styling is lost.
  • Prefer longhand over CSS shorthand. Use font-size, font-weight, font-family separately rather than the font: shorthand. The shorthand is CSS-only; longhand maps directly to SVG presentation attributes.
  • <defs> and id references are fine on renderers that allow them at all (Obsidian; not Gitea/GitHub-inline). <marker>, <linearGradient> referenced via url(#id) survive the Obsidian sanitizer.
  • Group-level inheritance works for font-family and fill (set on <g>, inherited by children) on Obsidian. Don't rely on it for cross-renderer; set the attribute on the leaf element.

The visible failure mode is silent: shapes still render, but with browser-default fill (usually black), no stroke, and default font sizing. On dark themes this looks like "nothing showed up"; on light themes like "a row of black blobs with no labels". There is no console error and no Markdown lint warning.

Rule 4: No blank lines inside inline SVG

pulldown-cmark (and most CommonMark parsers) terminate an HTML block at a blank line. Any content after the blank line — even if it's still inside <svg>...</svg> — is parsed as Markdown, not HTML. Indented SVG elements become fenced code blocks (<pre><code>); unindented ones become paragraphs wrapping escaped angle brackets.

This means the SVG source must be one unbroken block with no empty lines between <svg> and </svg>. Use comments (<!-- section: axes -->) instead of blank lines if you need visual separation inside the SVG source.

Diagnostic: "Part of my SVG renders but the rest appears as code or escaped text" → blank-line-induced block break. Remove all blank lines between <svg> and </svg>.

Default size

Generated SVG diagrams should not exceed 750 pixels in width by default — set width (and a matching viewBox) accordingly. Height is unconstrained — pick whatever the content needs. Wider SVGs blow out the column on most Markdown renderers. Override only when the user explicitly asks for wider.

Minimal example (external file recipe — the default)

The architecture is straightforward:

![Architecture overview](./images/architecture.svg)

Each component handles its own state.

Where ./images/architecture.svg is a normal SVG file — use any elements you want; standalone SVG is not subject to the inline-Markdown sanitizer.

Minimal example (inline, Obsidian-friendly — only if user asks)

Some prose before the diagram.

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80" width="200" height="80">
  <g font-family="sans-serif" font-size="12" fill="#222">
    <!-- inputs -->
    <rect x="10" y="20" width="80" height="40" fill="#e8f0fe" stroke="#1a73e8" stroke-width="2"/>
    <text x="50" y="45" text-anchor="middle">Input</text>
    <!-- arrow -->
    <line x1="90" y1="40" x2="130" y2="40" stroke="#1a73e8" stroke-width="2"/>
    <!-- outputs -->
    <rect x="130" y="20" width="60" height="40" fill="#fce8e6" stroke="#d93025" stroke-width="2"/>
    <text x="160" y="45" text-anchor="middle">Output</text>
  </g>
</svg>

Prose after.

Note: blank line before <svg>, blank line after </svg>, no blank lines inside the SVG (use <!-- comments --> for section separation), no fence, all styling via presentation attributes, font longhand on the group. This will render on Obsidian and similar permissive renderers. It will not render on GitHub .md. It will partially render on Gitea (the <svg> survives, the rest is stripped).

Pre-commit checklist

If you went with the external file default:

  • SVG saved as a .svg file in a sensible location.
  • Markdown references it with ![alt](path.svg).
  • width (the rendered width) ≤ 750px unless asked otherwise.

If the user asked for inline SVG:

  • Confirmed the target renderer allows SVG inline (not GitHub .md).
  • <svg> block has a blank line before and after, no surrounding ``` fence.
  • No blank lines between <svg> and </svg> (pulldown-cmark terminates the HTML block at blank lines).
  • No <style>, <title>, <use>, <foreignObject> elements (don't survive Obsidian).
  • No class= for styling, no CSS font: shorthand.
  • Every shape has explicit fill and (where applicable) stroke / stroke-width as inline attributes.
  • No <script>, on* handlers, or javascript: URLs.
  • width ≤ 750px unless asked otherwise.

If the target is Gitea specifically and inline is required:

  • Only <svg> + <path> elements used.
  • No fill, stroke, class, style, transform attributes (stripped by default policy).
  • OR confirmed the Gitea instance has [markup.sanitizer.N] extensions in app.ini permitting more.