diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue
index fad1cda6..4fc1220d 100644
--- a/client/components/editor/editor-markdown.vue
+++ b/client/components/editor/editor-markdown.vue
@@ -228,6 +228,9 @@ import Prism from 'prismjs'
// Mermaid
import mermaid from 'mermaid'
+// Vega
+import { hydrateVega } from '../vega/hydrate'
+
// Helpers
import katexHelper from './common/katex'
import tabsetHelper from './markdown/tabset'
@@ -419,6 +422,7 @@ export default {
if (newValue && !oldValue) {
this.$nextTick(() => {
this.renderMermaidDiagrams()
+ hydrateVega(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark })
Prism.highlightAllUnder(this.$refs.editorPreview)
Array.from(this.$refs.editorPreview.querySelectorAll('pre.line-numbers')).forEach(pre => pre.classList.add('prismjs'))
})
@@ -472,6 +476,7 @@ export default {
this.$nextTick(() => {
tabsetHelper.format()
this.renderMermaidDiagrams()
+ hydrateVega(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark })
Prism.highlightAllUnder(this.$refs.editorPreview)
Array.from(this.$refs.editorPreview.querySelectorAll('pre.line-numbers')).forEach(pre => pre.classList.add('prismjs'))
this.scrollSync(this.cm)
diff --git a/client/components/vega/hydrate.js b/client/components/vega/hydrate.js
new file mode 100644
index 00000000..1872501d
--- /dev/null
+++ b/client/components/vega/hydrate.js
@@ -0,0 +1,148 @@
+const VEGA_SPEC_ATTR = 'data-vega-spec'
+
+const VEGA_VERSION = '6.2.0'
+const VEGA_LITE_VERSION = '6.4.3'
+const VEGA_EMBED_VERSION = '7.1.0'
+
+const CDN_BASE = 'https://cdn.jsdelivr.net/npm'
+const VEGA_SCRIPTS = [
+ { url: `${CDN_BASE}/vega@${VEGA_VERSION}/build/vega.min.js`, global: 'vega' },
+ { url: `${CDN_BASE}/vega-lite@${VEGA_LITE_VERSION}/build/vega-lite.min.js`, global: 'vegaLite' },
+ { url: `${CDN_BASE}/vega-embed@${VEGA_EMBED_VERSION}/build/vega-embed.min.js`, global: 'vegaEmbed' }
+]
+
+let vegaEmbedPromise = null
+
+function loadScript(url) {
+ return new Promise((resolve, reject) => {
+ const existing = document.querySelector(`script[data-vega-cdn="${url}"]`)
+ if (existing) {
+ if (existing.dataset.loaded === 'true') {
+ resolve()
+ return
+ }
+ existing.addEventListener('load', () => resolve())
+ existing.addEventListener('error', () => reject(new Error(`Failed to load ${url}`)))
+ return
+ }
+ const script = document.createElement('script')
+ script.src = url
+ script.async = false
+ script.dataset.vegaCdn = url
+ script.addEventListener('load', () => {
+ script.dataset.loaded = 'true'
+ resolve()
+ })
+ script.addEventListener('error', () => reject(new Error(`Failed to load ${url}`)))
+ document.head.appendChild(script)
+ })
+}
+
+async function loadVegaEmbed() {
+ if (vegaEmbedPromise) {
+ return vegaEmbedPromise
+ }
+ vegaEmbedPromise = (async () => {
+ for (const { url, global } of VEGA_SCRIPTS) {
+ await loadScript(url)
+ if (typeof window[global] === 'undefined') {
+ throw new Error(`Loaded ${url} but ${global} global is undefined`)
+ }
+ }
+ return window.vegaEmbed
+ })().catch(err => {
+ vegaEmbedPromise = null
+ throw err
+ })
+ return vegaEmbedPromise
+}
+
+function readSpecText(el) {
+ if (el.hasAttribute(VEGA_SPEC_ATTR)) {
+ return el.getAttribute(VEGA_SPEC_ATTR)
+ }
+ const text = el.textContent
+ el.setAttribute(VEGA_SPEC_ATTR, text)
+ return text
+}
+
+function renderError(el, message) {
+ const sourceText = el.getAttribute(VEGA_SPEC_ATTR) || el.textContent
+ el.classList.add('vega-error')
+ el.replaceChildren()
+
+ const heading = document.createElement('strong')
+ heading.textContent = 'Vega render error'
+
+ const messageEl = document.createElement('p')
+ messageEl.textContent = message
+
+ const sourceEl = document.createElement('pre')
+ sourceEl.textContent = sourceText
+
+ el.appendChild(heading)
+ el.appendChild(messageEl)
+ el.appendChild(sourceEl)
+}
+
+function normalizeRawCodeBlocks(root) {
+ // Some pipelines (e.g., editor preview) leave fenced vega blocks as
+ // `
…
`. Convert those to the
+ // same `…
` shape produced by the server-
+ // side renderer, so the rest of the hydrator works uniformly.
+ const codeNodes = root.querySelectorAll('pre > code.language-vega, pre > code.language-vega-lite')
+ codeNodes.forEach(code => {
+ const lang = code.classList.contains('language-vega-lite') ? 'vega-lite' : 'vega'
+ const div = document.createElement('div')
+ div.className = lang
+ div.textContent = code.textContent
+ const pre = code.parentElement
+ pre.parentNode.replaceChild(div, pre)
+ })
+}
+
+export async function hydrateVega(rootEl, { darkMode = false } = {}) {
+ const root = rootEl || document
+ normalizeRawCodeBlocks(root)
+ const elements = Array.from(root.querySelectorAll('.vega, .vega-lite'))
+ if (elements.length === 0) {
+ return
+ }
+
+ // Capture spec text before any DOM mutation so re-runs stay idempotent.
+ elements.forEach(el => readSpecText(el))
+
+ let embed
+ try {
+ embed = await loadVegaEmbed()
+ } catch (err) {
+ elements.forEach(el => renderError(el, `Failed to load Vega library: ${err.message}`))
+ // eslint-disable-next-line no-console
+ console.error('[vega] failed to load vega-embed from CDN', err)
+ return
+ }
+
+ const theme = darkMode ? 'dark' : undefined
+
+ await Promise.all(elements.map(async el => {
+ const specText = readSpecText(el)
+ const mode = el.classList.contains('vega-lite') ? 'vega-lite' : 'vega'
+
+ let spec
+ try {
+ spec = JSON.parse(specText)
+ } catch (err) {
+ renderError(el, `Invalid JSON: ${err.message}`)
+ return
+ }
+
+ el.replaceChildren()
+ el.classList.remove('vega-error')
+
+ try {
+ await embed(el, spec, { mode, actions: false, theme })
+ } catch (err) {
+ renderError(el, err.message || String(err))
+ }
+ }))
+}
diff --git a/client/scss/app.scss b/client/scss/app.scss
index 566934ec..d4cb2610 100644
--- a/client/scss/app.scss
+++ b/client/scss/app.scss
@@ -15,6 +15,7 @@
@import 'components/v-dialog';
@import 'components/v-form';
@import 'components/v-tabs';
+@import 'components/vega';
// @import '../libs/twemoji/twemoji-awesome';
// @import '../libs/prism/prism.css';
diff --git a/client/scss/components/vega.scss b/client/scss/components/vega.scss
new file mode 100644
index 00000000..850952b5
--- /dev/null
+++ b/client/scss/components/vega.scss
@@ -0,0 +1,35 @@
+.vega,
+.vega-lite {
+ display: block;
+ margin: 1rem 0;
+ overflow-x: auto;
+}
+
+.vega-error {
+ border: 1px solid #c62828;
+ background: rgba(198, 40, 40, 0.05);
+ color: #c62828;
+ padding: 12px 16px;
+ border-radius: 4px;
+ font-size: 0.9rem;
+
+ strong {
+ display: block;
+ margin-bottom: 4px;
+ }
+
+ p {
+ margin: 4px 0 8px;
+ }
+
+ pre {
+ background: rgba(0, 0, 0, 0.05);
+ padding: 8px;
+ border-radius: 2px;
+ overflow-x: auto;
+ font-size: 0.8rem;
+ margin: 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+}
diff --git a/client/themes/default/components/page.vue b/client/themes/default/components/page.vue
index 76d6baf1..8541238e 100644
--- a/client/themes/default/components/page.vue
+++ b/client/themes/default/components/page.vue
@@ -382,6 +382,7 @@ import Tabset from './tabset.vue'
import NavSidebar from './nav-sidebar.vue'
import Prism from 'prismjs'
import mermaid from 'mermaid'
+import { hydrateVega } from '../../../components/vega/hydrate'
import { get, sync } from 'vuex-pathify'
import _ from 'lodash'
import ClipboardJS from 'clipboard'
@@ -645,6 +646,9 @@ export default {
theme: this.$vuetify.theme.dark ? `dark` : `default`
})
+ // -> Render Vega / Vega-Lite charts
+ hydrateVega(this.$refs.container, { darkMode: this.$vuetify.theme.dark })
+
// -> Handle anchor scrolling
if (window.location.hash && window.location.hash.length > 1) {
if (document.readyState === 'complete') {
diff --git a/docs/superpowers/specs/2026-05-09-vega-renderer-design.md b/docs/superpowers/specs/2026-05-09-vega-renderer-design.md
new file mode 100644
index 00000000..1f22bbae
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-09-vega-renderer-design.md
@@ -0,0 +1,230 @@
+# Vega + Vega-Lite Renderer — Design Spec
+
+**Date:** 2026-05-09
+**Status:** Approved
+**Author:** Tayeb Chlyah
+
+---
+
+## Goal
+
+Add support for [Vega](https://vega.github.io/vega/) and [Vega-Lite](https://vega.github.io/vega-lite/) charts in Wiki.js pages via fenced code blocks, using the official `vega-embed` library. Charts render in both the live page view and the markdown editor preview.
+
+---
+
+## Context
+
+Wiki.js already supports several embedded visualization syntaxes via the same pattern: a server-side rendering module rewrites highlighted fenced blocks into a placeholder ``, and the client mounts a JS library on those divs after the page loads.
+
+Reference modules:
+- `server/modules/rendering/html-mermaid/` — minimal renderer (8-line `init`).
+- Hydration in `client/themes/default/components/page.vue` (live view) and `client/components/editor/editor-markdown.vue` (editor preview).
+
+This spec follows that pattern, with deviations noted where Vega's characteristics differ from Mermaid.
+
+---
+
+## Markdown Syntax
+
+Two fenced-code languages, mapped to two engines:
+
+````markdown
+```vega
+{ ...full Vega spec... }
+```
+
+```vega-lite
+{ ...Vega-Lite spec... }
+```
+````
+
+The fence language unambiguously selects the engine. No auto-detection from `$schema`. Trailing whitespace and surrounding whitespace ignored. Spec body is JSON.
+
+---
+
+## Architecture
+
+### Server modules
+
+Two new rendering modules, modeled on `html-mermaid`:
+
+- `server/modules/rendering/html-vega/`
+ - `definition.yml`: `key: htmlVega`, `dependsOn: htmlCore`, `enabledDefault: true`, no props.
+ - `renderer.js`: matches `pre.prismjs > code.language-vega`, replaces parent `
` with `{spec text}
`.
+- `server/modules/rendering/html-vega-lite/`
+ - Same structure, key `htmlVegaLite`, class `vega-lite`, language `language-vega-lite`.
+
+Modules are independent — each can be enabled/disabled separately.
+
+### Client hydration
+
+Single shared helper module (`client/components/vega/hydrate.js`) exporting:
+
+```
+hydrateVega(rootEl, { darkMode })
+```
+
+Behavior:
+
+1. Query `rootEl.querySelectorAll('.vega, .vega-lite')`.
+2. If empty, return immediately (avoids paying the script-load cost on pages without charts).
+3. Lazily inject three `