Skip to content

feat: add Vue 3 adapter (@coseeing/see-mark/vue)#41

Open
jamliaoo wants to merge 19 commits into
mainfrom
feat/vue-adapter
Open

feat: add Vue 3 adapter (@coseeing/see-mark/vue)#41
jamliaoo wants to merge 19 commits into
mainfrom
feat/vue-adapter

Conversation

@jamliaoo

@jamliaoo jamliaoo commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

背景描述 (Why)

SeeMark 已有 React(@coseeing/see-mark)與 HTML(/html)兩個 adapter,共用
Stage 1 markup pipeline。本 PR 新增第三個對外 adapter @coseeing/see-mark/vue
讓社群/外部 Vue 3 專案能用同一套 a11y markdown pipeline,並維持與 React/HTML
相同的語意輸出。

挑戰:coseeing 內部沒有 Vue consumer,無法靠真實場景驗證 —— 因此以「與 React
adapter 語意等價」作為正確性主保證(三方 parity test)。

實作方法 (How)

  • 原生 VNode adapter(非 v-html 包 HTML adapter):converter 走 htmlparser2
    walk → h(),自訂 component 是真正的 Vue 3 組件(functional / defineComponent
    / SFC),children 走 default slot。
  • 公開 APIcreateMarkdownToVueParser(對稱 createMarkdownToReactParser
    • <SeeMark> 便利組件(只快取 parser、不快取 VNode —— VNode 每次 render 必須唯一)。
  • 正確性主保證cross-adapter-parity.test.js 擴充成 React/HTML/Vue 三方比對,
    同一 markdown 必須產生相同語意 DOM,15 種 component types 缺一個 coverage 就 fail CI。
  • SSR:完整 SSR → hydrate round-trip 測試(無 mismatch warning、DOM 節點未被替換、
    hydration 後互動與 reactivity 正常)。
  • 安全(不比 React 更危險):raw passthrough 中和四個 Vue 特有執行向量 —— drop
    字串 on*、drop <script>(VNode script 掛載即執行)、剝除 ref/key/ref_for/ref_key
    還原 SVG camelCase 屬性;javascript: URL 與 <style> 仍原樣通過(明文信任邊界:
    adapter 不是 sanitizer)。
  • 打包相容:CJS-only(與 React/HTML 一致);AsciiMath lazy 載入避免 ESM-strict
    import 崩潰;Vue entry 預設 enableAsciimath: false,避免 Vite 下反引號行內程式碼
    觸發 MathJax legacy shim 的 strict-mode 崩潰(要用可明確 opt-in)。

實際變更

  • 新增 src/entries/vue.jssrc/parsers/create-markdown-to-vue-parser.js
    src/markup-converters/vue/(converter + <SeeMark> + 15 個 default components)
  • cross-adapter-parity.test.js 擴充三方比對;新增 converter / parser / seemark /
    SSR-hydration 測試
  • rollup.config.mjs 加第三 entry;package.json./vue export、optional peer
    vue ^3.2.0、vue 相關 devDeps
  • AsciiMath 改 lazy 載入(load-asciimath.cjs
  • README 補 /vue 章節(用法、自訂組件、SSR、bundler 相容、trust model)

測試驗證

  • 全測試 365 綠(27 suites):含三方 parity、SSR/hydration、AsciiMath 預設守門
  • lint 通過;build 產出 lib/see-mark-vue.cjs
  • 瀏覽器手動驗證(數學 SVG 實際渲染、自訂 alert、安全 probes)—— 證據於本機
    gitignored 目錄

補充說明

  • AsciiMath 在 Vite 下即使 opt-in 仍會拋描述性錯誤(MathJax v2 legacy shim 上游限制,
    v4 未修);真正的 strict-safe AsciiMath 需要另開 PR 處理。

jamliaoo added 19 commits July 12, 2026 12:23
Review fix: the default-slot closure now rebuilds children per invocation
instead of returning one cached VNode array — a component may call
slots.default() twice in one render (or re-render later), and reusing
mounted VNodes violates Vue's uniqueness contract.
…SeeMark

mathjax-full's AsciiMath is built on a MathJax-v2 legacy shim that calls
arguments.callee during bootstrap — illegal under strict mode. Vite and
esbuild convert CJS to (always-strict) ESM, so merely importing any
@coseeing/see-mark entry crashed at startup; webpack consumers were
unaffected because their CJS wrapper stays sloppy-mode.

The AsciiMath runtime now initializes on the first conversion instead of
at module scope (load-asciimath.cjs, kept as literal call-time requires
via the commonjs plugin's ignore option). Importing SeeMark and rendering
LaTeX/Nemeth now works under Vite; converting AsciiMath there still hits
the upstream shim limitation and is documented in the README.
Browser verification caught custom `components` overrides silently not
reacting under Vite: the CJS-only build makes bundlers resolve
require('vue') to vue.cjs.js while the app imports the esm-bundler build,
so two Vue reactivity instances coexist and props tracked by one never
invalidate computeds created by the other.

The vue entry now also builds to lib/see-mark-vue.mjs with `import 'vue'`
kept intact; exports route bundlers to it (import) while Node — CJS and
ESM alike — stays on the CJS build (node condition), where AsciiMath keeps
working. AsciiMath init failures now rethrow one actionable message
covering both the ESM require gap and the strict-mode shim crash.
mathjax-full import specifiers gain explicit .js extensions so the ESM
output resolves under Node's stricter ESM rules too.
The symlinked SeeMark repo carries its own node_modules/vue (a
devDependency), so esbuild inlined a second full Vue runtime into the
prebundled chunk — two reactivity instances, and the components-prop
toggle silently stopped reacting. resolve.dedupe forces every vue import
to this app's copy (registry consumers are unaffected: npm never installs
a dependency's devDependencies). Also define the __VUE_* feature flags
normally injected by @vitejs/plugin-vue, silencing the esm-bundler
warning.
…rift

Deep review (multi-agent, adversarially verified) found three ways the
Vue adapter diverged from its 'no more dangerous than React' posture, all
reproduced empirically:

- raw <script> in markdown EXECUTED on mount: a VNode-built script is not
  parser-inserted, unlike React's inert script elements and the HTML
  adapter's innerHTML-inert string. Now dropped from raw passthrough
  (<style> stays — it behaves identically in both adapters).
- Vue-reserved props ref/key/ref_for/ref_key passed through from raw HTML,
  letting untrusted markup hijack the host component's $refs and corrupt
  keyed diffing. Now stripped.
- htmlparser2 lowercases camelCase SVG attributes (viewBox -> viewbox),
  which Vue's case-sensitive SVG setAttribute renders inert — a Vue-only
  drift from React/HTML for raw inline SVG. Now restored via a lookup map.

Also fixes the full-syntax SSR test to assert server-node identity (h1,
which carries no MJX-* id) instead of mere existence, and corrects the
README security section (on* was NOT the only exception).
The ESM build was added on a wrong diagnosis of the dual-Vue-runtime bug.
An isolated experiment (clean registry-style layout + real Vite build)
shows a registry consumer works fine with the CJS-only build: Vite's
optimizeDeps unifies require('vue') and the app's import 'vue' into a
single Vue instance (the bundle's reactive core appears exactly once).

The real dual-Vue cause is the playground's file:/symlink layout, where
the SeeMark repo's own vue devDependency becomes a second physical copy —
fixed by resolve.dedupe, independent of build format. Registry installs
never install a dependency's devDependencies, so they never hit it.

So /vue reverts to CommonJS-only, matching the React and HTML entries.
Lazy AsciiMath loading stays (bundlers convert even CJS to strict ESM, so
it's needed regardless of build format). The playground keeps dedupe and
regains optimizeDeps.include (Vite skips CJS pre-bundling for linked
packages). Browser-verified: custom components toggle correctly (no
dual-Vue) and the script/ref/svg security fixes remain intact.
- remove examples/vue-playground: only the Vue adapter had an example, so
  keep this branch focused on the library and defer the playground to a
  separate PR
- drop the leftover .js import extensions in tex-to-mml.js (dead remnant of
  the reverted ESM build; the CJS bundle resolves extension-less imports)
- restore .gitignore to main so staged-delivery evidence stays untracked
  rather than gitignored
- README: remove the dangling examples/vue-playground reference, keeping the
  global->globalThis note inline
Trim the comments added with the Vue adapter so each states why, not how:
drop Vue-internal function references, the strict-mode/arguments.callee
mechanics, cloneVNode internals, and step-by-step restatements of the code.
The security rationale (why on*/script/ref are dropped) is kept — it is the
part that stops the guard being refactored away.
The Vue adapter targets Vite/esbuild apps, where MathJax's AsciiMath legacy
shim crashes (arguments.callee under strict mode). With AsciiMath's default
backtick delimiter, ordinary inline code routed to MathJax and threw at render
time under Vite's dep pre-bundling — a crash on the default happy path, found
by adversarial review (our playground had masked it with enableAsciimath:
false).

The Vue parser factory now defaults enableAsciimath to false (React/HTML keep
true); backticks render as code spans out of the box, and math users opt in
explicitly where it works. cross-adapter parity now sets enableAsciimath: true
so it still compares one shared configuration across adapters.
@jamliaoo
jamliaoo marked this pull request as ready for review July 17, 2026 07:41
@jamliaoo
jamliaoo requested a review from wendyyuchensun July 17, 2026 07:41

@wendyyuchensun wendyyuchensun left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vue adapter 的部分 LGTM。


比較麻煩的是「MathJax AsciiMath 需要 lazy load」這個 workaround。但覺得可以後續再追加處理。

查了一輪,目前沒有其他單一 library 能像 mathjax 一樣,同時涵蓋 LaTeX → MathML、AsciiMath → MathML、MathML → SVG 三種轉換,所以整套換掉(如 KaTeX/Temml)改動會不小。

不考慮 hack 的話,感覺改動最小做法是只替換 AsciiMath → MathML 這一段,改用 mathup,其餘維持 MathJax。實測過可行:mathup 是原生 ESM、無 legacy 語法,import 階段不會踩到 Vite/esbuild 的 strict mode 問題,產出的 MathML 也能直接餵給 MathJax 的 mml2svg。

兩個後續問題事項:

  1. mathup 是 AsciiMath 的「方言」,不是完全相容——常用語法(分數、根號、上下標、矩陣、極限)都沒問題,但 forallcolor()stackrel()cancel()frac{}{} 等指令不支援,會退化成逐字母輸出,對既有內容算 breaking change。
  2. mathup 是 ESM-only,被 rollup 打進 CJS lib 後會遇到 default export interop 問題(require(esm) 拿到的是 namespace),需要像 React converter 處理 html-react-parser 那樣加一層 typeof mathup === 'function' ? mathup : mathup.default 的 guard。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants