diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index 4fc1220d..9e30ef69 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -231,6 +231,10 @@ import mermaid from 'mermaid' // Vega import { hydrateVega } from '../vega/hydrate' +// AntV Infographic +import { hydrateInfographic } from '../infographic/hydrate' +import infographicPlugin from '../../../server/modules/rendering/markdown-infographic/plugin' + // Helpers import katexHelper from './common/katex' import tabsetHelper from './markdown/tabset' @@ -285,6 +289,7 @@ const md = new MarkdownIt({ .use(mdMark) .use(mdFootnote) .use(mdImsize) + .use(infographicPlugin) // DOMPurify fix for draw.io DOMPurify.addHook('uponSanitizeElement', (elm) => { @@ -423,6 +428,7 @@ export default { this.$nextTick(() => { this.renderMermaidDiagrams() hydrateVega(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark }) + hydrateInfographic(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')) }) @@ -477,6 +483,7 @@ export default { tabsetHelper.format() this.renderMermaidDiagrams() hydrateVega(this.$refs.editorPreview, { darkMode: this.$vuetify.theme.dark }) + hydrateInfographic(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/infographic/hydrate.js b/client/components/infographic/hydrate.js new file mode 100644 index 00000000..978b563a --- /dev/null +++ b/client/components/infographic/hydrate.js @@ -0,0 +1,167 @@ +const SPEC_ATTR = 'data-infographic-spec' +const OPTS_ATTR = 'data-opts' +const RENDERED_ATTR = 'data-infographic-rendered' + +const INFOGRAPHIC_VERSION = '0.2.19' +const CDN_URL = `https://cdn.jsdelivr.net/npm/@antv/infographic@${INFOGRAPHIC_VERSION}/dist/infographic.min.js` +const GLOBAL_NAME = 'AntVInfographic' + +const ALLOWED_OPT_KEYS = new Set(['width', 'height', 'theme']) + +let libraryPromise = null + +function loadScript(url) { + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[data-infographic-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.infographicCdn = 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 loadInfographicLib() { + if (libraryPromise) { + return libraryPromise + } + libraryPromise = (async () => { + await loadScript(CDN_URL) + const ns = window[GLOBAL_NAME] + if (!ns || typeof ns.Infographic !== 'function') { + throw new Error(`Loaded ${CDN_URL} but window.${GLOBAL_NAME}.Infographic is missing`) + } + return ns.Infographic + })().catch(err => { + libraryPromise = null + throw err + }) + return libraryPromise +} + +function readSpecText(el) { + if (el.hasAttribute(SPEC_ATTR)) { + return el.getAttribute(SPEC_ATTR) + } + const text = el.textContent + el.setAttribute(SPEC_ATTR, text) + return text +} + +function parseOpts(raw) { + const out = {} + if (!raw) { + return out + } + raw.trim().split(/\s+/).forEach(pair => { + const eq = pair.indexOf('=') + if (eq <= 0) { + return + } + const key = pair.slice(0, eq) + const value = pair.slice(eq + 1) + if (ALLOWED_OPT_KEYS.has(key)) { + out[key] = value + } + }) + return out +} + +function coerceDimension(v, fallback) { + if (v === undefined || v === null || v === '') { + return fallback + } + // Allow numeric pixels ("400"), CSS strings ("100%", "30rem"). + if (/^\d+$/.test(v)) { + return Number(v) + } + return v +} + +function renderError(el, message) { + const sourceText = el.getAttribute(SPEC_ATTR) || el.textContent + el.classList.add('infographic-error') + el.replaceChildren() + + const heading = document.createElement('strong') + heading.textContent = 'Infographic 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) +} + +export async function hydrateInfographic(rootEl, { darkMode = false } = {}) { + const root = rootEl || document + const elements = Array.from(root.querySelectorAll(`.infographic:not([${RENDERED_ATTR}])`)) + if (elements.length === 0) { + return + } + + // Capture spec text before any DOM mutation so re-runs stay idempotent. + elements.forEach(el => readSpecText(el)) + + let Infographic + try { + Infographic = await loadInfographicLib() + } catch (err) { + elements.forEach(el => renderError(el, `Failed to load infographic library: ${err.message}`)) + // eslint-disable-next-line no-console + console.error('[infographic] failed to load library from CDN', err) + return + } + + await Promise.all(elements.map(async el => { + const spec = readSpecText(el) + let opts + try { + opts = parseOpts(el.getAttribute(OPTS_ATTR)) + } catch (err) { + renderError(el, `Invalid data-opts: ${err.message}`) + return + } + + const finalOpts = { + container: el, + width: coerceDimension(opts.width, '100%'), + height: coerceDimension(opts.height, 480), + theme: opts.theme || (darkMode ? 'dark' : undefined), + editable: false + } + + el.setAttribute(RENDERED_ATTR, '1') + el.replaceChildren() + el.classList.remove('infographic-error') + + try { + const inst = new Infographic(finalOpts) + const result = inst.render(spec) + if (result && typeof result.then === 'function') { + await result + } + } catch (err) { + el.removeAttribute(RENDERED_ATTR) + renderError(el, err && err.message ? err.message : String(err)) + } + })) +} diff --git a/client/scss/app.scss b/client/scss/app.scss index d4cb2610..4db4c340 100644 --- a/client/scss/app.scss +++ b/client/scss/app.scss @@ -16,6 +16,7 @@ @import 'components/v-form'; @import 'components/v-tabs'; @import 'components/vega'; +@import 'components/infographic'; // @import '../libs/twemoji/twemoji-awesome'; // @import '../libs/prism/prism.css'; diff --git a/client/scss/components/infographic.scss b/client/scss/components/infographic.scss new file mode 100644 index 00000000..ebe248da --- /dev/null +++ b/client/scss/components/infographic.scss @@ -0,0 +1,34 @@ +.infographic { + display: block; + margin: 1rem 0; + overflow: auto; +} + +.infographic-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 8541238e..572f12bb 100644 --- a/client/themes/default/components/page.vue +++ b/client/themes/default/components/page.vue @@ -383,6 +383,7 @@ import NavSidebar from './nav-sidebar.vue' import Prism from 'prismjs' import mermaid from 'mermaid' import { hydrateVega } from '../../../components/vega/hydrate' +import { hydrateInfographic } from '../../../components/infographic/hydrate' import { get, sync } from 'vuex-pathify' import _ from 'lodash' import ClipboardJS from 'clipboard' @@ -649,6 +650,9 @@ export default { // -> Render Vega / Vega-Lite charts hydrateVega(this.$refs.container, { darkMode: this.$vuetify.theme.dark }) + // -> Render AntV Infographic diagrams + hydrateInfographic(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-11-infographic-renderer-design.md b/docs/superpowers/specs/2026-05-11-infographic-renderer-design.md new file mode 100644 index 00000000..241c7d1b --- /dev/null +++ b/docs/superpowers/specs/2026-05-11-infographic-renderer-design.md @@ -0,0 +1,259 @@ +# AntV Infographic Renderer — Design Spec + +**Date:** 2026-05-11 +**Status:** Approved +**Author:** Tayeb Chlyah + +--- + +## Goal + +Add support for [AntV Infographic](https://github.com/antvis/infographic) diagrams in Wiki.js pages via fenced code blocks, using the official `@antv/infographic` library. Infographics render in both the live page view and the markdown editor preview. + +--- + +## Context + +Wiki.js already supports several embedded visualization syntaxes (Mermaid, Vega, Vega-Lite) via the same pattern: server-side renderer rewrites fenced blocks into a placeholder `