{
  "slug": "svg-embeds",
  "title": "Inline SVG figures (svgEmbed)",
  "description": "Embed CSS-stylable SVG illustrations in richtext via the SVG figure picker or the {{svg:slug}} shortcode. Backed by the svgEmbed TipTap node.",
  "category": "guides",
  "order": 7,
  "locale": "en",
  "translationGroup": "505112c0-68af-4a30-b09f-499bfc64bde3",
  "helpCardId": null,
  "content": "## Two ways to put an SVG on a page\n\nThe CMS supports SVGs in two different modes depending on what you need:\n\n| Mode | How to insert | Output | Use when |\n|---|---|---|---|\n| **As image** | Media picker → Image insert | `<img src=\"/uploads/foo.svg\">` | Standard logo/icon, simple scaling |\n| **As inline figure** | Toolbar → SVG figure picker | `<figure><svg>…</svg><figcaption>…</figcaption></figure>` | CSS-stylable SVG, dark-mode ready, accessible figure + caption |\n\nThe inline-figure mode is what this page is about. It's why you'd reach for SvgEmbed instead of the normal image insert.\n\n## Why inline SVG?\n\nWhen 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.\n\nExamples that require inline SVG:\n\n- Diagrams in technical articles that need to flip stroke color between light and dark themes\n- Illustrations whose accent color is driven by the same `--accent` CSS variable as the rest of the brand\n- SVGs that should inherit `color` or `font-size` from the parent via `currentColor`\n- Accessible figures with `<title>`, `<desc>`, and a paired `<figcaption>`\n\n## Inserting via the editor\n\n1. Upload your `.svg` file to **Media** (if it's not already there).\n2. In any richtext field, click the **SVG figure** button in the toolbar (the shapes icon next to the snippet button).\n3. The picker modal lists every `.svg` in your media library with a thumbnail preview.\n4. Click one to insert. The file's slug (filename without `.svg`) becomes the shortcode key.\n5. After insertion, type a caption directly under the SVG in the editor. It's optional.\n\nThe 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).\n\n## Markdown shortcode\n\nUnder the hood, the SvgEmbed node serializes to a plain-text shortcode:\n\n```\n{{svg:memex-desk}}\n{{svg:memex-desk|A schematic reading of Bush's proposed desk}}\n```\n\nThe 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.\n\nSlugs must match `[a-z0-9-]+`. The caption after `|` can contain any text except `}` — HTML is escaped automatically at render time.\n\n## Expanding shortcodes at build time\n\nThe `@webhouse/cms` package ships a shared shortcode expander. Import it from your `build.ts`:\n\n```typescript\nimport { expandShortcodes } from '@webhouse/cms';\nimport { join } from 'node:path';\n\nconst html = expandShortcodes(markdownHtml, {\n  uploadsDir: join(import.meta.dirname, 'public', 'uploads'),\n  basePath: process.env.BASE_PATH ?? '',\n});\n```\n\nOptions:\n\n| Option | Purpose | Default |\n|---|---|---|\n| `uploadsDir` | Absolute path to your uploads directory. Required for the default inliner to read SVG files. | `undefined` (falls back to `<img>` reference) |\n| `svgDir` | Sub-directory under `uploadsDir` where SVGs live. | `\"svg\"` |\n| `svgCaptions` | `Record<slug, caption>` for default captions when the shortcode has none. | `{}` |\n| `basePath` | Prepended to any `/uploads/...` URLs the fallback renderer emits. | `\"\"` |\n| `renderSvg` | Full override — `(slug, caption, bp) => string`. Useful for custom wrappers or CDN rewrites. | built-in |\n\nThe 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.\n\n## Asset conventions\n\nTwo practical locations depending on who owns the SVG:\n\n- **User-uploaded SVGs** — `public/uploads/svg/` (or wherever the media library writes). These are normal media assets, available in the picker.\n- **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.\n\nThe shortcode syntax is the same in both cases — only the resolution location changes.\n\n## One-file expand example\n\nMinimal drop-in for a static site:\n\n```typescript\nimport { marked } from 'marked';\nimport { expandShortcodes } from '@webhouse/cms';\nimport { join } from 'node:path';\n\nconst UPLOADS = join(import.meta.dirname, 'public', 'uploads');\n\nexport function renderContent(md: string): string {\n  const html = marked.parse(md, { async: false }) as string;\n  return expandShortcodes(html, { uploadsDir: UPLOADS });\n}\n```\n\nEvery `{{svg:slug}}` becomes an inline `<figure>`. Every `{{svg:slug|caption}}` adds a `<figcaption>`. All five built-in shortcodes (`!!INTERACTIVE`, `!!FILE`, `!!MAP`, `{{snippet:slug}}`, `{{svg:slug}}`) expand in a single pass.\n\n## Why this exists (the bug this fixes)\n\nTipTap 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.\n\nThe `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.\n\n## Rendering on the frontend\n\nThe expander returns inline SVG, so no additional runtime code is needed. Style the figures with normal CSS:\n\n```css\n.cms-svg svg { max-width: 100%; height: auto; }\n.cms-svg figcaption {\n  font-family: monospace;\n  font-size: 0.8rem;\n  color: var(--fg-muted);\n  margin-top: 0.5rem;\n}\n@media (prefers-color-scheme: dark) {\n  .cms-svg svg [stroke=\"#1a1715\"] { stroke: #FAF9F5; }\n}\n```\n\nThe last rule is the payoff — it's impossible with `<img src=…svg>`.",
  "excerpt": "Two ways to put an SVG on a page\n\nThe CMS supports SVGs in two different modes depending on what you need:\n\n| Mode | How to insert | Output | Use when |\n|---|---|---|---|\n| As image | Media picker → Image insert |  | Standard logo/icon, simple scaling |\n| As inline figure | Toolbar → SVG figure pick",
  "seo": {
    "metaTitle": "Inline SVG figures — webhouse.app Docs",
    "metaDescription": "Embed CSS-stylable inline SVG illustrations in richtext via the SVG figure picker or {{svg:slug}} shortcode. Survives the editor's markdown roundtrip.",
    "keywords": [
      "webhouse",
      "cms",
      "richtext",
      "svg",
      "svgEmbed",
      "inline-svg",
      "figure",
      "figcaption",
      "shortcode",
      "tiptap",
      "{{svg:slug}}",
      "expandShortcodes"
    ]
  },
  "createdAt": "2026-04-15T20:00:00.000Z",
  "updatedAt": "2026-04-15T20:00:00.000Z"
}