test: add tests for rehypeScalableSvg (#9248)

This commit is contained in:
one 2025-08-17 18:46:33 +08:00 committed by GitHub
parent 635bc084b7
commit b53a5aa3af
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 370 additions and 8 deletions

View File

@ -243,7 +243,9 @@
"reflect-metadata": "0.2.2",
"rehype-katex": "^7.0.1",
"rehype-mathjax": "^7.1.0",
"rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
"rehype-stringify": "^10.0.1",
"remark-cjk-friendly": "^1.2.0",
"remark-gfm": "^4.0.1",
"remark-github-blockquote-alert": "^2.0.0",

View File

@ -0,0 +1,337 @@
import rehypeParse from 'rehype-parse'
import rehypeStringify from 'rehype-stringify'
import { unified } from 'unified'
import { describe, expect, it } from 'vitest'
import rehypeScalableSvg from '../rehypeScalableSvg'
const processHtml = (html: string): string => {
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeScalableSvg)
.use(rehypeStringify)
.processSync(html)
.toString()
}
const createSvgHtml = (attributes: Record<string, string>): string => {
const attrs = Object.entries(attributes)
.map(([key, value]) => `${key}="${value}"`)
.join(' ')
return `<svg ${attrs}></svg>`
}
describe('rehypeScalableSvg', () => {
describe('simple SVG cases', () => {
it('should add viewBox when missing numeric width and height', () => {
const html = createSvgHtml({ width: '100', height: '50' })
const result = processHtml(html)
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('width="100%"')
expect(result).not.toContain('height=')
expect(result).toContain('max-width: 100')
})
it('should preserve existing viewBox and original dimensions', () => {
const html = createSvgHtml({ width: '100', height: '50', viewBox: '0 0 100 50' })
const result = processHtml(html)
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('width="100"')
expect(result).toContain('height="50"')
expect(result).toContain('max-width: 100')
})
it('should handle different viewBox values and preserve original dimensions', () => {
const html = createSvgHtml({ width: '200', height: '100', viewBox: '10 20 180 80' })
const result = processHtml(html)
expect(result).toContain('viewBox="10 20 180 80"')
expect(result).toContain('width="200"')
expect(result).toContain('height="100"')
expect(result).toContain('max-width: 200')
})
it('should handle numeric width and height as strings', () => {
const html = createSvgHtml({ width: '300', height: '150' })
const result = processHtml(html)
expect(result).toContain('viewBox="0 0 300 150"')
expect(result).toContain('width="100%"')
expect(result).not.toContain('height=')
expect(result).toContain('max-width: 300')
})
it('should handle decimal numeric values', () => {
const html = createSvgHtml({ width: '100.5', height: '50.25' })
const result = processHtml(html)
expect(result).toContain('viewBox="0 0 100.5 50.25"')
expect(result).toContain('width="100%"')
expect(result).not.toContain('height=')
expect(result).toContain('max-width: 100.5')
})
})
describe('complex SVG cases', () => {
it('should flag SVGs with units for runtime measurement', () => {
const html = createSvgHtml({ width: '100px', height: '50px' })
const result = processHtml(html)
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain('width="100px"')
expect(result).toContain('height="50px"')
expect(result).toContain('max-width: 100px')
expect(result).not.toContain('viewBox=')
})
it('should handle various CSS units', () => {
const units = ['px', 'pt', 'em', 'rem', '%', 'cm', 'mm']
units.forEach((unit) => {
const html = createSvgHtml({ width: `100${unit}`, height: `50${unit}` })
const result = processHtml(html)
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain(`width="100${unit}"`)
expect(result).toContain(`height="50${unit}"`)
expect(result).toContain(`max-width: 100${unit}`)
expect(result).not.toContain('viewBox=')
})
})
it('should handle mixed unit types', () => {
const html = createSvgHtml({ width: '100px', height: '2em' })
const result = processHtml(html)
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain('width="100px"')
expect(result).toContain('height="2em"')
expect(result).toContain('max-width: 100px')
expect(result).not.toContain('viewBox=')
})
it('should handle SVGs with only width (no height)', () => {
const html = createSvgHtml({ width: '100px' })
const result = processHtml(html)
expect(result).not.toContain('data-needs-measurement="true"')
expect(result).toContain('width="100px"')
expect(result).toContain('max-width: 100px')
expect(result).not.toContain('viewBox=')
})
it('should handle SVGs with only height (no width)', () => {
const html = createSvgHtml({ height: '50px' })
const result = processHtml(html)
expect(result).not.toContain('data-needs-measurement="true"')
expect(result).toContain('height="50px"')
expect(result).not.toContain('max-width:')
expect(result).not.toContain('viewBox=')
})
})
describe('edge cases', () => {
it('should handle SVG with no properties object', () => {
// Create HTML that will result in an SVG element with no properties
const html = '<svg></svg>'
const result = processHtml(html)
// The plugin should handle undefined properties gracefully
expect(result).toBe('<svg></svg>')
})
it('should handle SVG with no dimensions', () => {
const html = '<svg></svg>'
const result = processHtml(html)
expect(result).not.toContain('width="')
expect(result).not.toContain('height=')
expect(result).not.toContain('viewBox=')
expect(result).not.toContain('data-needs-measurement="true"')
expect(result).not.toContain('max-width:')
})
it('should handle SVG with whitespace-only dimensions', () => {
const html = createSvgHtml({ width: ' ', height: ' ' })
const result = processHtml(html)
expect(result).not.toContain('data-needs-measurement="true"')
expect(result).toContain('width=" "')
expect(result).toContain('height=" "')
expect(result).not.toContain('max-width:')
expect(result).not.toContain('viewBox=')
})
it('should handle SVG with non-numeric strings', () => {
const html = createSvgHtml({ width: 'auto', height: 'inherit' })
const result = processHtml(html)
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain('width="auto"')
expect(result).toContain('height="inherit"')
expect(result).toContain('max-width: auto')
expect(result).not.toContain('viewBox=')
})
it('should handle SVG with mixed numeric and non-numeric values', () => {
const html = createSvgHtml({ width: '100', height: 'auto' })
const result = processHtml(html)
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain('width="100"')
expect(result).toContain('height="auto"')
expect(result).toContain('max-width: 100')
expect(result).not.toContain('viewBox=')
})
})
describe('style handling', () => {
it('should append to existing style attribute for simple SVG', () => {
const html = createSvgHtml({
width: '100',
height: '50',
style: 'fill: red; stroke: blue'
})
const result = processHtml(html)
expect(result).toContain('style="fill: red; stroke: blue; max-width: 100"')
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('width="100%"')
})
it('should handle style attribute with trailing semicolon for simple SVG', () => {
const html = createSvgHtml({
width: '100',
height: '50',
style: 'fill: red;'
})
const result = processHtml(html)
expect(result).toContain('style="fill: red; max-width: 100"')
expect(result).toContain('viewBox="0 0 100 50"')
})
it('should handle empty style attribute for simple SVG', () => {
const html = createSvgHtml({
width: '100',
height: '50',
style: ''
})
const result = processHtml(html)
expect(result).toContain('style="max-width: 100"')
expect(result).toContain('viewBox="0 0 100 50"')
})
it('should handle style with only whitespace for simple SVG', () => {
const html = createSvgHtml({
width: '100',
height: '50',
style: ' '
})
const result = processHtml(html)
expect(result).toContain('style="max-width: 100"')
expect(result).toContain('viewBox="0 0 100 50"')
})
it('should preserve complex style attributes for complex SVG', () => {
const html = createSvgHtml({
width: '100px',
height: '50px',
style: 'fill: url(#gradient); stroke: #333; stroke-width: 2;'
})
const result = processHtml(html)
expect(result).toContain('style="fill: url(#gradient); stroke: #333; stroke-width: 2; max-width: 100px"')
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain('width="100px"')
expect(result).toContain('height="50px"')
})
})
describe('HTML structure handling', () => {
it('should only process SVG elements', () => {
const html = '<div width="100" height="50"></div>'
const result = processHtml(html)
expect(result).toBe('<div width="100" height="50"></div>')
})
it('should process multiple SVG elements in one document', () => {
const html = `
<svg width="100" height="50"></svg>
<svg width="200px" height="100px"></svg>
<svg viewBox="0 0 300 150" width="300" height="150"></svg>
`
const result = processHtml(html)
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('data-needs-measurement="true"')
expect(result).toContain('viewBox="0 0 300 150"')
})
it('should handle nested SVG elements', () => {
const html = `
<svg width="200" height="200">
<svg width="100" height="100"></svg>
</svg>
`
const result = processHtml(html)
expect(result).toContain('viewBox="0 0 200 200"')
expect(result).toContain('viewBox="0 0 100 100"')
})
it('should handle SVG with other attributes', () => {
const html = createSvgHtml({
width: '100',
height: '50',
id: 'test-svg',
class: 'svg-class',
'data-custom': 'value'
})
const result = processHtml(html)
expect(result).toContain('id="test-svg"')
expect(result).toContain('class="svg-class"')
expect(result).toContain('data-custom="value"')
expect(result).toContain('viewBox="0 0 100 50"')
expect(result).toContain('width="100%"')
})
})
describe('numeric validation', () => {
it('should correctly identify numeric strings', () => {
const testCases = [
{ value: '100', expected: true },
{ value: '0', expected: true },
{ value: '-50', expected: true },
{ value: '3.14', expected: true },
{ value: '100px', expected: false },
{ value: 'auto', expected: false },
{ value: '', expected: false },
{ value: ' ', expected: false },
{ value: '100 ', expected: true },
{ value: ' 100', expected: true },
{ value: ' 100 ', expected: true }
]
testCases.forEach(({ value, expected }) => {
const html = createSvgHtml({ width: value, height: '50' })
const result = processHtml(html)
if (expected && value.trim() !== '') {
expect(result).toContain('viewBox="0 0 ' + value.trim() + ' 50"')
} else if (value.trim() === '') {
expect(result).not.toContain('viewBox=')
} else {
expect(result).toContain('data-needs-measurement="true"')
}
})
})
})
})

