diff --git a/README.md b/README.md index b64973e..ca8fed6 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,139 @@ interface Position { } ``` +## Vue + +The `@coseeing/see-mark/vue` entry renders the same markdown pipeline to Vue 3 +VNodes. Vue 3.2+ is required (declared as an optional peer dependency — React +users are unaffected). + +### Usage + +```js +import { createApp, defineComponent, h, ref } from 'vue'; +import { SeeMark, createMarkdownToVueParser } from '@coseeing/see-mark/vue'; + +// Option 1: the component (idiomatic for most apps) +const App = defineComponent({ + setup() { + const source = ref('# Hello \\(a^2 + b^2 = c^2\\)'); + return () => h(SeeMark, { source: source.value, options: OPTIONS }); + }, +}); + +// Option 2: the parser factory (mirrors createMarkdownToReactParser) +const parse = createMarkdownToVueParser({ options: OPTIONS }); +const MyDoc = defineComponent({ + props: { markdown: String }, + setup(props) { + return () => h('article', parse(props.markdown)); + }, +}); +``` + +`options` accepts the same table as the React parser (see above), with one +changed default: **`enableAsciimath` defaults to `false`** for the Vue entry +(React/HTML default it to `true`). AsciiMath's backtick delimiter otherwise +turns ordinary inline code into math, and its MathJax shim can't run under +Vite/esbuild (see Bundler compatibility). Pass `enableAsciimath: true` to opt +in where it works. +`createMarkdownToVueParser` returns a function producing a fresh VNode array — +call it inside a render function. `` re-parses when `source` changes +and rebuilds its parser when `options`/`components` change; pass stable object +references for `options`/`components` (an inline literal re-creates the parser +on every parent render). + +### Custom components + +A custom component is an ordinary Vue 3 component (functional, `defineComponent` +or SFC). Payload arrives as props; children arrive through the **default slot**. +Declare the payload props you use (plus `position`) — undeclared payload keys +would otherwise fall through onto the root element as DOM attributes. + +```js +const Alert = defineComponent({ + props: { + variant: { type: String, default: '' }, + title: { type: String, default: '' }, + internalLinkId: { type: String, default: '' }, + position: { type: Object, default: undefined }, + }, + setup(props, { slots }) { + return () => + h('div', { class: `alert alert-${props.variant}` }, [ + props.title ? h('strong', null, props.title) : null, + slots.default?.(), + ]); + }, +}); + +h(SeeMark, { source, options: OPTIONS, components: { alert: Alert } }); +``` + +Errors thrown by a custom component surface at mount/render time and follow +Vue's normal error path (`app.config.errorHandler`), not at parse time. + +### SSR + +The Vue adapter is SSR-safe (`@vue/server-renderer` / Nuxt): MathJax runs in +the parsing stage, not in components. One caveat: MathJax assigns +globally-incrementing element IDs, so a server parse and a client parse of the +same document can disagree on `MJX-*` attribute values, which may surface as +attribute-level hydration warnings in dev builds. Structure and content +hydrate correctly. + +### Bundler compatibility + +Like the React and HTML entries, `/vue` ships a CommonJS bundle. Bundlers +(Vite, webpack) pre-bundle it to ESM automatically and share your app's single +`vue` copy; no extra config is needed for a normal registry install. Two +notes: + +- **AsciiMath under ESM-strict bundlers (Vite, esbuild) is unavailable.** + MathJax implements AsciiMath through a MathJax-v2 legacy shim that cannot + run under strict mode, and Vite's dependency pre-bundling converts even + CommonJS deps to always-strict ESM. SeeMark loads it lazily, so importing + SeeMark and rendering LaTeX/Nemeth work everywhere; the Vue entry also + **defaults `enableAsciimath` to `false`**, so ordinary backtick content + (inline code) renders as code spans out of the box. Explicitly setting + `enableAsciimath: true` under such a bundler throws a descriptive error when + it hits AsciiMath. Server-side (Node/webpack) AsciiMath is unaffected — a + plain esbuild *build* (not Vite's dep pre-bundle) does not trip it either. +- **Linked-package development**: if you consume SeeMark via `npm link` / + `file:` during development, add `optimizeDeps.include: ['@coseeing/see-mark/vue']` + and `resolve.dedupe: ['vue']` to your Vite config — Vite skips CJS→ESM + pre-bundling for symlinked packages, and the linked repo carries its own + `node_modules/vue` (two Vue runtimes on one page silently break reactivity + across the component boundary). Registry installs need neither. In the + browser you may also need a `global` → `globalThis` define, since + mathjax-full references the Node.js `global` at module scope. + +## Security / trust model + +SeeMark adapters are **not sanitizers**. Raw HTML in the markdown source +passes through to the output — including `javascript:` URLs — matching the +React adapter's behavior. If you render untrusted markdown, sanitize it at the +source, or sanitize the HTML adapter's string output with DOMPurify via its +`sanitize` hook. + +To keep the Vue adapter no more dangerous than the React one, raw passthrough +neutralizes three Vue-specific execution vectors — none of which affect your +own custom components (`@click`/`v-on`, `ref`, `key` on components all work +normally): + +- **string `on*` attributes** (e.g. `onclick="..."`) are dropped. Vue would + otherwise attach them as live inline handlers; React ignores string + handlers. +- **raw ` after' + ); + expect(wrapper.find('script').exists()).toBe(false); + expect(window.__seemarkScriptRan).toBeUndefined(); + expect(wrapper.text()).toContain('before'); + expect(wrapper.text()).toContain('after'); + }); + + it('strips Vue reserved props (ref/key) from raw passthrough', () => { + // ref/key handed to h() become framework directives, not attributes: + // `ref` would hijack the consuming component instance's $refs. + const refs = {}; + mount({ + render() { + return h('div', convertMarkup('x')); + }, + mounted() { + refs.hijack = this.$refs.hijack; + }, + }); + expect(refs.hijack).toBeUndefined(); + }); + + it('restores camelCase SVG attribute names lowercased by the HTML parser', () => { + // htmlparser2 lowercases viewBox -> viewbox; Vue's SVG setAttribute is + // case-sensitive, so without restoration the attribute is inert. + const wrapper = mountMarkup( + '' + ); + const svg = wrapper.get('svg').element; + expect(svg.getAttribute('viewBox')).toBe('0 0 100 100'); + expect(svg.getAttribute('preserveAspectRatio')).toBe('xMidYMid'); + }); + + it('keeps non-handler attributes verbatim, including data-* on unknown types', () => { + const wrapper = mountMarkup( + 'x' + ); + // Unknown seemark type falls back to passthrough, same as React/HTML. + const span = wrapper.get('span'); + expect(span.attributes('data-seemark-element-type')).toBe( + 'not-a-real-type' + ); + expect(span.attributes('data-foo')).toBe('bar'); + expect(span.text()).toBe('x'); + }); + + it('dispatches component placeholders to default components', () => { + const wrapper = mountMarkup( + `
body
` + ); + const region = wrapper.get('[role="region"]'); + expect(region.get('p').text()).toBe('NOTE'); + expect(region.text()).toContain('body'); + // The placeholder's data-seemark-* attributes must NOT leak into the DOM. + expect(wrapper.find('[data-seemark-element-type]').exists()).toBe(false); + }); + + it('lets consumer components override defaults and receive props + default slot', () => { + const CustomAlert = defineComponent({ + name: 'CustomAlert', + props: { + variant: { type: String, default: '' }, + title: { type: String, default: '' }, + internalLinkId: { type: String, default: '' }, + position: { type: Object, default: undefined }, + }, + setup(props, { slots }) { + return () => + h('section', { class: `custom-${props.variant}` }, slots.default?.()); + }, + }); + const wrapper = mountMarkup( + `
inner
`, + { alert: CustomAlert } + ); + const section = wrapper.get('section.custom-warning'); + expect(section.text()).toBe('inner'); + }); + + it('supports plain functional components as overrides', () => { + const FnAlert = ({ variant = '' }, { slots }) => + h('aside', { 'data-variant': variant }, slots.default?.()); + FnAlert.inheritAttrs = false; + const wrapper = mountMarkup( + `
fn body
`, + { alert: FnAlert } + ); + expect(wrapper.get('aside').attributes('data-variant')).toBe('tip'); + expect(wrapper.get('aside').text()).toBe('fn body'); + }); + + it('returns fresh VNodes on every slot invocation', () => { + // A component may render its default slot more than once (e.g. a visual + // copy plus an sr-only copy). Each invocation must get a fresh VNode + // tree — sharing one cached array would violate VNode uniqueness. + const TwiceAlert = (props, { slots }) => + h('div', [ + h('section', { class: 'visual' }, slots.default?.()), + h('section', { class: 'copy' }, slots.default?.()), + ]); + TwiceAlert.inheritAttrs = false; + const wrapper = mountMarkup( + `
bold text
`, + { alert: TwiceAlert } + ); + const sections = wrapper.findAll('section'); + expect(sections).toHaveLength(2); + expect(sections[0].element.innerHTML).toContain('bold'); + expect(sections[1].element.innerHTML).toContain('bold'); + // Both copies must be backed by distinct DOM nodes. + expect(sections[0].element.querySelector('strong')).not.toBe( + sections[1].element.querySelector('strong') + ); + }); + + it('throws loudly on an unparseable payload (Stage 1 contract violation)', () => { + expect(() => + convertMarkup( + '
' + ) + ).toThrow(/contract violation/); + }); + + it('drops HTML comments (VNodes have no string round-trip for them)', () => { + const wrapper = mountMarkup('

a

b

'); + expect(wrapper.element.innerHTML).not.toContain('secret'); + expect(wrapper.findAll('p')).toHaveLength(2); + }); +}); diff --git a/src/markup-converters/vue/default-components/alert.js b/src/markup-converters/vue/default-components/alert.js new file mode 100644 index 0000000..b205559 --- /dev/null +++ b/src/markup-converters/vue/default-components/alert.js @@ -0,0 +1,20 @@ +import { h } from 'vue'; + +const alert = ( + { internalLinkId = '', variant = '', title = '' }, + { slots } +) => { + const children = [h('p', null, variant.toUpperCase())]; + if (slots.default) children.push(...slots.default()); + if (internalLinkId) { + children.push(h('a', { href: `#${internalLinkId}-source` }, 'back')); + } + return h( + 'div', + { role: 'region', 'aria-label': title, id: internalLinkId || null }, + children + ); +}; +alert.inheritAttrs = false; + +export default alert; diff --git a/src/markup-converters/vue/default-components/codepen.js b/src/markup-converters/vue/default-components/codepen.js new file mode 100644 index 0000000..1ee2609 --- /dev/null +++ b/src/markup-converters/vue/default-components/codepen.js @@ -0,0 +1,17 @@ +import { h } from 'vue'; + +// Attributes adapted from CodePen's official embed snippet. +const codepen = ({ title = '', source = '' }) => + h('iframe', { + height: '300', + style: 'width: 100%;', + scrolling: 'no', + title, + src: source, + frameborder: 'no', + loading: 'lazy', + allowtransparency: 'true', + }); +codepen.inheritAttrs = false; + +export default codepen; diff --git a/src/markup-converters/vue/default-components/default-components.js b/src/markup-converters/vue/default-components/default-components.js new file mode 100644 index 0000000..849a1ad --- /dev/null +++ b/src/markup-converters/vue/default-components/default-components.js @@ -0,0 +1,37 @@ +import { SUPPORTED_COMPONENT_TYPES } from '../../../shared/supported-components'; + +import alert from './alert'; +import heading from './heading'; +import internalLink from './internal-link'; +import internalLinkTitle from './internal-link-title'; +import image from './image'; +import imageLink from './image-link'; +import imageDisplay from './image-display'; +import imageDisplayLink from './image-display-link'; +import externalLinkTab from './external-link-tab'; +import externalLinkTitle from './external-link-title'; +import externalLinkTabTitle from './external-link-tab-title'; +import youtube from './youtube'; +import codepen from './codepen'; +import iframe from './iframe'; +import math from './math'; + +const defaultComponents = { + [SUPPORTED_COMPONENT_TYPES.ALERT]: alert, + [SUPPORTED_COMPONENT_TYPES.HEADING]: heading, + [SUPPORTED_COMPONENT_TYPES.INTERNAL_LINK]: internalLink, + [SUPPORTED_COMPONENT_TYPES.INTERNAL_LINK_TITLE]: internalLinkTitle, + [SUPPORTED_COMPONENT_TYPES.IMAGE]: image, + [SUPPORTED_COMPONENT_TYPES.IMAGE_LINK]: imageLink, + [SUPPORTED_COMPONENT_TYPES.IMAGE_DISPLAY]: imageDisplay, + [SUPPORTED_COMPONENT_TYPES.IMAGE_DISPLAY_LINK]: imageDisplayLink, + [SUPPORTED_COMPONENT_TYPES.EXTERNAL_LINK_TAB]: externalLinkTab, + [SUPPORTED_COMPONENT_TYPES.EXTERNAL_LINK_TITLE]: externalLinkTitle, + [SUPPORTED_COMPONENT_TYPES.EXTERNAL_LINK_TAB_TITLE]: externalLinkTabTitle, + [SUPPORTED_COMPONENT_TYPES.YOUTUBE]: youtube, + [SUPPORTED_COMPONENT_TYPES.CODEPEN]: codepen, + [SUPPORTED_COMPONENT_TYPES.IFRAME]: iframe, + [SUPPORTED_COMPONENT_TYPES.MATH]: math, +}; + +export default defaultComponents; diff --git a/src/markup-converters/vue/default-components/default-components.test.js b/src/markup-converters/vue/default-components/default-components.test.js new file mode 100644 index 0000000..5159409 --- /dev/null +++ b/src/markup-converters/vue/default-components/default-components.test.js @@ -0,0 +1,117 @@ +/** + * Vue 3 ships its default "." export condition pointing at the + * bundler-targeted ESM build, which assumes further processing by a bundler + * and cannot be executed directly by Node/Jest. jsdom's default Jest + * environment also requests the "browser" export condition instead of + * "node", so without this override Jest resolves `import ... from 'vue'` to + * that non-executable bundler build. This per-file docblock (a native Jest + * feature) asks Jest to prefer the real Node entry points instead — it does + * not touch the shared jest.config.js. + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ +import { h } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { SUPPORTED_COMPONENT_TYPES } from '../../../shared/supported-components'; + +import defaultComponents from './default-components'; +import alert from './alert'; +import heading from './heading'; +import math from './math'; +import imageDisplayLink from './image-display-link'; + +const mountComponent = (Component, props, childText) => + mount({ + render: () => + h( + 'div', + h( + Component, + props, + childText ? { default: () => [childText] } : undefined + ) + ), + }); + +describe('vue default components', () => { + it('registry covers every SUPPORTED_COMPONENT_TYPES key', () => { + for (const type of Object.values(SUPPORTED_COMPONENT_TYPES)) { + expect(typeof defaultComponents[type]).toBe('function'); + } + expect(Object.keys(defaultComponents)).toHaveLength( + Object.values(SUPPORTED_COMPONENT_TYPES).length + ); + }); + + it('every component disables attribute fallthrough', () => { + // Payload keys (position, math, typed, ...) would otherwise be applied + // to the root element as DOM attributes and break cross-adapter parity. + for (const Component of Object.values(defaultComponents)) { + expect(Component.inheritAttrs).toBe(false); + } + }); + + it('alert renders region, uppercased variant, slot children and backlink', () => { + const wrapper = mountComponent( + alert, + { internalLinkId: 'a1', variant: 'warning', title: 'Careful' }, + 'body text' + ); + const region = wrapper.get('[role="region"]'); + expect(region.attributes('aria-label')).toBe('Careful'); + expect(region.attributes('id')).toBe('a1'); + expect(region.get('p').text()).toBe('WARNING'); + expect(region.text()).toContain('body text'); + expect(region.get('a').attributes('href')).toBe('#a1-source'); + }); + + it('alert omits id and backlink without internalLinkId', () => { + const wrapper = mountComponent( + alert, + { internalLinkId: '', variant: 'note', title: '' }, + 'x' + ); + expect(wrapper.get('[role="region"]').attributes('id')).toBeUndefined(); + expect(wrapper.find('a').exists()).toBe(false); + }); + + it('heading renders the level and clamps invalid levels to h1', () => { + expect( + mountComponent(heading, { id: 'sec', level: 3 }, 'Title').get('h3').text() + ).toBe('Title'); + expect( + mountComponent(heading, { id: null, level: 9 }, 'T').find('h1').exists() + ).toBe(true); + }); + + it('math renders mathMl/svg via innerHTML into sr-only + aria-hidden spans', () => { + const wrapper = mountComponent(math, { + mathMl: 'x', + svg: '', + }); + expect(wrapper.get('span.sr-only').element.innerHTML).toBe( + 'x' + ); + expect(wrapper.get('span[aria-hidden="true"]').element.innerHTML).toBe( + '' + ); + }); + + it('imageDisplayLink composes anchor > figure > img + figcaption', () => { + const wrapper = mountComponent(imageDisplayLink, { + display: 'A cat', + target: 'https://example.com', + alt: 'cat', + imageId: 'pic-id', + source: 'https://example.com/p.png', + }); + const anchor = wrapper.get('a'); + expect(anchor.attributes('href')).toBe('https://example.com'); + const img = anchor.get('figure img'); + expect(img.attributes('src')).toBe('https://example.com/p.png'); + expect(img.attributes('alt')).toBe('cat'); + expect(img.attributes('data-seemark-image-id')).toBe('pic-id'); + expect(anchor.get('figcaption').text()).toBe('A cat'); + }); +}); diff --git a/src/markup-converters/vue/default-components/external-link-tab-title.js b/src/markup-converters/vue/default-components/external-link-tab-title.js new file mode 100644 index 0000000..410b04d --- /dev/null +++ b/src/markup-converters/vue/default-components/external-link-tab-title.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +const externalLinkTabTitle = ({ display = '', title = '', target = '' }) => + h( + 'a', + { href: target, title, target: '_blank', rel: 'noopener noreferrer' }, + display + ); +externalLinkTabTitle.inheritAttrs = false; + +export default externalLinkTabTitle; diff --git a/src/markup-converters/vue/default-components/external-link-tab.js b/src/markup-converters/vue/default-components/external-link-tab.js new file mode 100644 index 0000000..952e3aa --- /dev/null +++ b/src/markup-converters/vue/default-components/external-link-tab.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +const externalLinkTab = ({ display = '', target = '' }) => + h( + 'a', + { href: target, target: '_blank', rel: 'noopener noreferrer' }, + display + ); +externalLinkTab.inheritAttrs = false; + +export default externalLinkTab; diff --git a/src/markup-converters/vue/default-components/external-link-title.js b/src/markup-converters/vue/default-components/external-link-title.js new file mode 100644 index 0000000..d3e0478 --- /dev/null +++ b/src/markup-converters/vue/default-components/external-link-title.js @@ -0,0 +1,7 @@ +import { h } from 'vue'; + +const externalLinkTitle = ({ display = '', title = '', target = '' }) => + h('a', { href: target, title }, display); +externalLinkTitle.inheritAttrs = false; + +export default externalLinkTitle; diff --git a/src/markup-converters/vue/default-components/heading.js b/src/markup-converters/vue/default-components/heading.js new file mode 100644 index 0000000..82c29a3 --- /dev/null +++ b/src/markup-converters/vue/default-components/heading.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +const VALID_LEVELS = new Set([1, 2, 3, 4, 5, 6]); + +const heading = ({ id = null, level = 1 }, { slots }) => { + const lvl = VALID_LEVELS.has(level) ? level : 1; + return h(`h${lvl}`, { id: id || null }, slots.default ? slots.default() : []); +}; +heading.inheritAttrs = false; + +export default heading; diff --git a/src/markup-converters/vue/default-components/iframe.js b/src/markup-converters/vue/default-components/iframe.js new file mode 100644 index 0000000..1191623 --- /dev/null +++ b/src/markup-converters/vue/default-components/iframe.js @@ -0,0 +1,7 @@ +import { h } from 'vue'; + +const iframe = ({ title = '', source = '' }) => + h('iframe', { title, src: source }); +iframe.inheritAttrs = false; + +export default iframe; diff --git a/src/markup-converters/vue/default-components/image-display-link.js b/src/markup-converters/vue/default-components/image-display-link.js new file mode 100644 index 0000000..f0f1d9f --- /dev/null +++ b/src/markup-converters/vue/default-components/image-display-link.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +import image from './image'; + +const imageDisplayLink = ({ display = '', ...props }) => + h('a', { href: props.target }, [ + h('figure', null, [image(props), h('figcaption', null, display)]), + ]); +imageDisplayLink.inheritAttrs = false; + +export default imageDisplayLink; diff --git a/src/markup-converters/vue/default-components/image-display.js b/src/markup-converters/vue/default-components/image-display.js new file mode 100644 index 0000000..99f9c60 --- /dev/null +++ b/src/markup-converters/vue/default-components/image-display.js @@ -0,0 +1,9 @@ +import { h } from 'vue'; + +import image from './image'; + +const imageDisplay = ({ display = '', ...props }) => + h('figure', null, [image(props), h('figcaption', null, display)]); +imageDisplay.inheritAttrs = false; + +export default imageDisplay; diff --git a/src/markup-converters/vue/default-components/image-link.js b/src/markup-converters/vue/default-components/image-link.js new file mode 100644 index 0000000..1b2548f --- /dev/null +++ b/src/markup-converters/vue/default-components/image-link.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +import image from './image'; + +// Call image() directly, not h(image, ...), to avoid an extra component +// instance in the tree — mirroring the HTML adapter's string composition. +const imageLink = (props = {}) => + h('a', { href: props.target }, [image(props)]); +imageLink.inheritAttrs = false; + +export default imageLink; diff --git a/src/markup-converters/vue/default-components/image.js b/src/markup-converters/vue/default-components/image.js new file mode 100644 index 0000000..c32baf2 --- /dev/null +++ b/src/markup-converters/vue/default-components/image.js @@ -0,0 +1,7 @@ +import { h } from 'vue'; + +const image = ({ alt = '', imageId = '', source = '' }) => + h('img', { src: source, alt, 'data-seemark-image-id': imageId }); +image.inheritAttrs = false; + +export default image; diff --git a/src/markup-converters/vue/default-components/internal-link-title.js b/src/markup-converters/vue/default-components/internal-link-title.js new file mode 100644 index 0000000..846a03c --- /dev/null +++ b/src/markup-converters/vue/default-components/internal-link-title.js @@ -0,0 +1,16 @@ +import { h } from 'vue'; + +const internalLinkTitle = ({ display = '', title = '', target = '' }) => + h( + 'a', + { + href: `#${target}`, + id: `${target}-source`, + title, + class: 'underline', + }, + display + ); +internalLinkTitle.inheritAttrs = false; + +export default internalLinkTitle; diff --git a/src/markup-converters/vue/default-components/internal-link.js b/src/markup-converters/vue/default-components/internal-link.js new file mode 100644 index 0000000..374cfc8 --- /dev/null +++ b/src/markup-converters/vue/default-components/internal-link.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +const internalLink = ({ display = '', target = '' }) => + h( + 'a', + { href: `#${target}`, id: `${target}-source`, class: 'underline' }, + display + ); +internalLink.inheritAttrs = false; + +export default internalLink; diff --git a/src/markup-converters/vue/default-components/math.js b/src/markup-converters/vue/default-components/math.js new file mode 100644 index 0000000..ff6661f --- /dev/null +++ b/src/markup-converters/vue/default-components/math.js @@ -0,0 +1,11 @@ +import { h } from 'vue'; + +// mathMl/svg are trusted MathJax output from Stage 1, so they render as live +// HTML via innerHTML. +const math = ({ mathMl = '', svg = '' }) => [ + h('span', { class: 'sr-only', innerHTML: mathMl }), + h('span', { 'aria-hidden': 'true', innerHTML: svg }), +]; +math.inheritAttrs = false; + +export default math; diff --git a/src/markup-converters/vue/default-components/youtube.js b/src/markup-converters/vue/default-components/youtube.js new file mode 100644 index 0000000..6fbc3fb --- /dev/null +++ b/src/markup-converters/vue/default-components/youtube.js @@ -0,0 +1,18 @@ +import { h } from 'vue'; + +// Attributes adapted from YouTube's official embed snippet. +const youtube = ({ title = '', source = '' }) => + h('iframe', { + width: '560', + height: '315', + src: source, + title, + frameborder: '0', + allow: + 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', + referrerpolicy: 'strict-origin-when-cross-origin', + allowfullscreen: '', + }); +youtube.inheritAttrs = false; + +export default youtube; diff --git a/src/markup-converters/vue/seemark.js b/src/markup-converters/vue/seemark.js new file mode 100644 index 0000000..df1d712 --- /dev/null +++ b/src/markup-converters/vue/seemark.js @@ -0,0 +1,26 @@ +import { computed, defineComponent } from 'vue'; + +import createMarkdownToVueParser from '../../parsers/create-markdown-to-vue-parser'; + +// Cache the parser, never the VNodes: a VNode tree must be unique per render. +// Consumers should pass stable `options`/`components` references — an inline +// literal re-creates the parser on every parent render. +const SeeMark = defineComponent({ + name: 'SeeMark', + props: { + source: { type: String, default: '' }, + options: { type: Object, default: undefined }, + components: { type: Object, default: undefined }, + }, + setup(props) { + const parse = computed(() => + createMarkdownToVueParser({ + options: props.options, + components: props.components, + }) + ); + return () => parse.value(props.source); + }, +}); + +export default SeeMark; diff --git a/src/markup-converters/vue/seemark.test.js b/src/markup-converters/vue/seemark.test.js new file mode 100644 index 0000000..a7b9588 --- /dev/null +++ b/src/markup-converters/vue/seemark.test.js @@ -0,0 +1,79 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ +import { h, ref, defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; + +import SeeMark from './seemark'; + +const OPTIONS = { + latexDelimiter: 'bracket', + asciimathDelimiter: 'graveaccent', + documentFormat: 'inline', + imageFiles: { 'pic-id': 'https://example.com/p.png' }, +}; + +describe('SeeMark component', () => { + it('renders markdown from the source prop', () => { + const wrapper = mount(SeeMark, { + props: { source: '# One', options: OPTIONS }, + }); + expect(wrapper.get('h1').text()).toBe('One'); + }); + + it('re-renders when source changes, including back to a previous value', async () => { + // A -> B -> A regression: a naive VNode cache keyed on source would hand + // back already-mounted VNodes on the third render and corrupt the patch. + // (Design spec: cache the parser only, never the VNodes.) + const wrapper = mount(SeeMark, { + props: { source: '# One', options: OPTIONS }, + }); + await wrapper.setProps({ source: '# Two' }); + expect(wrapper.get('h1').text()).toBe('Two'); + await wrapper.setProps({ source: '# One' }); + expect(wrapper.get('h1').text()).toBe('One'); + }); + + it('rebuilds the parser when options change', async () => { + const wrapper = mount(SeeMark, { + props: { source: 'eq $a^2$ done', options: OPTIONS }, + }); + // Under 'bracket', $...$ is not math. + expect(wrapper.find('span.sr-only').exists()).toBe(false); + await wrapper.setProps({ + options: { ...OPTIONS, latexDelimiter: 'dollar' }, + }); + expect(wrapper.find('span.sr-only').exists()).toBe(true); + }); + + it('applies and reacts to the components prop', async () => { + const FnHeading = ({ id = null }, { slots }) => + h('h1', { id: id || null, class: 'custom' }, slots.default?.()); + FnHeading.inheritAttrs = false; + const wrapper = mount(SeeMark, { + props: { source: '# T', options: OPTIONS }, + }); + expect(wrapper.find('h1.custom').exists()).toBe(false); + await wrapper.setProps({ components: { heading: FnHeading } }); + expect(wrapper.find('h1.custom').exists()).toBe(true); + }); + + it('survives unrelated parent re-renders (VNode uniqueness regression)', async () => { + const Parent = defineComponent({ + setup() { + const n = ref(0); + return () => + h('div', [ + h('button', { onClick: () => n.value++ }, String(n.value)), + h(SeeMark, { source: '# Stable', options: OPTIONS }), + ]); + }, + }); + const wrapper = mount(Parent); + await wrapper.get('button').trigger('click'); + await wrapper.get('button').trigger('click'); + expect(wrapper.get('button').text()).toBe('2'); + expect(wrapper.get('h1').text()).toBe('Stable'); + }); +}); diff --git a/src/markup-converters/vue/ssr-hydration.test.js b/src/markup-converters/vue/ssr-hydration.test.js new file mode 100644 index 0000000..4fdc934 --- /dev/null +++ b/src/markup-converters/vue/ssr-hydration.test.js @@ -0,0 +1,143 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ +import { jest } from '@jest/globals'; +import { createSSRApp, defineComponent, h, ref, nextTick } from 'vue'; +import { renderToString } from '@vue/server-renderer'; + +import createMarkdownToVueParser from '../../parsers/create-markdown-to-vue-parser'; + +import { fullSyntaxMarkdown } from '../html/full-syntax-fixture'; + +const OPTIONS = { + latexDelimiter: 'bracket', + asciimathDelimiter: 'graveaccent', + documentFormat: 'inline', + imageFiles: { 'pic-id': 'https://example.com/p.png' }, +}; + +const makeApp = (markdown, components) => + createSSRApp( + defineComponent({ + setup() { + const parse = createMarkdownToVueParser({ + options: OPTIONS, + components, + }); + return () => h('div', parse(markdown)); + }, + }) + ); + +const hydrateInJsdom = async (markdown, components) => { + const html = await renderToString(makeApp(markdown, components)); + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = html; + makeApp(markdown, components).mount(container); + await nextTick(); + return { html, container }; +}; + +describe('SSR + hydration', () => { + afterEach(() => { + jest.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('hydrates a math-free document with zero mismatch warnings and reuses server DOM', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const markdown = [ + '# Title', + '', + '> [!NOTE]', + '> body', + '', + 'See [ref] and ![cat](pic-id).', + ].join('\n'); + + const html = await renderToString(makeApp(markdown)); + expect(html.length).toBeGreaterThan(0); + + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = html; + const serverH1 = container.querySelector('h1'); + + makeApp(markdown).mount(container); + await nextTick(); + + // Hydration must adopt the server-rendered node, not replace it. + expect(container.querySelector('h1')).toBe(serverH1); + const logged = [...warn.mock.calls, ...error.mock.calls] + .flat() + .map(String) + .join('\n'); + expect(logged).not.toMatch(/hydrat/i); + }); + + it('SSR-renders and hydrates the full syntax document without structural replacement', async () => { + // MathJax assigns globally-incrementing element IDs (MJX-1-, MJX-2-, ...), + // so the server parse and the client parse can disagree on MJX-* + // attributes. Attribute-level hydration warnings are tolerated HERE ONLY; + // structural replacement is not. This caveat is documented in the README + // SSR section. + jest.spyOn(console, 'warn').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + + // Inlined (not via hydrateInJsdom) so we can capture a server node BEFORE + // hydration and prove it was ADOPTED, not replaced. The h1 carries no + // MJX-* id, so — unlike the math subtree — it is a clean structural-reuse + // signal unaffected by MathJax's per-parse id drift. + const html = await renderToString(makeApp(fullSyntaxMarkdown)); + expect(html).toContain(' { + const ClickAlert = defineComponent({ + name: 'ClickAlert', + props: { + variant: { type: String, default: '' }, + title: { type: String, default: '' }, + internalLinkId: { type: String, default: '' }, + position: { type: Object, default: undefined }, + }, + setup(props, { slots }) { + const clicks = ref(0); + return () => + h('div', { role: 'note' }, [ + h( + 'button', + { onClick: () => clicks.value++ }, + `clicks:${clicks.value}` + ), + slots.default?.(), + ]); + }, + }); + + const { container } = await hydrateInJsdom('> [!NOTE]\n> body', { + alert: ClickAlert, + }); + const button = container.querySelector('button'); + expect(button.textContent).toBe('clicks:0'); + button.dispatchEvent(new window.MouseEvent('click', { bubbles: true })); + await nextTick(); + expect(button.textContent).toBe('clicks:1'); + }); +}); diff --git a/src/parsers/create-markdown-to-vue-parser.js b/src/parsers/create-markdown-to-vue-parser.js new file mode 100644 index 0000000..1e68639 --- /dev/null +++ b/src/parsers/create-markdown-to-vue-parser.js @@ -0,0 +1,28 @@ +import markdownProcessor from '../markdown-processor/markdown-processor'; +import convertMarkup from '../markup-converters/vue/converter'; + +import { createMarkdownParserOptions } from './options'; + +// Factory, mirroring createMarkdownToReactParser: fix the configuration once, +// render many times. Returns an array of VNodes — call it inside a render +// function (VNodes must be freshly created on every render). +const createMarkdownToVueParser = ({ options, components } = {}) => { + // AsciiMath is off by default for the Vue entry (React/HTML default it on): + // its backtick delimiter turns ordinary inline code into math, and MathJax's + // AsciiMath shim crashes under Vite/esbuild dep pre-bundling — the bundlers + // most Vue apps use (see load-asciimath.cjs). Opt in with enableAsciimath: + // true where it works. + const parsedOptions = createMarkdownParserOptions({ + enableAsciimath: false, + ...options, + }); + + const parseMarkdown = (markdownContent) => { + const seemarkMarkup = markdownProcessor(markdownContent, parsedOptions); + return convertMarkup(seemarkMarkup, components); + }; + + return parseMarkdown; +}; + +export default createMarkdownToVueParser; diff --git a/src/parsers/create-markdown-to-vue-parser.test.js b/src/parsers/create-markdown-to-vue-parser.test.js new file mode 100644 index 0000000..0afa3a1 --- /dev/null +++ b/src/parsers/create-markdown-to-vue-parser.test.js @@ -0,0 +1,87 @@ +/** + * @jest-environment jsdom + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ + +import { h } from 'vue'; +import { mount } from '@vue/test-utils'; + +import createMarkdownToVueParser from './create-markdown-to-vue-parser'; + +const OPTIONS = { + latexDelimiter: 'bracket', + asciimathDelimiter: 'graveaccent', + documentFormat: 'inline', + imageFiles: { 'pic-id': 'https://example.com/p.png' }, +}; + +const mountMarkdown = (markdown, config = {}) => { + const parse = createMarkdownToVueParser({ options: OPTIONS, ...config }); + return mount({ render: () => h('div', parse(markdown)) }); +}; + +describe('createMarkdownToVueParser', () => { + it('renders a heading through the full pipeline', () => { + const wrapper = mountMarkdown('# Hello'); + expect(wrapper.get('h1').text()).toBe('Hello'); + }); + + it('renders math with MathML in sr-only and svg aria-hidden', () => { + const wrapper = mountMarkdown('eq \\(a^2 + b^2 = c^2\\) done'); + expect(wrapper.get('span.sr-only').element.innerHTML).toContain(' { + const wrapper = mountMarkdown('use `x+y` here'); + expect(wrapper.find('span.sr-only').exists()).toBe(false); + expect(wrapper.get('code').text()).toBe('x+y'); + }); + + it('renders backtick AsciiMath as math when enableAsciimath is opted in', () => { + const wrapper = mountMarkdown('use `x+y` here', { + options: { ...OPTIONS, enableAsciimath: true }, + }); + expect(wrapper.get('span.sr-only').element.innerHTML).toContain(' { + const wrapper = mountMarkdown('![cat](pic-id)'); + expect(wrapper.get('img').attributes('src')).toBe( + 'https://example.com/p.png' + ); + }); + + it('applies consumer component overrides', () => { + const FnHeading = ({ id = null }, { slots }) => + h('h1', { id: id || null, class: 'custom-heading' }, slots.default?.()); + FnHeading.inheritAttrs = false; + const wrapper = mountMarkdown('# Custom', { + components: { heading: FnHeading }, + }); + expect(wrapper.get('h1.custom-heading').text()).toBe('Custom'); + }); + + // Trust-boundary documentation tests — these pin the DELIBERATE posture + // from the design spec (docs/superpowers/specs/2026-07-12-vue-adapter-design.md): + // the adapter is not a sanitizer; URL schemes pass through verbatim, same + // as the React adapter. Changing this behavior must be a conscious decision. + it('passes javascript: URLs through verbatim (documented trust boundary)', () => { + const wrapper = mountMarkdown('x'); + expect(wrapper.get('a').attributes('href')).toBe('javascript:alert(1)'); + }); + + it('passes data: URLs through verbatim (documented trust boundary)', () => { + const wrapper = mountMarkdown( + 'd' + ); + expect(wrapper.get('img').attributes('src')).toBe( + 'data:image/png;base64,AAAA' + ); + }); +});