webhouse.appwebhouse.appdocs

Embed CSS-stylable SVG illustrations in richtext via the SVG figure picker or the {{svg:slug}} shortcode. Backed by the svgEmbed TipTap node.

Two ways to put an SVG on a page

The CMS supports SVGs in two different modes depending on what you need:

ModeHow to insertOutputUse when
As imageMedia picker → Image insert<img src="/uploads/foo.svg">Standard logo/icon, simple scaling
As inline figureToolbar → SVG figure picker<figure><svg>…</svg><figcaption>…</figcaption></figure>CSS-stylable SVG, dark-mode ready, accessible figure + caption

The inline-figure mode is what this page is about. It's why you'd reach for SvgEmbed instead of the normal image insert.

Why inline SVG?

When an SVG is served as <img>, it's a sealed asset — you can't reach into it from CSS. Inline SVG is different: once it's part of the page's DOM, every <path>, <stroke>, <fill> is a CSS target. You can recolor strokes for dark mode, animate sub-paths, or apply currentColor so the illustration follows the surrounding text color.

Examples that require inline SVG:

  • Diagrams in technical articles that need to flip stroke color between light and dark themes
  • Illustrations whose accent color is driven by the same --accent CSS variable as the rest of the brand
  • SVGs that should inherit color or font-size from the parent via currentColor
  • Accessible figures with <title>, <desc>, and a paired <figcaption>

Inserting via the editor

  1. Upload your .svg file to Media (if it's not already there).
  2. In any richtext field, click the SVG figure button in the toolbar (the shapes icon next to the snippet button).
  3. The picker modal lists every .svg in your media library with a thumbnail preview.
  4. Click one to insert. The file's slug (filename without .svg) becomes the shortcode key.
  5. After insertion, type a caption directly under the SVG in the editor. It's optional.

The node appears as a draggable block with the SVG preview and an inline caption input. Delete works with the usual inline confirm (Remove? Yes / No).

Markdown shortcode

Under the hood, the SvgEmbed node serializes to a plain-text shortcode:

{{svg:memex-desk}}
{{svg:memex-desk|A schematic reading of Bush's proposed desk}}

The shortcode is the only representation that survives the TipTap ↔ markdown roundtrip — which is exactly why raw <svg> blocks get stripped on save and shortcodes don't. If you author content in your IDE (instead of the CMS editor), drop the shortcode directly into your markdown and the editor will rehydrate it into a node on load.

Slugs must match [a-z0-9-]+. The caption after | can contain any text except } — HTML is escaped automatically at render time.

Expanding shortcodes at build time

The @webhouse/cms package ships a shared shortcode expander. Import it from your build.ts:

typescript
import { expandShortcodes } from '@webhouse/cms';
import { join } from 'node:path';

const html = expandShortcodes(markdownHtml, {
  uploadsDir: join(import.meta.dirname, 'public', 'uploads'),
  basePath: process.env.BASE_PATH ?? '',
});

Options:

OptionPurposeDefault
uploadsDirAbsolute path to your uploads directory. Required for the default inliner to read SVG files.undefined (falls back to <img> reference)
svgDirSub-directory under uploadsDir where SVGs live."svg"
svgCaptionsRecord<slug, caption> for default captions when the shortcode has none.{}
basePathPrepended to any /uploads/... URLs the fallback renderer emits.""
renderSvgFull override — (slug, caption, bp) => string. Useful for custom wrappers or CDN rewrites.built-in

The built-in renderer reads {uploadsDir}/{svgDir}/{slug}.svg and inlines the file content wrapped in <figure class="cms-svg cms-svg--{slug}">…</figure>. If the file is missing, it falls back to <img src="/uploads/svg/{slug}.svg"> so the page still renders.

Asset conventions

Two practical locations depending on who owns the SVG:

  • User-uploaded SVGspublic/uploads/svg/ (or wherever the media library writes). These are normal media assets, available in the picker.
  • Dev-authored SVGs — a sibling directory like figures/ that you exclude from .gitignore-ed uploads. Pass uploadsDir: 'figures' (or point at your own directory with absolute path) so the expander reads from there.

The shortcode syntax is the same in both cases — only the resolution location changes.

One-file expand example

Minimal drop-in for a static site:

typescript
import { marked } from 'marked';
import { expandShortcodes } from '@webhouse/cms';
import { join } from 'node:path';

const UPLOADS = join(import.meta.dirname, 'public', 'uploads');

export function renderContent(md: string): string {
  const html = marked.parse(md, { async: false }) as string;
  return expandShortcodes(html, { uploadsDir: UPLOADS });
}

Every {{svg:slug}} becomes an inline <figure>. Every {{svg:slug|caption}} adds a <figcaption>. All five built-in shortcodes (!!INTERACTIVE, !!FILE, !!MAP, <!-- snippet not found: slug -->, {{svg:slug}}) expand in a single pass.

Why this exists (the bug this fixes)

TipTap stores a richtext document as a ProseMirror tree. When the editor saves, it serializes the tree back to markdown via tiptap-markdown. Raw HTML blocks like <svg>…</svg> that aren't bound to a TipTap node get treated as loose HTML and dropped silently on re-serialize. Pages with inline SVG figures would lose their illustrations on the next save with no error — just a shrunken content field.

The svgEmbed node fixes this properly. The SVG content lives on disk (referenced by slug); the richtext only carries the shortcode. The roundtrip is lossless because the shortcode is plain text.

Rendering on the frontend

The expander returns inline SVG, so no additional runtime code is needed. Style the figures with normal CSS:

css
.cms-svg svg { max-width: 100%; height: auto; }
.cms-svg figcaption {
  font-family: monospace;
  font-size: 0.8rem;
  color: var(--fg-muted);
  margin-top: 0.5rem;
}
@media (prefers-color-scheme: dark) {
  .cms-svg svg [stroke="#1a1715"] { stroke: #FAF9F5; }
}

The last rule is the payoff — it's impossible with <img src=…svg>.

Tags:MediaRichtextShortcodes
Previous
Content Relationships
Next
Troubleshooting
JSON API →Edit on GitHub →