From 711e63ffb09cc1cac552888bc866caa987be5d4d Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Tue, 19 May 2026 09:29:39 +0800 Subject: [PATCH 01/11] feat: add HTML adapter via @coseeing/see-mark/html Phase 1A: deliver a framework-free renderer that consumes the same Stage 1 markup pipeline as the React adapter, lets consumers replace 16 component slots with `(props, childrenHtml) => htmlString` functions, and ships escape utilities + a safeUrl scheme allow-list for XSS hygiene. The React adapter and Stage 1 are untouched (the payload attribute-escape fix this branch originally carried landed on main independently as #40). Phase 1A includes the 5 default components Access8MathTemplate needs (alert, heading, internal-link, image, math); the other 11 are Phase 1B. The package now exports three entries: `@coseeing/see-mark` (React, unchanged), `@coseeing/see-mark/html` (renderToHtml), `@coseeing/see-mark/html/utils` (escape helpers). `peerDependenciesMeta` makes React optional for consumers using only `/html`. --- package-lock.json | 226 ++++++++++++++++-- package.json | 19 +- rollup.config.mjs | 74 ++++-- src/markup-converters/html/converter.js | 78 ++++++ .../html/default-components/alert.js | 14 ++ .../default-components/default-components.js | 21 ++ .../html/default-components/heading.js | 11 + .../html/default-components/image.js | 7 + .../html/default-components/internal-link.js | 8 + .../html/default-components/math.js | 8 + src/markup-converters/html/escape.js | 47 ++++ src/parsers/create-markdown-to-html-parser.js | 15 ++ .../create-markdown-to-html-parser.test.js | 211 ++++++++++++++++ src/see-mark-html-utils.js | 5 + src/see-mark-html.js | 6 + 15 files changed, 701 insertions(+), 49 deletions(-) create mode 100644 src/markup-converters/html/converter.js create mode 100644 src/markup-converters/html/default-components/alert.js create mode 100644 src/markup-converters/html/default-components/default-components.js create mode 100644 src/markup-converters/html/default-components/heading.js create mode 100644 src/markup-converters/html/default-components/image.js create mode 100644 src/markup-converters/html/default-components/internal-link.js create mode 100644 src/markup-converters/html/default-components/math.js create mode 100644 src/markup-converters/html/escape.js create mode 100644 src/parsers/create-markdown-to-html-parser.js create mode 100644 src/parsers/create-markdown-to-html-parser.test.js create mode 100644 src/see-mark-html-utils.js create mode 100644 src/see-mark-html.js 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..0ec13f5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,19 @@ { "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" + }, + "./html/utils": { + "default": "./lib/see-mark-html-utils.cjs" + } + }, "files": [ "lib", "src" @@ -43,6 +54,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 +63,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..71575c4 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -2,28 +2,56 @@ 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/see-mark-html.js', + output: [ + { + file: 'lib/see-mark-html.cjs', + format: 'cjs', + sourcemap: true, + }, + ], + plugins: sharedPlugins, + external: [/node_modules/], + }, + { + input: 'src/see-mark-html-utils.js', + output: [ + { + file: 'lib/see-mark-html-utils.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/markup-converters/html/converter.js b/src/markup-converters/html/converter.js new file mode 100644 index 0000000..3306e20 --- /dev/null +++ b/src/markup-converters/html/converter.js @@ -0,0 +1,78 @@ +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', +]); + +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}`; +}; + +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 payloadStr = node.attribs[SEE_MARK_PAYLOAD_DATA_ATTRIBUTES]; + try { + const props = payloadStr ? JSON.parse(payloadStr) : {}; + const childrenHtml = (node.children || []).map(walk).join(''); + return Component(props, childrenHtml); + } catch { + // Payload parse failed — fall through to render as a normal element + // so the placeholder is at least visible rather than silently dropped. + } + } + 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/default-components.js b/src/markup-converters/html/default-components/default-components.js new file mode 100644 index 0000000..372c1ce --- /dev/null +++ b/src/markup-converters/html/default-components/default-components.js @@ -0,0 +1,21 @@ +import { SUPPORTED_COMPONENT_TYPES } from '../../../shared/supported-components'; + +import alert from './alert'; +import heading from './heading'; +import internalLink from './internal-link'; +import image from './image'; +import math from './math'; + +// Phase 1A delivers the 5 components required by Access8MathTemplate. +// The remaining 11 (image-link, image-display, image-display-link, +// internal-link-title, external-link-tab/-title/-tab-title, youtube, +// codepen, iframe) are added in Phase 1B. +const defaultComponents = { + [SUPPORTED_COMPONENT_TYPES.ALERT]: alert, + [SUPPORTED_COMPONENT_TYPES.HEADING]: heading, + [SUPPORTED_COMPONENT_TYPES.INTERNAL_LINK]: internalLink, + [SUPPORTED_COMPONENT_TYPES.IMAGE]: image, + [SUPPORTED_COMPONENT_TYPES.MATH]: math, +}; + +export default defaultComponents; 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/image.js b/src/markup-converters/html/default-components/image.js new file mode 100644 index 0000000..555953c --- /dev/null +++ b/src/markup-converters/html/default-components/image.js @@ -0,0 +1,7 @@ +import { escapeAttr, safeUrl } from '../escape'; + +const image = ({ alt = '', imageId = '', source = '' } = {}) => { + return `${escapeAttr(alt)}`; +}; + +export default image; 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..fa1712f --- /dev/null +++ b/src/markup-converters/html/default-components/math.js @@ -0,0 +1,8 @@ +// mathMl and svg are produced by MathJax from the math expression in Stage 1. +// They are trusted markup (matches the React adapter behavior for math). +// 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/escape.js b/src/markup-converters/html/escape.js new file mode 100644 index 0000000..cb655cd --- /dev/null +++ b/src/markup-converters/html/escape.js @@ -0,0 +1,47 @@ +// HTML escape utilities for the HTML adapter. +// +// These are used internally by default components and exposed via +// @coseeing/see-mark/html/utils for consumers writing custom components. + +export const escapeHtml = (value) => { + if (value === undefined || value === null) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +// For attribute values inside double-quoted attributes. +// Mirrors escapeHtml; named separately for call-site clarity. +export const escapeAttr = escapeHtml; + +// Match URL scheme (letter-led, followed by ":") at the start of the string. +const SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i; + +// Whitelist of safe schemes. Schemes outside this set (e.g., javascript:, +// data:, vbscript:, file:) are rejected. URLs without a scheme are treated as +// relative and accepted. +const SAFE_SCHEMES = new Set([ + 'http', + 'https', + 'mailto', + 'tel', + 'ftp', + 'sftp', + 'blob', +]); + +export const safeUrl = (value) => { + if (value === undefined || value === null) return '#'; + const trimmed = String(value).trim(); + if (trimmed === '') return '#'; + const match = trimmed.match(SCHEME_PATTERN); + if (match) { + if (SAFE_SCHEMES.has(match[1].toLowerCase())) return escapeAttr(trimmed); + return '#'; + } + // No scheme — relative URL, fragment, or query. Safe. + return escapeAttr(trimmed); +}; 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..0453542 --- /dev/null +++ b/src/parsers/create-markdown-to-html-parser.js @@ -0,0 +1,15 @@ +import markdownProcessor from '../markdown-processor/markdown-processor'; +import convertMarkup from '../markup-converters/html/converter'; + +import { createMarkdownParserOptions } from './options'; + +const renderToHtml = ( + markdownContent, + { options, components, sanitize } = {} +) => { + const parsedOptions = createMarkdownParserOptions(options); + const seemarkMarkup = markdownProcessor(markdownContent, parsedOptions); + return convertMarkup(seemarkMarkup, components, { sanitize }); +}; + +export default renderToHtml; 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..cd578ef --- /dev/null +++ b/src/parsers/create-markdown-to-html-parser.test.js @@ -0,0 +1,211 @@ +import '@testing-library/jest-dom'; +import { jest } from '@jest/globals'; + +import renderToHtml from './create-markdown-to-html-parser'; +import { + escapeHtml, + escapeAttr, + safeUrl, +} 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('safeUrl whitelists http/https/mailto/tel/relative; rejects javascript:', () => { + expect(safeUrl('javascript:alert(1)')).toBe('#'); + expect(safeUrl('vbscript:foo')).toBe('#'); + expect(safeUrl('data:text/html,')).toBe('#'); + expect(safeUrl('https://example.com')).toBe('https://example.com'); + expect(safeUrl('http://example.com')).toBe('http://example.com'); + expect(safeUrl('/relative/path')).toBe('/relative/path'); + expect(safeUrl('mailto:x@y.com')).toBe('mailto:x@y.com'); + expect(safeUrl('tel:+1234')).toBe('tel:+1234'); + expect(safeUrl('#anchor')).toBe('#anchor'); + }); + + it('escapeAttr encodes single quote (the Stage 1 attribute-escape concern)', () => { + expect(escapeAttr("I'm sure")).toBe('I'm sure'); + }); + }); + + describe('XSS protection', () => { + it('rejects javascript: URLs in image source via default Image', () => { + const html = renderToHtml('![bad](evil)', { + options: { imageFiles: { evil: 'javascript:alert(1)' } }, + }); + const container = mount(html); + expect(container.querySelector('img').getAttribute('src')).toBe('#'); + }); + + it('passes through math mathMl/svg without escaping (trusted MathJax output)', () => { + const html = renderToHtml('\\(x\\)', { + options: { + latexDelimiter: 'bracket', + documentFormat: 'inline', + imageFiles: {}, + }, + }); + expect(html).toMatch(//); + expect(html).toMatch(//); + }); + }); + + describe('Stage 1 attribute escape robustness', () => { + it('handles single-quote in content without breaking the Stage 1 attribute', () => { + // Before the helpers.js escape fix, the single quote in alert content + // would terminate the payload attribute prematurely, causing the + // alert region to lose its content (or the parse to fail entirely). + 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."); + }); + }); +}); diff --git a/src/see-mark-html-utils.js b/src/see-mark-html-utils.js new file mode 100644 index 0000000..960d8f2 --- /dev/null +++ b/src/see-mark-html-utils.js @@ -0,0 +1,5 @@ +export { + escapeHtml, + escapeAttr, + safeUrl, +} from './markup-converters/html/escape'; diff --git a/src/see-mark-html.js b/src/see-mark-html.js new file mode 100644 index 0000000..7925809 --- /dev/null +++ b/src/see-mark-html.js @@ -0,0 +1,6 @@ +import latexDelimiterConvertor from './content-processor/latext-delimiter-convertor'; + +import renderToHtml from './parsers/create-markdown-to-html-parser'; +import createTableOfContents from './table-of-contents/create-table-of-contents'; + +export { latexDelimiterConvertor, renderToHtml, createTableOfContents }; From 343b3fbd97e29bdf934bdc15b64cec6ab608e262 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Sat, 30 May 2026 00:56:02 +0800 Subject: [PATCH 02/11] fix(html): neutralize dangerous raw HTML passthrough (XSS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Browser verification (agent-browser) surfaced that the design assumption "marked escapes inline raw HTML" was false — marked passes raw HTML through Stage 1 verbatim. The HTML adapter then re-emitted it into a string the consumer assigns to innerHTML, making `` actually fire — strictly more dangerous than the React adapter (which drops on* handlers and never executes vDOM scripts). The converter now, for raw passthrough elements only (component placeholders are unaffected): strips on* event-handler attributes, runs href/src/etc. through safeUrl, and drops b c' + ); + expect(html).not.toContain(' { + const html = renderToHtml('正常 粗體
換行'); + expect(html).toContain('粗體'); + expect(html).toMatch(//); + }); }); describe('Stage 1 attribute escape robustness', () => { From 7d93d2ea5b4a0d0e0771cac680e1254c4cb45db0 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Sat, 30 May 2026 19:58:17 +0800 Subject: [PATCH 03/11] feat(html): complete remaining default components + cross-adapter parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1B completes the HTML adapter's default component set (15 total, matching the React adapter's SUPPORTED_COMPONENT_TYPES): adds internal-link-title, image-link, image-display, image-display-link, external-link-tab/-title/-tab-title, youtube, codepen, iframe. URL-bearing props (href/src/target/source) run through safeUrl; text props through escapeHtml/escapeAttr. Adds a cross-adapter parity test that renders the same markdown through both the React and HTML adapters, parses both outputs with the same DOM parser, and asserts identical normalized semantic signatures (tag, semantic attrs, text) — normalizing away formatting differences (self-closing tags, attr order, className/class, style spacing, empty-vs-omitted attrs, and MathJax's globally-incrementing element IDs). 18 cases cover all component types. 138 tests pass. Verified in a real browser (agent-browser): all new components render correctly and the javascript:-URL guard holds on external links. --- .../cross-adapter-parity.test.js | 100 ++++++++++++++++++ .../html/default-components/codepen.js | 8 ++ .../default-components/default-components.js | 26 ++++- .../external-link-tab-title.js | 11 ++ .../default-components/external-link-tab.js | 7 ++ .../default-components/external-link-title.js | 7 ++ .../html/default-components/iframe.js | 7 ++ .../default-components/image-display-link.js | 13 +++ .../html/default-components/image-display.js | 12 +++ .../html/default-components/image-link.js | 12 +++ .../default-components/internal-link-title.js | 8 ++ .../html/default-components/youtube.js | 8 ++ 12 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 src/markup-converters/cross-adapter-parity.test.js create mode 100644 src/markup-converters/html/default-components/codepen.js create mode 100644 src/markup-converters/html/default-components/external-link-tab-title.js create mode 100644 src/markup-converters/html/default-components/external-link-tab.js create mode 100644 src/markup-converters/html/default-components/external-link-title.js create mode 100644 src/markup-converters/html/default-components/iframe.js create mode 100644 src/markup-converters/html/default-components/image-display-link.js create mode 100644 src/markup-converters/html/default-components/image-display.js create mode 100644 src/markup-converters/html/default-components/image-link.js create mode 100644 src/markup-converters/html/default-components/internal-link-title.js create mode 100644 src/markup-converters/html/default-components/youtube.js 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..f1e9f56 --- /dev/null +++ b/src/markup-converters/cross-adapter-parity.test.js @@ -0,0 +1,100 @@ +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'; + +// Parity between the React adapter and the HTML adapter: for the same markdown, +// both 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. + +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()); + + const cases = [ + ['heading', '# Hello World'], + ['alert', '> [!WARNING]\n> A warning message.'], + ['alert with backlink', '> [!NOTE]#anchor1\n> noted.'], + ['internal link', '[Go]'], + ['internal link title', '[Go][[a tooltip]]'], + ['image', '![cat](pic-id)'], + ['image link', '![cat](pic-id)((https://example.com))'], + ['image display', '![cat][[A cat]](pic-id)'], + ['image display link', '![cat][[A cat]](pic-id)((https://example.com))'], + ['external link tab', '@[Vercel](https://vercel.com)'], + ['external link title', '[Vercel][[Homepage]](https://vercel.com)'], + ['external link tab title', '@[Vercel][[Homepage]](https://vercel.com)'], + ['iframe', '@![Demo](https://example.com/embed)'], + ['youtube', '@![Vid](https://www.youtube.com/embed/abc123)'], + ['codepen', '@![Pen](https://codepen.io/user/embed/xyz)'], + ['latex math', 'eq \\(a^2 + b^2 = c^2\\) done'], + ['asciimath', 'frac `a/b` done'], + ['mixed paragraph', '# Title\n\nSee [ref] and ![img](pic-id).'], + ]; + + it.each(cases)( + 'produces matching semantic DOM for: %s', + (_name, markdown) => { + const { reactSig, htmlSig } = parity(markdown); + expect(htmlSig).toBe(reactSig); + } + ); +}); 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..eca5456 --- /dev/null +++ b/src/markup-converters/html/default-components/codepen.js @@ -0,0 +1,8 @@ +import { escapeAttr, safeUrl } from '../escape'; + +// Mirrors the React CodePen default (official embed attributes). +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 index 372c1ce..a07d97e 100644 --- a/src/markup-converters/html/default-components/default-components.js +++ b/src/markup-converters/html/default-components/default-components.js @@ -3,18 +3,36 @@ 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'; -// Phase 1A delivers the 5 components required by Access8MathTemplate. -// The remaining 11 (image-link, image-display, image-display-link, -// internal-link-title, external-link-tab/-title/-tab-title, youtube, -// codepen, iframe) are added in Phase 1B. +// Mirrors the React adapter's default-components (same SUPPORTED_COMPONENT_TYPES +// keys). Each is a pure (props, childrenHtml) => htmlString function. 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, }; 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..84cdba7 --- /dev/null +++ b/src/markup-converters/html/default-components/external-link-tab-title.js @@ -0,0 +1,11 @@ +import { escapeHtml, escapeAttr, safeUrl } 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..e54e14b --- /dev/null +++ b/src/markup-converters/html/default-components/external-link-tab.js @@ -0,0 +1,7 @@ +import { escapeHtml, safeUrl } 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..281808d --- /dev/null +++ b/src/markup-converters/html/default-components/external-link-title.js @@ -0,0 +1,7 @@ +import { escapeHtml, escapeAttr, safeUrl } from '../escape'; + +const externalLinkTitle = ({ display = '', title = '', target = '' } = {}) => { + return `${escapeHtml(display)}`; +}; + +export default externalLinkTitle; 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..9e7c6f7 --- /dev/null +++ b/src/markup-converters/html/default-components/iframe.js @@ -0,0 +1,7 @@ +import { escapeAttr, safeUrl } 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..31f151d --- /dev/null +++ b/src/markup-converters/html/default-components/image-display-link.js @@ -0,0 +1,13 @@ +import { escapeHtml, escapeAttr, safeUrl } from '../escape'; + +const imageDisplayLink = ({ + alt = '', + display = '', + imageId = '', + source = '', + target = '', +} = {}) => { + return `
${escapeAttr(alt)}
${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..615aac8 --- /dev/null +++ b/src/markup-converters/html/default-components/image-display.js @@ -0,0 +1,12 @@ +import { escapeHtml, escapeAttr, safeUrl } from '../escape'; + +const imageDisplay = ({ + alt = '', + display = '', + imageId = '', + source = '', +} = {}) => { + return `
${escapeAttr(alt)}
${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..563bd0f --- /dev/null +++ b/src/markup-converters/html/default-components/image-link.js @@ -0,0 +1,12 @@ +import { escapeAttr, safeUrl } from '../escape'; + +const imageLink = ({ + alt = '', + imageId = '', + source = '', + target = '', +} = {}) => { + return `${escapeAttr(alt)}`; +}; + +export default imageLink; 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/youtube.js b/src/markup-converters/html/default-components/youtube.js new file mode 100644 index 0000000..0a9dd22 --- /dev/null +++ b/src/markup-converters/html/default-components/youtube.js @@ -0,0 +1,8 @@ +import { escapeAttr, safeUrl } from '../escape'; + +// Mirrors the React YouTube default (official embed attributes). +const youtube = ({ title = '', source = '' } = {}) => { + return ``; +}; + +export default youtube; From 2a956b1cd354a7ffbf8eed4d03082b41c5f91ff0 Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Sun, 31 May 2026 14:35:34 +0800 Subject: [PATCH 04/11] fix(html): harden raw-HTML passthrough against XSS (3 review findings) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address three findings from the automated security review of the pushed branch: 1. Forged component payload (math XSS) — a user could smuggle `data-seemark-element-type`/`data-seemark-payload` via raw HTML in their markdown to forge a `math` component (whose payload is emitted as live HTML) and inject script. Fixed at the source in Stage 1: the marked `html` renderer now strips those attributes from user raw HTML, so only genuine extension-renderer placeholders are ever dispatched. Protects both adapters. 2. Dangerous passthrough tags/attributes — the converter now drops iframe, object, embed, meta, base, form, link (in addition to script/style) and strips srcdoc, formaction, http-equiv, ping, background (in addition to on*). `' + ); + expect(html).not.toMatch(/`; }; diff --git a/src/markup-converters/html/default-components/default-components.js b/src/markup-converters/html/default-components/default-components.js index a07d97e..fb9484c 100644 --- a/src/markup-converters/html/default-components/default-components.js +++ b/src/markup-converters/html/default-components/default-components.js @@ -16,8 +16,7 @@ import codepen from './codepen'; import iframe from './iframe'; import math from './math'; -// Mirrors the React adapter's default-components (same SUPPORTED_COMPONENT_TYPES -// keys). Each is a pure (props, childrenHtml) => htmlString function. +// Each default component is a pure (props, childrenHtml) => htmlString function. const defaultComponents = { [SUPPORTED_COMPONENT_TYPES.ALERT]: alert, [SUPPORTED_COMPONENT_TYPES.HEADING]: heading, diff --git a/src/markup-converters/html/default-components/math.js b/src/markup-converters/html/default-components/math.js index fa1712f..4a478dd 100644 --- a/src/markup-converters/html/default-components/math.js +++ b/src/markup-converters/html/default-components/math.js @@ -1,6 +1,7 @@ -// mathMl and svg are produced by MathJax from the math expression in Stage 1. -// They are trusted markup (matches the React adapter behavior for math). -// Do not escape — they must render as live HTML. +// 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}`; }; diff --git a/src/markup-converters/html/default-components/youtube.js b/src/markup-converters/html/default-components/youtube.js index 0a9dd22..dd0a864 100644 --- a/src/markup-converters/html/default-components/youtube.js +++ b/src/markup-converters/html/default-components/youtube.js @@ -1,6 +1,6 @@ import { escapeAttr, safeUrl } from '../escape'; -// Mirrors the React YouTube default (official embed attributes). +// Attributes adapted from YouTube's official embed snippet. const youtube = ({ title = '', source = '' } = {}) => { return ``; }; From 73c6b7ba8891f9b65e3f2fb83aa0d98a2f4dbd1e Mon Sep 17 00:00:00 2001 From: jamliaoo Date: Mon, 29 Jun 2026 18:11:29 +0800 Subject: [PATCH 11/11] refactor(html): drop safeUrl/DROPPED_TAGS; correct-escaping-only, not a sanitizer Remove safeUrl (URL scheme allowlist), raw-html-policy.js (tag/attr denylist), and the DROPPED_TAGS check in the walker. All 8 URL-bearing default components switch from safeUrl(x) to escapeAttr(x). The HTML adapter is now positioned like marked: attribute-escaping prevents quote-context breakout, but scheme filtering and tag/attr removal are left to the sanitize hook (DOMPurify). Tests updated to assert passthrough behaviour rather than XSS blocking; Stage 1 forgery protection (stripSeemarkAttrs) is unaffected and remains the single data-integrity boundary. --- src/entries/html.js | 6 +- src/markup-converters/html/converter.js | 16 ++- .../html/default-components/codepen.js | 4 +- .../default-components/default-components.js | 1 - .../external-link-tab-title.js | 4 +- .../default-components/external-link-tab.js | 4 +- .../default-components/external-link-title.js | 4 +- .../html/default-components/iframe.js | 4 +- .../default-components/image-display-link.js | 4 +- .../html/default-components/image-link.js | 4 +- .../html/default-components/image.js | 4 +- .../html/default-components/youtube.js | 4 +- src/markup-converters/html/escape.js | 51 +------ src/markup-converters/html/raw-html-policy.js | 68 ---------- .../create-markdown-to-html-parser.test.js | 127 +++++++----------- 15 files changed, 82 insertions(+), 223 deletions(-) delete mode 100644 src/markup-converters/html/raw-html-policy.js diff --git a/src/entries/html.js b/src/entries/html.js index 84d1f73..03092ae 100644 --- a/src/entries/html.js +++ b/src/entries/html.js @@ -9,11 +9,7 @@ import createMarkdownToHtmlParser, { } from '../parsers/create-markdown-to-html-parser'; import createTableOfContents from '../table-of-contents/create-table-of-contents'; -export { - escapeHtml, - escapeAttr, - safeUrl, -} from '../markup-converters/html/escape'; +export { escapeHtml, escapeAttr } from '../markup-converters/html/escape'; export { latexDelimiterConvertor, diff --git a/src/markup-converters/html/converter.js b/src/markup-converters/html/converter.js index 47aa20b..0594416 100644 --- a/src/markup-converters/html/converter.js +++ b/src/markup-converters/html/converter.js @@ -5,8 +5,7 @@ import { SEE_MARK_PAYLOAD_DATA_ATTRIBUTES, } from '../../shared/common-markup'; -import { escapeHtml } from './escape'; -import { DROPPED_TAGS, serializeAttrs } from './raw-html-policy'; +import { escapeHtml, escapeAttr } from './escape'; import defaultComponents from './default-components/default-components'; const VOID_TAGS = new Set([ @@ -26,6 +25,17 @@ const VOID_TAGS = new Set([ '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}>`; @@ -71,8 +81,6 @@ const convertMarkup = (markup = '', components = {}, options = {}) => { // consumer bug that must surface, not degrade into a plain element. return Component(props, childrenHtml); } - // Drop dangerous raw passthrough tags entirely. - if (DROPPED_TAGS.has(node.name)) return ''; const innerHtml = (node.children || []).map(walk).join(''); return renderElement(node.name, node.attribs, innerHtml); } diff --git a/src/markup-converters/html/default-components/codepen.js b/src/markup-converters/html/default-components/codepen.js index 70ebbf9..ad2e6c4 100644 --- a/src/markup-converters/html/default-components/codepen.js +++ b/src/markup-converters/html/default-components/codepen.js @@ -1,8 +1,8 @@ -import { escapeAttr, safeUrl } from '../escape'; +import { escapeAttr } from '../escape'; // Attributes adapted from CodePen's official embed snippet. const codepen = ({ title = '', source = '' } = {}) => { - return ``; + 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 index fb9484c..849a1ad 100644 --- a/src/markup-converters/html/default-components/default-components.js +++ b/src/markup-converters/html/default-components/default-components.js @@ -16,7 +16,6 @@ import codepen from './codepen'; import iframe from './iframe'; import math from './math'; -// Each default component is a pure (props, childrenHtml) => htmlString function. const defaultComponents = { [SUPPORTED_COMPONENT_TYPES.ALERT]: alert, [SUPPORTED_COMPONENT_TYPES.HEADING]: heading, 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 index 84cdba7..a757ad3 100644 --- a/src/markup-converters/html/default-components/external-link-tab-title.js +++ b/src/markup-converters/html/default-components/external-link-tab-title.js @@ -1,11 +1,11 @@ -import { escapeHtml, escapeAttr, safeUrl } from '../escape'; +import { escapeHtml, escapeAttr } from '../escape'; const externalLinkTabTitle = ({ display = '', title = '', target = '', } = {}) => { - return `${escapeHtml(display)}`; + 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 index e54e14b..79ba238 100644 --- a/src/markup-converters/html/default-components/external-link-tab.js +++ b/src/markup-converters/html/default-components/external-link-tab.js @@ -1,7 +1,7 @@ -import { escapeHtml, safeUrl } from '../escape'; +import { escapeHtml, escapeAttr } from '../escape'; const externalLinkTab = ({ display = '', target = '' } = {}) => { - return `${escapeHtml(display)}`; + 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 index 281808d..2bde35c 100644 --- a/src/markup-converters/html/default-components/external-link-title.js +++ b/src/markup-converters/html/default-components/external-link-title.js @@ -1,7 +1,7 @@ -import { escapeHtml, escapeAttr, safeUrl } from '../escape'; +import { escapeHtml, escapeAttr } from '../escape'; const externalLinkTitle = ({ display = '', title = '', target = '' } = {}) => { - return `${escapeHtml(display)}`; + return `${escapeHtml(display)}`; }; export default externalLinkTitle; diff --git a/src/markup-converters/html/default-components/iframe.js b/src/markup-converters/html/default-components/iframe.js index 9e7c6f7..7facbf7 100644 --- a/src/markup-converters/html/default-components/iframe.js +++ b/src/markup-converters/html/default-components/iframe.js @@ -1,7 +1,7 @@ -import { escapeAttr, safeUrl } from '../escape'; +import { escapeAttr } from '../escape'; const iframe = ({ title = '', source = '' } = {}) => { - return ``; + 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 index d2e0d49..ecefb4c 100644 --- a/src/markup-converters/html/default-components/image-display-link.js +++ b/src/markup-converters/html/default-components/image-display-link.js @@ -1,8 +1,8 @@ -import { escapeHtml, safeUrl } from '../escape'; +import { escapeHtml, escapeAttr } from '../escape'; import image from './image'; const imageDisplayLink = ({ display = '', ...props } = {}) => - `
${image(props)}
${escapeHtml(display)}
`; + `
${image(props)}
${escapeHtml(display)}
`; export default imageDisplayLink; diff --git a/src/markup-converters/html/default-components/image-link.js b/src/markup-converters/html/default-components/image-link.js index 4d19ae2..a520dbf 100644 --- a/src/markup-converters/html/default-components/image-link.js +++ b/src/markup-converters/html/default-components/image-link.js @@ -1,8 +1,8 @@ -import { safeUrl } from '../escape'; +import { escapeAttr } from '../escape'; import image from './image'; const imageLink = (props = {}) => - `${image(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 index 555953c..b33b658 100644 --- a/src/markup-converters/html/default-components/image.js +++ b/src/markup-converters/html/default-components/image.js @@ -1,7 +1,7 @@ -import { escapeAttr, safeUrl } from '../escape'; +import { escapeAttr } from '../escape'; const image = ({ alt = '', imageId = '', source = '' } = {}) => { - return `${escapeAttr(alt)}`; + return `${escapeAttr(alt)}`; }; export default image; diff --git a/src/markup-converters/html/default-components/youtube.js b/src/markup-converters/html/default-components/youtube.js index dd0a864..b368111 100644 --- a/src/markup-converters/html/default-components/youtube.js +++ b/src/markup-converters/html/default-components/youtube.js @@ -1,8 +1,8 @@ -import { escapeAttr, safeUrl } from '../escape'; +import { escapeAttr } from '../escape'; // Attributes adapted from YouTube's official embed snippet. const youtube = ({ title = '', source = '' } = {}) => { - return ``; + return ``; }; export default youtube; diff --git a/src/markup-converters/html/escape.js b/src/markup-converters/html/escape.js index df8d02c..dd1989f 100644 --- a/src/markup-converters/html/escape.js +++ b/src/markup-converters/html/escape.js @@ -1,7 +1,8 @@ -// HTML escape utilities for the HTML adapter. -// -// These are used internally by default components and exposed via -// @coseeing/see-mark/html/utils for consumers writing custom components. +// 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 ''; @@ -13,45 +14,5 @@ export const escapeHtml = (value) => { .replace(/'/g, '''); }; -// For attribute values inside double-quoted attributes. -// Mirrors escapeHtml; named separately for call-site clarity. +// Named separately from escapeHtml for call-site clarity. export const escapeAttr = escapeHtml; - -// Match URL scheme (letter-led, followed by ":") at the start of the string. -const SCHEME_PATTERN = /^([a-z][a-z0-9+.-]*):/i; - -// Browsers ignore ASCII tab/newline/CR (and other C0 controls / DEL) inside -// URLs, so `java\tscript:alert(1)` is parsed as `javascript:`. We strip them -// before scheme detection so the allowlist check sees what the browser sees — -// otherwise an embedded control char breaks the scheme regex and the value is -// wrongly treated as a safe relative URL. -const CONTROL_CHARS = new RegExp( - `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, - 'g' -); - -// Whitelist of safe schemes. Schemes outside this set (e.g., javascript:, -// data:, vbscript:, file:) are rejected. URLs without a scheme are treated as -// relative and accepted. -const SAFE_SCHEMES = new Set([ - 'http', - 'https', - 'mailto', - 'tel', - 'ftp', - 'sftp', - 'blob', -]); - -export const safeUrl = (value) => { - if (value === undefined || value === null) return '#'; - const trimmed = String(value).replace(CONTROL_CHARS, '').trim(); - if (trimmed === '') return '#'; - const match = trimmed.match(SCHEME_PATTERN); - if (match) { - if (SAFE_SCHEMES.has(match[1].toLowerCase())) return escapeAttr(trimmed); - return '#'; - } - // No scheme — relative URL, fragment, or query. Safe. - return escapeAttr(trimmed); -}; diff --git a/src/markup-converters/html/raw-html-policy.js b/src/markup-converters/html/raw-html-policy.js deleted file mode 100644 index 113492f..0000000 --- a/src/markup-converters/html/raw-html-policy.js +++ /dev/null @@ -1,68 +0,0 @@ -import { escapeAttr, safeUrl } from './escape'; - -// Security policy for raw HTML that passes through Stage 1 verbatim (e.g., -// b c' + 'a c' ); - expect(html).not.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('粗體'); @@ -241,49 +230,23 @@ describe('renderToHtml', () => { }); }); - describe('XSS — control-character scheme bypass (safeUrl)', () => { - it('rejects javascript: with embedded tab/newline/CR', () => { - expect(safeUrl('java\tscript:alert(1)')).toBe('#'); - expect(safeUrl('java\nscript:alert(1)')).toBe('#'); - expect(safeUrl('java\rscript:alert(1)')).toBe('#'); - expect(safeUrl('javascript:alert(1)')).toBe('#'); - }); - - it('still accepts legitimate URLs', () => { - expect(safeUrl('https://example.com')).toBe('https://example.com'); - expect(safeUrl('/rel/path')).toBe('/rel/path'); - }); - }); - - describe('XSS — dangerous passthrough tags & attributes', () => { - it('drops user raw ' - ); - expect(html).not.toMatch(/