diff --git a/package-lock.json b/package-lock.json index bb7613a..65404a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "@coseeing/see-mark", - "version": "1.8.0", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@coseeing/see-mark", - "version": "1.8.0", + "version": "1.10.0", "license": "ISC", "dependencies": { "@coseeing/nemeth2latex": "^0.2.0", "html-react-parser": "^5.2.5", + "htmlparser2": "^12.0.0", "marked": "^15.0.11", "mathjax-full": "^3.2.2" }, @@ -5565,26 +5566,61 @@ "license": "MIT" }, "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, + "node_modules/dom-serializer/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/dom-serializer/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -5618,19 +5654,54 @@ } }, "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", "license": "BSD-2-Clause", "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/domutils/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domutils/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6869,6 +6940,65 @@ "htmlparser2": "10.0.0" } }, + "node_modules/html-dom-parser/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/html-dom-parser/node_modules/htmlparser2": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", + "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.1", + "entities": "^6.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -6911,9 +7041,9 @@ } }, "node_modules/htmlparser2": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", - "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -6923,10 +7053,56 @@ ], "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.1", - "entities": "^6.0.0" + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/htmlparser2/node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/htmlparser2/node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, "node_modules/http-proxy-agent": { diff --git a/package.json b/package.json index 279a920..9dac994 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,16 @@ { "name": "@coseeing/see-mark", - "version": "1.10.2", + "version": "1.11.0", "description": "A markdown parser for a11y", "main": "./lib/see-mark.cjs", + "exports": { + ".": { + "default": "./lib/see-mark.cjs" + }, + "./html": { + "default": "./lib/see-mark-html.cjs" + } + }, "files": [ "lib", "src" @@ -43,6 +51,7 @@ "dependencies": { "@coseeing/nemeth2latex": "^0.2.0", "html-react-parser": "^5.2.5", + "htmlparser2": "^12.0.0", "marked": "^15.0.11", "mathjax-full": "^3.2.2" }, @@ -51,5 +60,10 @@ "prop-types": "^15.8.1", "react": "^18.2.0", "react-dom": "^18.2.0" + }, + "peerDependenciesMeta": { + "prop-types": { "optional": true }, + "react": { "optional": true }, + "react-dom": { "optional": true } } } diff --git a/rollup.config.mjs b/rollup.config.mjs index 89074e3..22ce378 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -2,28 +2,44 @@ import commonjs from '@rollup/plugin-commonjs'; import babel from '@rollup/plugin-babel'; import nodeResolve from '@rollup/plugin-node-resolve'; -export default { - input: 'src/index.js', - output: [ - { - file: 'lib/see-mark.cjs', - format: 'cjs', - sourcemap: true, +const sharedPlugins = [ + commonjs({ + requireReturnsDefault: (id) => { + return id === 'html-react-parser'; }, - ], - plugins: [ - commonjs({ - requireReturnsDefault: (id) => { - return id === 'html-react-parser'; + }), + nodeResolve(), + babel({ + babelHelpers: 'bundled', + extensions: ['.js', '.jsx'], + exclude: 'node_modules/**', + }), +]; + +export default [ + { + input: 'src/index.js', + output: [ + { + file: 'lib/see-mark.cjs', + format: 'cjs', + sourcemap: true, + }, + ], + plugins: sharedPlugins, + jsx: 'react', + external: [/node_modules/], + }, + { + input: 'src/entries/html.js', + output: [ + { + file: 'lib/see-mark-html.cjs', + format: 'cjs', + sourcemap: true, }, - }), - nodeResolve(), - babel({ - babelHelpers: 'bundled', - extensions: ['.js', '.jsx'], - exclude: 'node_modules/**', - }), - ], - jsx: 'react', - external: [/node_modules/], -}; + ], + plugins: sharedPlugins, + external: [/node_modules/], + }, +]; diff --git a/src/entries/html.js b/src/entries/html.js new file mode 100644 index 0000000..03092ae --- /dev/null +++ b/src/entries/html.js @@ -0,0 +1,19 @@ +// Package entry for `@coseeing/see-mark/html` (built to lib/see-mark-html.cjs). +// +// One entry per adapter: the render APIs and the escape utilities (for +// authoring custom components) ship from the same subpath. +import latexDelimiterConvertor from '../content-processor/latext-delimiter-convertor'; + +import createMarkdownToHtmlParser, { + renderToHtml, +} from '../parsers/create-markdown-to-html-parser'; +import createTableOfContents from '../table-of-contents/create-table-of-contents'; + +export { escapeHtml, escapeAttr } from '../markup-converters/html/escape'; + +export { + latexDelimiterConvertor, + createMarkdownToHtmlParser, + renderToHtml, + createTableOfContents, +}; diff --git a/src/markdown-processor/marked-wrapper/marked-wrapper.js b/src/markdown-processor/marked-wrapper/marked-wrapper.js index 72674d1..c531268 100644 --- a/src/markdown-processor/marked-wrapper/marked-wrapper.js +++ b/src/markdown-processor/marked-wrapper/marked-wrapper.js @@ -2,6 +2,23 @@ import { Marked } from 'marked'; import { createPositionTracker } from './position-tracker'; +// Strip SeeMark's dispatch attributes from USER-authored raw HTML. The Stage 1 +// extension renderers emit `data-seemark-element-type` / `data-seemark-payload` +// placeholders that the adapters expand into components; if a user could smuggle +// those attributes via raw HTML in their markdown, they could forge a component +// (notably `math`, whose payload is emitted as live HTML) and inject script. +// Neutralizing the forgery here protects every adapter at the source. Genuine +// placeholders come from extension renderer output (not `html` tokens), so they +// are unaffected. +const SEEMARK_ATTR_PATTERN = + /\s+data-seemark-(?:element-type|payload)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]+)/gi; + +const stripSeemarkAttrs = (token) => { + const raw = + typeof token === 'string' ? token : (token?.text ?? token?.raw ?? ''); + return raw.replace(SEEMARK_ATTR_PATTERN, ''); +}; + const markedProcessorFactory = ({ enableLatex = true, enableAsciimath = true, @@ -23,6 +40,11 @@ const markedProcessorFactory = ({ preprocess: positionTracker.preprocess, }, walkTokens: positionTracker.walkTokens, + renderer: { + html(token) { + return stripSeemarkAttrs(token); + }, + }, }); extensions.forEach((extension) => { diff --git a/src/markup-converters/cross-adapter-parity.test.js b/src/markup-converters/cross-adapter-parity.test.js new file mode 100644 index 0000000..4b993d8 --- /dev/null +++ b/src/markup-converters/cross-adapter-parity.test.js @@ -0,0 +1,155 @@ +import '@testing-library/jest-dom'; +import { render, cleanup } from '@testing-library/react'; + +import createMarkdownToReactParser from '../parsers/create-markdown-to-react-parser'; +import { renderToHtml } from '../parsers/create-markdown-to-html-parser'; +import { SUPPORTED_COMPONENT_TYPES } from '../shared/supported-components'; + +import { fullSyntaxMarkdown } from './html/full-syntax-fixture'; + +// CONTRACT TEST — the formal drift guard between the React and HTML adapters. +// +// The HTML default components are hand-written mirrors of the React ones (a +// deliberate trade-off: it keeps /html framework-free instead of depending on +// react-dom/server). The cost is long-term drift risk, and this suite is the +// enforcement: for the same markdown, both adapters must produce the same +// *semantic* DOM (tag names, semantic attributes, text). Formatting +// differences (self-closing tags, attribute order, className vs class, style +// spacing, empty-string vs omitted attributes) are normalized away — both +// outputs are parsed by the SAME DOM parser first. +// +// Every type in SUPPORTED_COMPONENT_TYPES must be exercised by at least one +// case (each case declares which types it `covers`; a coverage test below +// enforces the union). Adding component #16 without a parity case fails CI. + +const OPTIONS = { + latexDelimiter: 'bracket', + asciimathDelimiter: 'graveaccent', + documentFormat: 'inline', + imageFiles: { 'pic-id': 'https://example.com/p.png' }, +}; + +const renderReactToDom = (markdown) => { + const parse = createMarkdownToReactParser({ + options: OPTIONS, + components: {}, + }); + const { container } = render(parse(markdown)); + return container; +}; + +const toDom = (htmlString) => { + const el = document.createElement('div'); + el.innerHTML = htmlString; + return el; +}; + +// Canonical, format-agnostic signature of a DOM subtree. +const signature = (node) => { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent.replace(/\s+/g, ' ').trim(); + return text ? `#text:${text}` : ''; + } + if (node.nodeType !== Node.ELEMENT_NODE) return ''; + + const attrs = [...node.attributes] + .map((a) => { + if (a.name === 'style') return `style=${node.style.cssText}`; + // Treat empty-string attributes as absent (React renders id="", + // the HTML adapter omits it — semantically identical). + if (a.value === '') return ''; + // MathJax assigns globally-incrementing element IDs (MJX-1-, MJX-2-, ...) + // that differ per render order, not per adapter. Normalize them away. + const value = a.value.replace(/MJX-\d+-/g, 'MJX-N-'); + return `${a.name}=${value}`; + }) + .filter(Boolean) + .sort(); + + const children = [...node.childNodes].map(signature).filter(Boolean); + return `<${node.tagName.toLowerCase()} ${attrs.join(' ')}>[${children.join(',')}]`; +}; + +const parity = (markdown) => { + const reactSig = signature(renderReactToDom(markdown)); + const htmlSig = signature( + toDom(renderToHtml(markdown, { options: OPTIONS })) + ); + return { reactSig, htmlSig }; +}; + +describe('cross-adapter parity (React vs HTML)', () => { + afterEach(() => cleanup()); + + // [name, markdown, covers] — `covers` declares which component types the + // case exercises; the coverage test below enforces the full set. + const cases = [ + ['heading', '# Hello World', ['heading']], + ['alert', '> [!WARNING]\n> A warning message.', ['alert']], + ['alert with backlink', '> [!NOTE]#anchor1\n> noted.', ['alert']], + ['internal link', '[Go]', ['internalLink']], + ['internal link title', '[Go][[a tooltip]]', ['internalLinkTitle']], + ['image', '![cat](pic-id)', ['image']], + ['image link', '![cat](pic-id)((https://example.com))', ['imageLink']], + ['image display', '![cat][[A cat]](pic-id)', ['imageDisplay']], + [ + 'image display link', + '![cat][[A cat]](pic-id)((https://example.com))', + ['imageDisplayLink'], + ], + ['external link tab', '@[Vercel](https://vercel.com)', ['externalLinkTab']], + [ + 'external link title', + '[Vercel][[Homepage]](https://vercel.com)', + ['externalLinkTitle'], + ], + [ + 'external link tab title', + '@[Vercel][[Homepage]](https://vercel.com)', + ['externalLinkTabTitle'], + ], + ['iframe', '@![Demo](https://example.com/embed)', ['iframe']], + ['youtube', '@![Vid](https://www.youtube.com/embed/abc123)', ['youtube']], + ['codepen', '@![Pen](https://codepen.io/user/embed/xyz)', ['codepen']], + ['latex math', 'eq \\(a^2 + b^2 = c^2\\) done', ['math']], + ['asciimath', 'frac `a/b` done', ['math']], + [ + 'mixed paragraph', + '# Title\n\nSee [ref] and ![img](pic-id).', + ['heading', 'internalLink', 'image'], + ], + [ + 'full syntax document', + fullSyntaxMarkdown, + [ + 'heading', + 'image', + 'imageLink', + 'imageDisplay', + 'imageDisplayLink', + 'externalLinkTab', + 'externalLinkTitle', + 'externalLinkTabTitle', + 'youtube', + 'codepen', + 'iframe', + 'math', + ], + ], + ]; + + it.each(cases)( + 'produces matching semantic DOM for: %s', + (_name, markdown) => { + const { reactSig, htmlSig } = parity(markdown); + expect(htmlSig).toBe(reactSig); + } + ); + + it('covers every type in SUPPORTED_COMPONENT_TYPES with at least one case', () => { + const covered = new Set(cases.flatMap(([, , covers = []]) => covers)); + for (const type of Object.values(SUPPORTED_COMPONENT_TYPES)) { + expect([...covered]).toContain(type); + } + }); +}); diff --git a/src/markup-converters/html/converter.js b/src/markup-converters/html/converter.js new file mode 100644 index 0000000..0594416 --- /dev/null +++ b/src/markup-converters/html/converter.js @@ -0,0 +1,96 @@ +import { parseDocument } from 'htmlparser2'; + +import { + SEEMARK_ELEMENT_TYPE_DATA_ATTRIBUTE, + SEE_MARK_PAYLOAD_DATA_ATTRIBUTES, +} from '../../shared/common-markup'; + +import { escapeHtml, escapeAttr } from './escape'; +import defaultComponents from './default-components/default-components'; + +const VOID_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]); + +// Re-serialize a passthrough element's attributes. Values are attribute-escaped +// for correctness (so a quote/angle-bracket in a value cannot break out of the +// attribute), but nothing is dropped or scheme-filtered: the HTML adapter is +// not a sanitizer. Untrusted input belongs in the `sanitize` hook (DOMPurify). +const serializeAttrs = (attribs) => { + if (!attribs) return ''; + return Object.entries(attribs) + .map(([k, v]) => ` ${k}="${escapeAttr(v)}"`) + .join(''); +}; + +const renderElement = (name, attribs, innerHtml) => { + const attrs = serializeAttrs(attribs); + if (VOID_TAGS.has(name)) return `<${name}${attrs}>`; + return `<${name}${attrs}>${innerHtml}`; +}; + +// Stage 1 guarantees parseable payloads (it attribute-escapes the JSON and +// strips forged data-seemark-* from user raw HTML), so an unparseable payload +// can only mean a SeeMark bug — fail loudly instead of silently emitting a +// fallback element that would leak data-seemark-* attributes downstream. +const parsePayload = (payloadStr, type) => { + if (!payloadStr) return {}; + try { + return JSON.parse(payloadStr); + } catch (error) { + throw new Error( + `SeeMark: unparseable ${SEE_MARK_PAYLOAD_DATA_ATTRIBUTES} for component "${type}" — Stage 1/Stage 2 contract violation (${error.message})` + ); + } +}; + +const convertMarkup = (markup = '', components = {}, options = {}) => { + const { sanitize } = options; + const processedComponents = { ...defaultComponents, ...components }; + + const walk = (node) => { + if (node.type === 'text') return escapeHtml(node.data); + if (node.type === 'comment') return ``; + if ( + node.type === 'tag' || + node.type === 'script' || + node.type === 'style' + ) { + const type = node.attribs?.[SEEMARK_ELEMENT_TYPE_DATA_ATTRIBUTE]; + const Component = type && processedComponents[type]; + if (Component) { + const props = parsePayload( + node.attribs[SEE_MARK_PAYLOAD_DATA_ATTRIBUTES], + type + ); + const childrenHtml = (node.children || []).map(walk).join(''); + // Component exceptions propagate: a throwing (custom) component is a + // consumer bug that must surface, not degrade into a plain element. + return Component(props, childrenHtml); + } + const innerHtml = (node.children || []).map(walk).join(''); + return renderElement(node.name, node.attribs, innerHtml); + } + // root / document / cdata / directive + return (node.children || []).map(walk).join(''); + }; + + const doc = parseDocument(markup, { decodeEntities: true }); + const result = (doc.children || []).map(walk).join(''); + return sanitize ? sanitize(result) : result; +}; + +export default convertMarkup; diff --git a/src/markup-converters/html/default-components/alert.js b/src/markup-converters/html/default-components/alert.js new file mode 100644 index 0000000..537703c --- /dev/null +++ b/src/markup-converters/html/default-components/alert.js @@ -0,0 +1,14 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +const alert = ( + { internalLinkId = '', variant = '', title = '' }, + childrenHtml = '' +) => { + const idAttr = internalLinkId ? ` id="${escapeAttr(internalLinkId)}"` : ''; + const backlink = internalLinkId + ? `back` + : ''; + return `

${escapeHtml(variant.toUpperCase())}

${childrenHtml}${backlink}
`; +}; + +export default alert; diff --git a/src/markup-converters/html/default-components/codepen.js b/src/markup-converters/html/default-components/codepen.js new file mode 100644 index 0000000..ad2e6c4 --- /dev/null +++ b/src/markup-converters/html/default-components/codepen.js @@ -0,0 +1,8 @@ +import { escapeAttr } from '../escape'; + +// Attributes adapted from CodePen's official embed snippet. +const codepen = ({ title = '', source = '' } = {}) => { + return ``; +}; + +export default codepen; diff --git a/src/markup-converters/html/default-components/default-components.js b/src/markup-converters/html/default-components/default-components.js new file mode 100644 index 0000000..849a1ad --- /dev/null +++ b/src/markup-converters/html/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/html/default-components/external-link-tab-title.js b/src/markup-converters/html/default-components/external-link-tab-title.js new file mode 100644 index 0000000..a757ad3 --- /dev/null +++ b/src/markup-converters/html/default-components/external-link-tab-title.js @@ -0,0 +1,11 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +const externalLinkTabTitle = ({ + display = '', + title = '', + target = '', +} = {}) => { + return `${escapeHtml(display)}`; +}; + +export default externalLinkTabTitle; diff --git a/src/markup-converters/html/default-components/external-link-tab.js b/src/markup-converters/html/default-components/external-link-tab.js new file mode 100644 index 0000000..79ba238 --- /dev/null +++ b/src/markup-converters/html/default-components/external-link-tab.js @@ -0,0 +1,7 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +const externalLinkTab = ({ display = '', target = '' } = {}) => { + return `${escapeHtml(display)}`; +}; + +export default externalLinkTab; diff --git a/src/markup-converters/html/default-components/external-link-title.js b/src/markup-converters/html/default-components/external-link-title.js new file mode 100644 index 0000000..2bde35c --- /dev/null +++ b/src/markup-converters/html/default-components/external-link-title.js @@ -0,0 +1,7 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +const externalLinkTitle = ({ display = '', title = '', target = '' } = {}) => { + return `${escapeHtml(display)}`; +}; + +export default externalLinkTitle; diff --git a/src/markup-converters/html/default-components/heading.js b/src/markup-converters/html/default-components/heading.js new file mode 100644 index 0000000..5a5fab1 --- /dev/null +++ b/src/markup-converters/html/default-components/heading.js @@ -0,0 +1,11 @@ +import { escapeAttr } from '../escape'; + +const VALID_LEVELS = new Set([1, 2, 3, 4, 5, 6]); + +const heading = ({ id = null, level = 1 } = {}, childrenHtml = '') => { + const lvl = VALID_LEVELS.has(level) ? level : 1; + const idAttr = id ? ` id="${escapeAttr(id)}"` : ''; + return `${childrenHtml}`; +}; + +export default heading; diff --git a/src/markup-converters/html/default-components/iframe.js b/src/markup-converters/html/default-components/iframe.js new file mode 100644 index 0000000..7facbf7 --- /dev/null +++ b/src/markup-converters/html/default-components/iframe.js @@ -0,0 +1,7 @@ +import { escapeAttr } from '../escape'; + +const iframe = ({ title = '', source = '' } = {}) => { + return ``; +}; + +export default iframe; diff --git a/src/markup-converters/html/default-components/image-display-link.js b/src/markup-converters/html/default-components/image-display-link.js new file mode 100644 index 0000000..ecefb4c --- /dev/null +++ b/src/markup-converters/html/default-components/image-display-link.js @@ -0,0 +1,8 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +import image from './image'; + +const imageDisplayLink = ({ display = '', ...props } = {}) => + `
${image(props)}
${escapeHtml(display)}
`; + +export default imageDisplayLink; diff --git a/src/markup-converters/html/default-components/image-display.js b/src/markup-converters/html/default-components/image-display.js new file mode 100644 index 0000000..68a28b2 --- /dev/null +++ b/src/markup-converters/html/default-components/image-display.js @@ -0,0 +1,8 @@ +import { escapeHtml } from '../escape'; + +import image from './image'; + +const imageDisplay = ({ display = '', ...props } = {}) => + `
${image(props)}
${escapeHtml(display)}
`; + +export default imageDisplay; diff --git a/src/markup-converters/html/default-components/image-link.js b/src/markup-converters/html/default-components/image-link.js new file mode 100644 index 0000000..a520dbf --- /dev/null +++ b/src/markup-converters/html/default-components/image-link.js @@ -0,0 +1,8 @@ +import { escapeAttr } from '../escape'; + +import image from './image'; + +const imageLink = (props = {}) => + `${image(props)}`; + +export default imageLink; diff --git a/src/markup-converters/html/default-components/image.js b/src/markup-converters/html/default-components/image.js new file mode 100644 index 0000000..b33b658 --- /dev/null +++ b/src/markup-converters/html/default-components/image.js @@ -0,0 +1,7 @@ +import { escapeAttr } from '../escape'; + +const image = ({ alt = '', imageId = '', source = '' } = {}) => { + return `${escapeAttr(alt)}`; +}; + +export default image; diff --git a/src/markup-converters/html/default-components/internal-link-title.js b/src/markup-converters/html/default-components/internal-link-title.js new file mode 100644 index 0000000..6e71f52 --- /dev/null +++ b/src/markup-converters/html/default-components/internal-link-title.js @@ -0,0 +1,8 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +const internalLinkTitle = ({ display = '', title = '', target = '' } = {}) => { + const safeTarget = escapeAttr(target); + return `${escapeHtml(display)}`; +}; + +export default internalLinkTitle; diff --git a/src/markup-converters/html/default-components/internal-link.js b/src/markup-converters/html/default-components/internal-link.js new file mode 100644 index 0000000..3a74afc --- /dev/null +++ b/src/markup-converters/html/default-components/internal-link.js @@ -0,0 +1,8 @@ +import { escapeHtml, escapeAttr } from '../escape'; + +const internalLink = ({ display = '', target = '' } = {}) => { + const safeTarget = escapeAttr(target); + return `${escapeHtml(display)}`; +}; + +export default internalLink; diff --git a/src/markup-converters/html/default-components/math.js b/src/markup-converters/html/default-components/math.js new file mode 100644 index 0000000..4a478dd --- /dev/null +++ b/src/markup-converters/html/default-components/math.js @@ -0,0 +1,9 @@ +// mathMl and svg are MathJax output produced in Stage 1 — trusted markup +// (Stage 1 strips forged data-seemark-* from user raw HTML, so a math payload +// can only come from the math extension itself). Do not escape — they must +// render as live HTML. +const math = ({ mathMl = '', svg = '' } = {}) => { + return `${mathMl}`; +}; + +export default math; diff --git a/src/markup-converters/html/default-components/youtube.js b/src/markup-converters/html/default-components/youtube.js new file mode 100644 index 0000000..b368111 --- /dev/null +++ b/src/markup-converters/html/default-components/youtube.js @@ -0,0 +1,8 @@ +import { escapeAttr } from '../escape'; + +// Attributes adapted from YouTube's official embed snippet. +const youtube = ({ title = '', source = '' } = {}) => { + return ``; +}; + +export default youtube; diff --git a/src/markup-converters/html/escape.js b/src/markup-converters/html/escape.js new file mode 100644 index 0000000..dd1989f --- /dev/null +++ b/src/markup-converters/html/escape.js @@ -0,0 +1,18 @@ +// Scope note: these provide *correct escaping* (so a value cannot break out of +// its text/attribute context), NOT sanitization. The HTML adapter deliberately +// does not scheme-filter URLs or strip dangerous tags — see the README threat +// model. Untrusted input should be passed through DOMPurify via the `sanitize` +// hook. + +export const escapeHtml = (value) => { + if (value === undefined || value === null) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +// Named separately from escapeHtml for call-site clarity. +export const escapeAttr = escapeHtml; diff --git a/src/markup-converters/html/full-syntax-fixture.js b/src/markup-converters/html/full-syntax-fixture.js new file mode 100644 index 0000000..162a4a2 --- /dev/null +++ b/src/markup-converters/html/full-syntax-fixture.js @@ -0,0 +1,105 @@ +// A single markdown document exercising every supported syntax / component. +// Shared by the HTML-adapter structural test and the cross-adapter parity test. +// +// String.raw keeps LaTeX backslashes (\(, \frac, \\, ...) literal — a plain +// template literal would interpret \f, \( etc. as escape sequences and corrupt +// the math. The document contains no backticks and no ${...}, so String.raw is +// safe here. +export const fullSyntaxMarkdown = String.raw`# H1:基礎語法總覽 +## H2:二階 +### H3:三階 +#### H4:四階 +##### H5:五階 +###### H6:六階 + +--- +## 一、行內文字格式 +**粗體** 與 *斜體* 與 ***粗斜體*** 與 ~~刪除線~~。 + +--- +## 二、清單 +無序: +- 蘋果 +- 香蕉 + - 香蕉皮 + - 香蕉肉 + - 更深一層 +- 櫻桃 + +有序: +1. 第一 +2. 第二 + 1. 巢狀 + 2. 巢狀 +3. 第三 + +--- +## 三、表格 +| 左對齊 | 置中 | 右對齊 | +| :----- | :---: | -----: | +| a | b | c | +| 蘋果 | 香蕉 | 櫻桃 | + +--- +## 四、連結 +標準連結 +[標準連結](https://example.com) + +外部連結開新分頁 +@[Coseeing](https://coseeing.org) + +外部連結帶 title +[Coseeing 首頁][[官方網站]](https://coseeing.org) + +外部連結開新分頁+title +@[Coseeing 文件][[Read the docs]](https://docs.coseeing.org) + +--- + +## 五、圖片 +基本圖片: +![貓咪 alt](https://s.yimg.com/cv/apiv2/ysun01/cat.jpg) + +圖片帶連結: +![貓咪縮圖](https://s.yimg.com/cv/apiv2/ysun01/cat.jpg)((https://example.com/cat)) + +圖片帶顯示說明: +![貓咪 alt][[點圖看完整貓咪]](https://s.yimg.com/cv/apiv2/ysun01/cat.jpg) + +圖片帶顯示說明+連結: +![貓咪 alt][[點圖跳轉]](https://s.yimg.com/cv/apiv2/ysun01/cat.jpg)((https://example.com/cat)) + +--- +## 六、Iframe +一般 iframe: +@![Example 嵌入頁](https://example.com) + +YouTube embed URL: +@![YouTube embed 範例](https://www.youtube.com/embed/dQw4w9WgXcQ) + +YouTube watch URL(會自動轉成 embed): +@![YouTube watch 範例](https://www.youtube.com/watch?v=dQw4w9WgXcQ) + +> CodePen embed: +@![CodePen 範例](https://codepen.io/team/codepen/embed/PNaGbb) + +--- +## 七、數學:LaTeX(bracket 分隔符) +行內 LaTeX:圓面積公式為 \( A = \pi r^{2} \),畢氏定理 \( a^{2} + b^{2} = c^{2} \)。 + +積分: \( \int_{0}^{\infty} e^{-x^{2}} \, dx = \frac{\sqrt{\pi}}{2} \) + +矩陣: +\( \begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix} \) +分數與根號: \( \frac{-b \pm \sqrt{b^{2} - 4ac}}{2a} \) + +--- +## 八、混合測試 +這是一段更長的混合測試文字,包含多種行內元素的組合運用。在學術寫作中,**關鍵術語**通常需要以粗體呈現,而*外文詞彙*或*書名*則習慣以斜體標示。當我們引用外部資源時,可以這樣連結到 @[MDN Web Docs](https://developer.mozilla.org) 或是 @[W3C](https://www.w3.org) 等權威網站。 + +接著測試**粗斜體混用**:***這段文字同時是粗體與斜體***,用於強調特別重要的內容。例如 **useState** 這個 React Hook,可以同時表達語法與重要性。 + +更複雜的情境:當一個句子裡同時出現**粗體連結**透過 @[Access8Math](https://access8math.com) 引用,以及*斜體中包含程式碼*的混合,還有外部資源 @[GitHub Repository](https://github.com) 與 @[npm Package](https://npmjs.com) 並列時,渲染器需要正確處理巢狀語意。 + +最後一段測試特殊符號與標點:引號「中文引號」與 "English quotes"、破折號 —— 這裡是破折號、省略號 ⋯⋯ 以及全形與半形混排。 +`; diff --git a/src/markup-converters/html/full-syntax.test.js b/src/markup-converters/html/full-syntax.test.js new file mode 100644 index 0000000..02f7736 --- /dev/null +++ b/src/markup-converters/html/full-syntax.test.js @@ -0,0 +1,126 @@ +import '@testing-library/jest-dom'; + +import { renderToHtml } from '../../parsers/create-markdown-to-html-parser'; + +import { fullSyntaxMarkdown } from './full-syntax-fixture'; + +// Render the full-syntax document through the HTML adapter and assert the +// structure of every supported component. renderToHtml's output is +// deterministic (MathJax runs in Node at Stage 1), so no real browser is +// needed — jsdom is enough. + +// Parse the adapter's HTML string into a queryable DOM without raw innerHTML. +const parse = (html) => { + const doc = new DOMParser().parseFromString( + `${html}`, + 'text/html' + ); + return doc.body; +}; + +describe('HTML adapter — full-syntax document', () => { + const html = renderToHtml(fullSyntaxMarkdown, { + options: { + latexDelimiter: 'bracket', + documentFormat: 'block', + imageFiles: {}, + shouldBuildImageObjectURL: false, + }, + }); + const root = parse(html); + const q = (selector) => root.querySelectorAll(selector); + + it('renders every heading level, with slugified ids', () => { + expect(q('h1')).toHaveLength(1); + expect(q('h2')).toHaveLength(9); + expect(q('h3')).toHaveLength(1); + expect(q('h4')).toHaveLength(1); + expect(q('h5')).toHaveLength(1); + expect(q('h6')).toHaveLength(1); + expect(root.querySelector('h1').id).toBeTruthy(); + }); + + it('renders inline formatting (bold / italic / strikethrough)', () => { + expect(q('strong').length).toBeGreaterThan(0); + expect(q('em').length).toBeGreaterThan(0); + expect(q('del, s').length).toBeGreaterThan(0); + }); + + it('renders nested unordered and ordered lists', () => { + expect(q('ul').length).toBeGreaterThanOrEqual(3); + expect(q('ol').length).toBeGreaterThanOrEqual(2); + expect(q('li')).toHaveLength(11); + }); + + it('renders a table with a 3-column header', () => { + expect(q('table')).toHaveLength(1); + expect(q('th')).toHaveLength(3); + }); + + it('renders the four link variants with correct target/rel/title', () => { + const byText = (text) => + [...q('a')].find((a) => a.textContent.trim() === text); + + const standard = byText('標準連結'); + expect(standard.getAttribute('href')).toBe('https://example.com'); + expect(standard.getAttribute('target')).toBeNull(); + + const newTab = byText('Coseeing'); + expect(newTab.getAttribute('target')).toBe('_blank'); + expect(newTab.getAttribute('rel')).toBe('noopener noreferrer'); + + const titled = byText('Coseeing 首頁'); + expect(titled.getAttribute('title')).toBe('官方網站'); + expect(titled.getAttribute('target')).toBeNull(); + + const newTabTitled = byText('Coseeing 文件'); + expect(newTabTitled.getAttribute('target')).toBe('_blank'); + expect(newTabTitled.getAttribute('rel')).toBe('noopener noreferrer'); + expect(newTabTitled.getAttribute('title')).toBe('Read the docs'); + + // Every @[...] external link opens a new tab safely (noopener noreferrer). + expect(q('a[target="_blank"][rel="noopener noreferrer"]')).toHaveLength(7); + }); + + it('renders the four image variants with external https src (#37)', () => { + const imgs = [...q('img')]; + expect(imgs).toHaveLength(4); + for (const img of imgs) { + expect(img.getAttribute('src')).toBe( + 'https://s.yimg.com/cv/apiv2/ysun01/cat.jpg' + ); + expect(img.getAttribute('alt')).toBeTruthy(); + expect(img.getAttribute('data-seemark-image-id')).toBeTruthy(); + } + // The two display variants wrap the image in
. + expect(q('figure')).toHaveLength(2); + expect([...q('figcaption')].map((f) => f.textContent.trim())).toEqual([ + '點圖看完整貓咪', + '點圖跳轉', + ]); + }); + + it('renders iframe / youtube / codepen embeds (watch URL → embed)', () => { + const srcs = [...q('iframe')].map((f) => f.getAttribute('src')); + expect(srcs).toHaveLength(4); + // Both the /embed/ URL and the /watch?v= URL resolve to an embed src. + expect(srcs.filter((s) => s.includes('youtube.com/embed/'))).toHaveLength( + 2 + ); + expect(srcs.some((s) => s.includes('codepen.io'))).toBe(true); + expect(srcs.some((s) => s === 'https://example.com')).toBe(true); + }); + + it('renders math with the MathML + SVG accessibility dual track', () => { + // 5 LaTeX expressions → each a screen-reader MathML + an aria-hidden SVG. + expect(q('.sr-only')).toHaveLength(5); + expect(html.match(/]/gi) || []).toHaveLength(5); + expect(q('svg').length).toBeGreaterThanOrEqual(5); + expect(q('[aria-hidden="true"]').length).toBeGreaterThanOrEqual(5); + }); + + it('consumes every placeholder (no leftover data-seemark-* attributes)', () => { + expect(html).not.toContain('data-seemark-element-type'); + expect(html).not.toContain('data-seemark-payload'); + }); +}); diff --git a/src/parsers/create-markdown-to-html-parser.js b/src/parsers/create-markdown-to-html-parser.js new file mode 100644 index 0000000..2bf4de1 --- /dev/null +++ b/src/parsers/create-markdown-to-html-parser.js @@ -0,0 +1,24 @@ +import markdownProcessor from '../markdown-processor/markdown-processor'; +import convertMarkup from '../markup-converters/html/converter'; + +import { createMarkdownParserOptions } from './options'; + +// Factory, mirroring createMarkdownToReactParser: fix the configuration once, +// render many times. +const createMarkdownToHtmlParser = ({ options, components, sanitize } = {}) => { + const parsedOptions = createMarkdownParserOptions(options); + + const parseMarkdown = (markdownContent) => { + const seemarkMarkup = markdownProcessor(markdownContent, parsedOptions); + return convertMarkup(seemarkMarkup, components, { sanitize }); + }; + + return parseMarkdown; +}; + +// One-shot convenience for the common "render a document once" case +// (e.g. the exported-website template). +export const renderToHtml = (markdownContent, config = {}) => + createMarkdownToHtmlParser(config)(markdownContent); + +export default createMarkdownToHtmlParser; diff --git a/src/parsers/create-markdown-to-html-parser.test.js b/src/parsers/create-markdown-to-html-parser.test.js new file mode 100644 index 0000000..2abe41f --- /dev/null +++ b/src/parsers/create-markdown-to-html-parser.test.js @@ -0,0 +1,325 @@ +import '@testing-library/jest-dom'; +import { jest } from '@jest/globals'; + +import createMarkdownToHtmlParser, { + renderToHtml, +} from './create-markdown-to-html-parser'; +import convertMarkup from '../markup-converters/html/converter'; +import { escapeHtml, escapeAttr } from '../markup-converters/html/escape'; + +const mount = (html) => { + const container = document.createElement('div'); + container.append(...parseFragment(html)); + document.body.appendChild(container); + return container; +}; + +// Use DOMParser to avoid the "raw innerHTML assignment" pattern in tests. +const parseFragment = (html) => { + const doc = new DOMParser().parseFromString( + `${html}`, + 'text/html' + ); + return Array.from(doc.body.childNodes); +}; + +describe('renderToHtml', () => { + afterEach(() => { + document.body.textContent = ''; + }); + + it('parses heading markdown', () => { + const html = renderToHtml('# Hello World', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: {}, + }, + }); + const container = mount(html); + const h = container.querySelector('h1'); + expect(h).toBeInTheDocument(); + expect(h.textContent).toBe('Hello World'); + }); + + it('works with null-ish options', () => { + const html = renderToHtml('# Hello World'); + const container = mount(html); + expect(container.querySelector('h1').textContent).toBe('Hello World'); + }); + + it('renders alert with role=region and aria-label', () => { + const html = renderToHtml('> [!WARNING]\n> Watch out.', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: {}, + }, + }); + const container = mount(html); + const region = container.querySelector('[role="region"]'); + expect(region).toBeInTheDocument(); + expect(region.getAttribute('aria-label')).toBe('Warning'); + expect(region.textContent).toContain('WARNING'); + expect(region.textContent).toContain('Watch out.'); + }); + + it('renders internal link', () => { + const html = renderToHtml('See [section]', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: {}, + }, + }); + const container = mount(html); + const a = container.querySelector('a[href="#sec-1"]'); + expect(a).toBeInTheDocument(); + expect(a.id).toBe('sec-1-source'); + expect(a.textContent).toBe('section'); + }); + + it('renders math (mathMl + svg) as multi-sibling span replacement', () => { + const html = renderToHtml('\\(a+b=c\\)', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: {}, + }, + }); + const container = mount(html); + const srOnly = container.querySelector('.sr-only'); + const ariaHidden = container.querySelector('[aria-hidden="true"]'); + expect(srOnly).toBeInTheDocument(); + expect(ariaHidden).toBeInTheDocument(); + expect(srOnly.textContent).toContain('a'); + expect(ariaHidden.querySelector('svg')).toBeInTheDocument(); + }); + + it('renders image with provided imageFiles URL', () => { + const html = renderToHtml('![pikachu](pikachu-path)', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: { 'pikachu-path': 'path/to/pikachu.png' }, + }, + }); + const container = mount(html); + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img.getAttribute('alt')).toBe('pikachu'); + expect(img.getAttribute('src')).toBe('path/to/pikachu.png'); + }); + + it('uses blob URL when shouldBuildImageObjectURL=true', () => { + window.URL.createObjectURL = jest.fn(() => 'mocked-blob-url'); + const html = renderToHtml('![p](pp)', { + options: { + imageFiles: { pp: 'fake-file-content' }, + shouldBuildImageObjectURL: true, + }, + }); + const container = mount(html); + expect(container.querySelector('img').getAttribute('src')).toBe( + 'mocked-blob-url' + ); + window.URL.createObjectURL.mockReset(); + }); + + it('allows overriding default components', () => { + const html = renderToHtml('# Hi', { + components: { + heading: (props, children) => + `
${children}
`, + }, + }); + const container = mount(html); + expect( + container.querySelector('section[data-custom="1"]') + ).toBeInTheDocument(); + expect(container.querySelector('h1')).toBeNull(); + }); + + it('runs the sanitize hook over the final HTML', () => { + const sanitize = jest.fn((s) => `${s}`); + const html = renderToHtml('# Hi', { sanitize }); + expect(sanitize).toHaveBeenCalled(); + expect(html.startsWith('')).toBe(true); + }); + + describe('escape utilities', () => { + it('escapeHtml encodes &, <, >, quotes', () => { + expect(escapeHtml('')).toBe( + '<script>alert(1)</script>' + ); + expect(escapeHtml('a & b')).toBe('a & b'); + expect(escapeHtml(`"x'y"`)).toBe('"x'y"'); + }); + + it('escapeAttr encodes single quote', () => { + expect(escapeAttr("I'm sure")).toBe('I'm sure'); + }); + }); + + describe('raw HTML passthrough — escaped, not sanitized', () => { + // The HTML adapter is not a sanitizer (see README threat model). Raw HTML in + // the markdown source is re-serialized verbatim and attribute values are + // escaped for correctness, but URLs are not scheme-filtered and no tags or + // attributes are dropped. Untrusted input belongs in the `sanitize` hook. + + it('passes a javascript: image source through unchanged', () => { + const html = renderToHtml('![bad](evil)', { + options: { imageFiles: { evil: 'javascript:alert(1)' } }, + }); + const container = mount(html); + expect(container.querySelector('img').getAttribute('src')).toBe( + 'javascript:alert(1)' + ); + }); + + it('passes a javascript: link href through unchanged', () => { + const html = renderToHtml('x'); + expect(html).toContain('href="javascript:alert(1)"'); + }); + + it('keeps on* event-handler attributes (not stripped)', () => { + const html = renderToHtml(''); + expect(html).toMatch(/onerror/i); + expect(html).toContain('/ c' + ); + expect(html).toContain(' { + // Correctness, not sanitization: an embedded quote must stay inside the + // attribute value rather than open a sibling (event-handler) attribute. + const html = renderToHtml( + 'x' + ); + const container = mount(html); + expect(container.querySelector('a').hasAttribute('onmouseover')).toBe( + false + ); + }); + + it('preserves safe raw inline HTML (strong, br)', () => { + const html = renderToHtml('正常 粗體
換行'); + expect(html).toContain('粗體'); + expect(html).toMatch(//); + }); + }); + + describe('Stage 1 attribute escape robustness', () => { + it('handles single-quote in content without breaking the Stage 1 attribute', () => { + // A single quote in the content must not terminate the single-quoted + // Stage 1 payload attribute (regression guard for the payload escaping). + const html = renderToHtml(`> [!NOTE]\n> I'm sure this works.`); + const container = mount(html); + const region = container.querySelector('[role="region"]'); + expect(region).toBeInTheDocument(); + expect(region.textContent).toContain("I'm sure this works."); + }); + }); + + describe('trusted output & contract integrity (still enforced)', () => { + it('emits math mathMl/svg unescaped (trusted MathJax output)', () => { + const html = renderToHtml('\\(x\\)', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: {}, + }, + }); + expect(html).toMatch(//); + expect(html).toMatch(//); + }); + + it('renders the youtube component iframe from SeeMark syntax', () => { + const html = renderToHtml( + '@![Vid](https://www.youtube.com/embed/abc123)' + ); + expect(html).toMatch(/