View File

@ -30,10 +30,10 @@ function rehypeScalableSvg() {
return (tree: Root) => {
visit(tree, 'element', (node: Element) => {
if (node.tagName === 'svg') {
const properties = node.properties || {}
const properties = node.properties
const hasViewBox = 'viewBox' in properties
const width = properties.width as string | undefined
const height = properties.height as string | undefined
const width = (properties.width as string)?.trim()
const height = (properties.height as string)?.trim()
// 1. Universally set max-width from the width attribute if it exists.
// This is safe for both simple and complex cases.
@ -46,16 +46,15 @@ function rehypeScalableSvg() {
// 2. Handle viewBox creation for simple, numeric cases.
if (!hasViewBox && isNumeric(width) && isNumeric(height)) {
properties.viewBox = `0 0 ${width} ${height}`
// Reset or clean up attributes.
properties.width = '100%'
delete properties.height
}
// 3. Flag complex cases for runtime measurement.
else if (!hasViewBox && width && height) {
properties['data-needs-measurement'] = 'true'
}
// 4. Reset or clean up attributes.
properties.width = '100%'
delete properties.height
node.properties = properties
}
})

View File

@ -8621,7 +8621,9 @@ __metadata:
reflect-metadata: "npm:0.2.2"
rehype-katex: "npm:^7.0.1"
rehype-mathjax: "npm:^7.1.0"
rehype-parse: "npm:^9.0.1"
rehype-raw: "npm:^7.0.0"
rehype-stringify: "npm:^10.0.1"
remark-cjk-friendly: "npm:^1.2.0"
remark-gfm: "npm:^4.0.1"
remark-github-blockquote-alert: "npm:^2.0.0"
@ -13728,7 +13730,7 @@ __metadata:
languageName: node
linkType: hard
"hast-util-to-html@npm:^9.0.5":
"hast-util-to-html@npm:^9.0.0, hast-util-to-html@npm:^9.0.5":
version: 9.0.5
resolution: "hast-util-to-html@npm:9.0.5"
dependencies:
@ -19509,6 +19511,17 @@ __metadata:
languageName: node
linkType: hard
"rehype-parse@npm:^9.0.1":
version: 9.0.1
resolution: "rehype-parse@npm:9.0.1"
dependencies:
"@types/hast": "npm:^3.0.0"
hast-util-from-html: "npm:^2.0.0"
unified: "npm:^11.0.0"
checksum: 10c0/efa9ca17673fe70e2d322a1d262796bbed5f6a89382f8f8393352bbd6f6bbf1d4d1d050984b86ff9cb6c0fa2535175ab0829e53c94b1e38fc3c158e6c0ad90bc
languageName: node
linkType: hard
"rehype-raw@npm:^7.0.0":
version: 7.0.0
resolution: "rehype-raw@npm:7.0.0"
@ -19520,6 +19533,17 @@ __metadata:
languageName: node
linkType: hard
"rehype-stringify@npm:^10.0.1":
version: 10.0.1
resolution: "rehype-stringify@npm:10.0.1"
dependencies:
"@types/hast": "npm:^3.0.0"
hast-util-to-html: "npm:^9.0.0"
unified: "npm:^11.0.0"
checksum: 10c0/c643ae3a4862465033e0f1e9f664433767279b4ee9296570746970a79940417ec1fb1997a513659aab97063cf971c5d97e0af8129f590719f01628c8aa480765
languageName: node
linkType: hard
"remark-cjk-friendly@npm:^1.2.0":
version: 1.2.0
resolution: "remark-cjk-friendly@npm:1.2.0"