From 9473a49837ef484a9b0caed50b9f3bf03b7ee175 Mon Sep 17 00:00:00 2001 From: Tayeb Chlyah Date: Sat, 9 May 2026 07:03:33 +0400 Subject: [PATCH] feat(client, server) #11: add vega + vega-lite renderer Design spec: docs/superpowers/specs/2026-05-09-vega-renderer-design.md --- client/components/editor/editor-markdown.vue | 5 + client/components/vega/hydrate.js | 148 +++++++++++ client/scss/app.scss | 1 + client/scss/components/vega.scss | 35 +++ client/themes/default/components/page.vue | 4 + .../specs/2026-05-09-vega-renderer-design.md | 230 ++++++++++++++++++ .../rendering/html-vega-lite/definition.yml | 8 + .../rendering/html-vega-lite/renderer.js | 8 + .../rendering/html-vega/definition.yml | 8 + .../modules/rendering/html-vega/renderer.js | 8 + server/test/rendering/vega.test.js | 56 +++++ 11 files changed, 511 insertions(+) create mode 100644 client/components/vega/hydrate.js create mode 100644 client/scss/components/vega.scss create mode 100644 docs/superpowers/specs/2026-05-09-vega-renderer-design.md create mode 100644 server/modules/rendering/html-vega-lite/definition.yml create mode 100644 server/modules/rendering/html-vega-lite/renderer.js create mode 100644 server/modules/rendering/html-vega/definition.yml create mode 100644 server/modules/rendering/html-vega/renderer.js create mode 100644 server/test/rendering/vega.test.js 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 `