├── .gitignore ├── static ├── full.webp ├── icon.png ├── better-svg.webp └── screenshot.png ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── eslint.config.js ├── examples ├── no-xmlns.svg ├── example.vue ├── example.svelte ├── logo.svg ├── example.tsx ├── example.astro ├── example.html ├── example.jsx └── demo.svg ├── tsconfig.json ├── .vscodeignore ├── NOTICE ├── src ├── consts.ts ├── debug_svgo.js ├── utils.ts ├── webview │ ├── index.html │ ├── styles.css │ └── main.js ├── svgEditorProvider.ts ├── svgTransform.ts ├── extension.ts ├── svgGutterPreview.ts └── svgTransform.test.ts ├── CHANGELOG.md ├── SECURITY.md ├── esbuild.js ├── package.json ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | dist 4 | *.vsix 5 | .DS_Store -------------------------------------------------------------------------------- /static/full.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/better-svg/main/static/full.webp -------------------------------------------------------------------------------- /static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/better-svg/main/static/icon.png -------------------------------------------------------------------------------- /static/better-svg.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/better-svg/main/static/better-svg.webp -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/midudev/better-svg/main/static/screenshot.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "connor4312.esbuild-problem-matchers" 4 | ] 5 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import neostandard from 'neostandard' 2 | 3 | export default neostandard({ 4 | ts: true, 5 | ignores: ['out/**', 'node_modules/**'], 6 | }) 7 | -------------------------------------------------------------------------------- /examples/no-xmlns.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /examples/example.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": [ 9 | "--extensionDevelopmentPath=${workspaceFolder}" 10 | ], 11 | "outFiles": [ 12 | "${workspaceFolder}/dist/**/*.js" 13 | ], 14 | "preLaunchTask": "npm: compile" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "ES2020", 5 | "outDir": "out", 6 | "lib": [ 7 | "ES2020" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "moduleResolution": "bundler" 16 | }, 17 | "exclude": [ 18 | "node_modules", 19 | ".vscode-test", 20 | "examples" 21 | ] 22 | } -------------------------------------------------------------------------------- /examples/example.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /examples/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | # files to ignore when packaging the extension 2 | 3 | .vscode/** 4 | .vscode-test/** 5 | src/** 6 | .gitignore 7 | .yarnrc 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | node_modules/** 13 | out/** 14 | esbuild.js 15 | eslint.config.js 16 | pnpm-lock.yaml 17 | package-lock.json 18 | CHANGES.md 19 | .npmrc 20 | .editorconfig 21 | .prettierrc 22 | .git 23 | .gitignore 24 | .DS_Store 25 | *.vsix 26 | *.webp 27 | *.svg 28 | 29 | # examples not needed 30 | examples/** 31 | # static files not needed 32 | static/full.webp 33 | static/screenshot.png 34 | static/better-svg.webp -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Better SVG 2 | Copyright 2025 Miguel Ángel Durán 3 | 4 | This product includes software developed by Miguel Ángel Durán. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | https://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | -------------------------------------------------------------------------------- /examples/example.tsx: -------------------------------------------------------------------------------- 1 | // TSX example 2 | 3 | export const Icon = () => ( 4 | 5 | 6 | 7 | 8 | 9 | ) 10 | 11 | export const ComplicatedComponent = () => { 12 | return ( 13 | {}} 19 | > 20 | 21 | 22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export const SUPPORTED_LANGUAGES = [ 18 | 'astro', 19 | 'ejs', 20 | 'erb', 21 | 'html', 22 | 'javascript', 23 | 'javascriptreact', 24 | 'php', 25 | 'svelte', 26 | 'svg', 27 | 'typescript', 28 | 'typescriptreact', 29 | 'vue', 30 | 'xml', 31 | ] -------------------------------------------------------------------------------- /examples/example.astro: -------------------------------------------------------------------------------- 1 | --- 2 | // Astro example 3 | --- 4 | 5 |
6 |

Astro SVG Example

7 | 18 | 19 | 20 | 21 | 22 | 23 | { 24 | true && ( 25 | // "test svg preview with conditional rendering" 26 | 37 | 38 | 39 | 40 | 41 | ) 42 | } 43 |
44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "Better SVG" extension will be documented in this file. 4 | 5 | ## [0.1.0] - 2025-10-20 6 | 7 | ### Added 8 | - Initial release 9 | - Live SVG preview panel in Explorer sidebar 10 | - Color picker for `currentColor` customization 11 | - Dark background toggle for better SVG visualization 12 | - Zoom and pan functionality (click to zoom, Alt+click to zoom out, Alt+scroll for smooth zoom) 13 | - SVG optimization with SVGO integration 14 | - Auto-reveal/collapse panel when opening/closing SVG files 15 | - Configurable default color for SVG preview 16 | - Grid background for transparent SVGs 17 | - Bundled with esbuild for fast loading 18 | 19 | ### Features 20 | - ✨ **Live Preview**: Real-time SVG preview in Explorer sidebar 21 | - 🎨 **Color Control**: Change `currentColor` value dynamically 22 | - 🌓 **Dark Mode**: Toggle dark background for light-colored SVGs 23 | - 🔍 **Zoom & Pan**: Interactive zoom with Alt key support 24 | - ⚡ **Optimization**: One-click SVG optimization with SVGO 25 | - 📐 **Grid Background**: Checkerboard pattern for transparency 26 | - ⚙️ **Configurable**: Auto-reveal and default color settings 27 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We release security updates for the following versions: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 0.1.x | :white_check_mark: | 10 | 11 | ## Reporting a Vulnerability 12 | 13 | If you discover a security vulnerability in Better SVG, please report it by: 14 | 15 | 1. **Email**: Send details to hi@midu.dev 16 | 2. **GitHub**: Use the [private vulnerability reporting](https://github.com/midudev/better-svg/security/advisories/new) feature 17 | 18 | Please include: 19 | - Description of the vulnerability 20 | - Steps to reproduce 21 | - Potential impact 22 | - Suggested fix (if any) 23 | 24 | We will respond within 48 hours and work on a fix as soon as possible. 25 | 26 | ## Security Measures 27 | 28 | This extension: 29 | - Does not collect or transmit user data 30 | - Does not make external network requests (except for SVGO optimization, which runs locally) 31 | - Uses VS Code's Content Security Policy for webviews 32 | - Is open source and can be audited at https://github.com/midudev/better-svg 33 | - All code is non-obfuscated and readable 34 | 35 | ## License 36 | 37 | This extension is licensed under Apache License 2.0. See [LICENSE](LICENSE) file for details. 38 | -------------------------------------------------------------------------------- /src/debug_svgo.js: -------------------------------------------------------------------------------- 1 | 2 | const { optimize } = require('svgo'); 3 | 4 | const svgContent = ''; 5 | 6 | const removeClasses = false; 7 | 8 | const plugins = [ 9 | { 10 | name: 'preset-default', 11 | params: { 12 | overrides: { 13 | cleanupIds: false, 14 | removeUnknownsAndDefaults: removeClasses 15 | } 16 | } 17 | }, 18 | 'removeDoctype', 19 | 'removeComments', 20 | { 21 | name: 'removeAttrs', 22 | params: { 23 | attrs: [ 24 | 'xmlns:xlink', 25 | 'xml:space', 26 | ...(removeClasses ? ['class'] : []) 27 | ] 28 | } 29 | } 30 | ]; 31 | 32 | const result = optimize(svgContent, { 33 | multipass: true, 34 | plugins 35 | }); 36 | 37 | console.log('Original:', svgContent); 38 | console.log('Optimized:', result.data); 39 | 40 | if (!result.data.includes('v-bind')) { 41 | console.error('FAIL: v-bind was removed'); 42 | } else { 43 | console.log('SUCCESS: v-bind was preserved'); 44 | } 45 | -------------------------------------------------------------------------------- /esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild') 2 | 3 | const production = process.argv.includes('--production') 4 | const watch = process.argv.includes('--watch') 5 | 6 | async function main () { 7 | const ctx = await esbuild.context({ 8 | entryPoints: ['src/extension.ts'], 9 | bundle: true, 10 | format: 'cjs', 11 | minify: true, 12 | sourcemap: !production, 13 | sourcesContent: false, 14 | platform: 'node', 15 | outfile: 'dist/extension.js', 16 | external: ['vscode'], 17 | logLevel: 'warning', 18 | plugins: [ 19 | /* add to the end of plugins array */ 20 | esbuildProblemMatcherPlugin 21 | ] 22 | }) 23 | if (watch) { 24 | await ctx.watch() 25 | } else { 26 | await ctx.rebuild() 27 | await ctx.dispose() 28 | } 29 | } 30 | 31 | /** 32 | * @type {import('esbuild').Plugin} 33 | */ 34 | const esbuildProblemMatcherPlugin = { 35 | name: 'esbuild-problem-matcher', 36 | 37 | setup (build) { 38 | build.onStart(() => { 39 | console.log('[watch] build started') 40 | }) 41 | build.onEnd(result => { 42 | result.errors.forEach(({ text, location }) => { 43 | console.error(`✘ [ERROR] ${text}`) 44 | if (location == null) return 45 | console.error(` ${location.file}:${location.line}:${location.column}:`) 46 | }) 47 | console.log('[watch] build finished') 48 | }) 49 | } 50 | } 51 | 52 | main().catch(e => { 53 | console.error(e) 54 | process.exit(1) 55 | }) 56 | -------------------------------------------------------------------------------- /examples/example.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Formats bytes into a readable string (bytes or KB) 19 | */ 20 | export function formatBytes (bytes: number): string { 21 | if (bytes < 1024) return `${bytes} bytes` 22 | return `${(bytes / 1024).toFixed(2)} KB` 23 | } 24 | 25 | /** 26 | * Calculates savings between original and optimized content 27 | */ 28 | export function calculateSavings (originalContent: string, optimizedContent: string) { 29 | const originalSize = Buffer.byteLength(originalContent, 'utf8') 30 | const optimizedSize = Buffer.byteLength(optimizedContent, 'utf8') 31 | const savingPercent = ((originalSize - optimizedSize) / originalSize * 100).toFixed(2) 32 | 33 | return { 34 | originalSize, 35 | optimizedSize, 36 | savingPercent, 37 | originalSizeFormatted: formatBytes(originalSize), 38 | optimizedSizeFormatted: formatBytes(optimizedSize) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "compile", 7 | "group": "build", 8 | "presentation": { 9 | "reveal": "silent" 10 | }, 11 | "problemMatcher": [] 12 | }, 13 | { 14 | "label": "watch", 15 | "dependsOn": [ 16 | "npm: watch:tsc", 17 | "npm: watch:esbuild", 18 | "npm: watch:webview" 19 | ], 20 | "presentation": { 21 | "reveal": "never" 22 | }, 23 | "group": { 24 | "kind": "build", 25 | "isDefault": true 26 | } 27 | }, 28 | { 29 | "type": "npm", 30 | "script": "watch:esbuild", 31 | "group": "build", 32 | "problemMatcher": { 33 | "owner": "esbuild", 34 | "fileLocation": "absolute", 35 | "pattern": { 36 | "regexp": "^✘ \\[ERROR\\] (.*)$", 37 | "message": 1 38 | }, 39 | "background": { 40 | "activeOnStart": true, 41 | "beginsPattern": "^\\[watch\\] build started$", 42 | "endsPattern": "^\\[watch\\] build finished$" 43 | } 44 | }, 45 | "isBackground": true, 46 | "label": "npm: watch:esbuild", 47 | "presentation": { 48 | "group": "watch", 49 | "reveal": "never" 50 | } 51 | }, 52 | { 53 | "type": "npm", 54 | "script": "watch:tsc", 55 | "group": "build", 56 | "problemMatcher": "$tsc-watch", 57 | "isBackground": true, 58 | "label": "npm: watch:tsc", 59 | "presentation": { 60 | "group": "watch", 61 | "reveal": "never" 62 | } 63 | }, 64 | { 65 | "type": "npm", 66 | "script": "watch:webview", 67 | "group": "build", 68 | "isBackground": true, 69 | "label": "npm: watch:webview", 70 | "presentation": { 71 | "group": "watch", 72 | "reveal": "never" 73 | } 74 | } 75 | ] 76 | } -------------------------------------------------------------------------------- /examples/example.jsx: -------------------------------------------------------------------------------- 1 | // JSX example 2 | 3 | export const Icon = () => ( 4 | 5 | 6 | 7 | 8 | ) 9 | 10 | export const ComplicatedComponent = () => { 11 | return ( 12 | 18 | 24 | 25 | ) 26 | } 27 | 28 | export const ConditionalComponent = (props) => { 29 | return ( 30 |
36 | {ok ? ( 37 | 38 | check-box-solid 39 | 43 | 44 | ) : ( 45 | 46 | times-circle-solid 47 | 51 | 52 | )} 53 |
54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /examples/demo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/webview/index.html: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 24 | SVG Preview 25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 | (Zoom 100%) 33 | 34 |
35 |
36 |
37 | 39 | 40 | 41 | 42 |
43 |
44 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | 54 | 55 | 56 | 57 | 58 |
59 |
60 | 62 | 63 | 65 | 67 | 68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 | {{svgContent}} 76 |
77 |
78 |
79 | 80 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "better-svg", 3 | "displayName": "Better SVG", 4 | "description": "Preview your SVG with color picker, zoom controls, and SVGO optimization.", 5 | "version": "0.4.1", 6 | "publisher": "midudev", 7 | "author": { 8 | "name": "Miguel Ángel Durán", 9 | "email": "hi@midu.dev", 10 | "url": "https://midu.dev" 11 | }, 12 | "pricing": "Free", 13 | "license": "Apache-2.0", 14 | "icon": "./static/icon.png", 15 | "galleryBanner": { 16 | "color": "#df6c31", 17 | "theme": "dark" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/midudev/better-svg" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/midudev/better-svg/issues", 25 | "email": "hi@midu.dev" 26 | }, 27 | "homepage": "https://github.com/midudev/better-svg#readme", 28 | "qna": "marketplace", 29 | "engines": { 30 | "vscode": "^1.85.0" 31 | }, 32 | "activationEvents": [ 33 | "onLanguage:svg", 34 | "onLanguage:xml", 35 | "onLanguage:astro", 36 | "onLanguage:javascriptreact", 37 | "onLanguage:typescriptreact", 38 | "onLanguage:svelte", 39 | "onLanguage:vue" 40 | ], 41 | "categories": [ 42 | "Programming Languages", 43 | "Visualization", 44 | "Other" 45 | ], 46 | "keywords": [ 47 | "svg", 48 | "svg-preview", 49 | "svg-editor", 50 | "svg-optimization", 51 | "svgo", 52 | "vector-graphics", 53 | "graphics", 54 | "visualization", 55 | "preview", 56 | "optimize" 57 | ], 58 | "main": "./dist/extension.js", 59 | "contributes": { 60 | "views": { 61 | "explorer": [ 62 | { 63 | "type": "webview", 64 | "id": "betterSvg.preview", 65 | "name": "SVG Preview", 66 | "icon": "$(eye)", 67 | "when": "betterSvg.hasSvgOpen" 68 | } 69 | ] 70 | }, 71 | "commands": [ 72 | { 73 | "command": "betterSvg.optimize", 74 | "title": "Optimize SVG", 75 | "icon": "$(zap)" 76 | } 77 | ], 78 | "menus": { 79 | "editor/title": [ 80 | { 81 | "command": "betterSvg.optimize", 82 | "when": "resourceExtname == .svg", 83 | "group": "navigation" 84 | } 85 | ] 86 | }, 87 | "configuration": { 88 | "title": "Better SVG", 89 | "properties": { 90 | "betterSvg.autoReveal": { 91 | "type": "boolean", 92 | "default": true, 93 | "description": "Automatically reveal and expand the SVG Preview panel in Explorer when opening SVG files" 94 | }, 95 | "betterSvg.autoCollapse": { 96 | "type": "boolean", 97 | "default": true, 98 | "description": "Automatically collapse the SVG Preview panel in Explorer when closing all SVG files" 99 | }, 100 | "betterSvg.defaultColor": { 101 | "type": "string", 102 | "default": "#ffffff", 103 | "pattern": "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$", 104 | "description": "Default color for currentColor in the SVG preview (hex format, e.g., #ffffff)" 105 | }, 106 | "betterSvg.removeClasses": { 107 | "type": "boolean", 108 | "default": true, 109 | "description": "Remove class attributes from SVG elements during optimization" 110 | }, 111 | "betterSvg.enableHover": { 112 | "type": "boolean", 113 | "default": true, 114 | "description": "Enable hover preview for SVG elements in supported files" 115 | }, 116 | "betterSvg.showGutterPreview": { 117 | "type": "boolean", 118 | "default": true, 119 | "description": "Show SVG preview icons next to line numbers in supported files" 120 | } 121 | } 122 | }, 123 | "configurationDefaults": { 124 | "workbench.editorAssociations": { 125 | "*.svg": "default" 126 | } 127 | } 128 | }, 129 | "scripts": { 130 | "vscode:prepublish": "npm run package", 131 | "compile": "npm run check-types && node esbuild.js && npm run copy-webview", 132 | "watch": "npm-run-all -p watch:*", 133 | "watch:esbuild": "node esbuild.js --watch", 134 | "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", 135 | "watch:webview": "node -e \"require('fs').watch('src/webview', () => require('child_process').exec('npm run copy-webview'))\"", 136 | "package": "npm run check-types && node esbuild.js --production && npm run copy-webview", 137 | "check-types": "tsc --noEmit", 138 | "copy-webview": "mkdir -p dist/webview && cp src/webview/*.html src/webview/*.css src/webview/*.js dist/webview/", 139 | "test": "node --import tsx --test src/*.test.ts", 140 | "test:vscode": "vscode-test", 141 | "lint": "eslint src --ext ts" 142 | }, 143 | "devDependencies": { 144 | "@types/node": "24.x", 145 | "@types/vscode": "^1.85.0", 146 | "@typescript-eslint/eslint-plugin": "8.48.0", 147 | "@typescript-eslint/parser": "8.48.0", 148 | "esbuild": "0.27.0", 149 | "eslint": "9.39.1", 150 | "neostandard": "0.12.2", 151 | "npm-run-all": "4.1.5", 152 | "tsx": "4.19.2", 153 | "typescript": "5.9.3" 154 | }, 155 | "dependencies": { 156 | "svgo": "4.0.0" 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/svgEditorProvider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as vscode from 'vscode' 18 | import * as fs from 'fs' 19 | import { optimizeSvgDocument } from './extension' 20 | 21 | export class SvgPreviewProvider implements vscode.WebviewViewProvider { 22 | public static readonly viewType = 'betterSvg.preview' 23 | private _view?: vscode.WebviewView 24 | private _currentDocument?: vscode.TextDocument 25 | 26 | constructor (private readonly context: vscode.ExtensionContext) {} 27 | 28 | public resolveWebviewView ( 29 | webviewView: vscode.WebviewView, 30 | context: vscode.WebviewViewResolveContext, 31 | _token: vscode.CancellationToken 32 | ): void { 33 | this._view = webviewView 34 | 35 | webviewView.webview.options = { 36 | enableScripts: true, 37 | localResourceRoots: [this.context.extensionUri] 38 | } 39 | 40 | // Initialize with current document if it's an SVG 41 | const editor = vscode.window.activeTextEditor 42 | if (editor && editor.document.fileName.endsWith('.svg')) { 43 | this._currentDocument = editor.document 44 | webviewView.webview.html = this.getHtmlForWebview(webviewView.webview, editor.document) 45 | } else { 46 | webviewView.webview.html = this.getHtmlForWebview(webviewView.webview, null) 47 | } 48 | 49 | // Handle messages from webview 50 | webviewView.webview.onDidReceiveMessage(e => { 51 | switch (e.type) { 52 | case 'update': 53 | if (this._currentDocument) { 54 | this.updateTextDocument(this._currentDocument, e.content) 55 | } 56 | break 57 | case 'optimize': 58 | if (this._currentDocument) { 59 | optimizeSvgDocument(this._currentDocument) 60 | } 61 | break 62 | } 63 | }) 64 | } 65 | 66 | public updatePreview (document: vscode.TextDocument) { 67 | if (this._view) { 68 | this._currentDocument = document 69 | this._view.webview.postMessage({ 70 | type: 'update', 71 | content: document.getText() 72 | }) 73 | } 74 | } 75 | 76 | public clearPreview () { 77 | if (this._view) { 78 | this._currentDocument = undefined 79 | this._view.webview.postMessage({ 80 | type: 'clear' 81 | }) 82 | } 83 | } 84 | 85 | private getHtmlForWebview (webview: vscode.Webview, document: vscode.TextDocument | null): string { 86 | try { 87 | const svgContent = document ? document.getText() : '' 88 | 89 | 90 | // Get default color from configuration 91 | const config = vscode.workspace.getConfiguration('betterSvg') 92 | const defaultColor = config.get('defaultColor', '#ffffff') 93 | 94 | // Debug info 95 | const extensionUri = this.context.extensionUri 96 | if (!extensionUri) { 97 | vscode.window.showErrorMessage('Better SVG: extensionUri is undefined!') 98 | throw new Error('extensionUri is undefined') 99 | } 100 | 101 | // Get URIs for webview resources 102 | const scriptUri = webview.asWebviewUri( 103 | vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'main.js') 104 | ) 105 | const stylesUri = webview.asWebviewUri( 106 | vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'styles.css') 107 | ) 108 | 109 | // Read HTML template 110 | const htmlUri = vscode.Uri.joinPath(extensionUri, 'dist', 'webview', 'index.html') 111 | const htmlPath = htmlUri.fsPath 112 | 113 | if (!htmlPath) { 114 | vscode.window.showErrorMessage(`Better SVG: htmlPath is undefined! URI: ${htmlUri.toString()}`) 115 | throw new Error('htmlPath is undefined') 116 | } 117 | 118 | let html: string 119 | try { 120 | html = fs.readFileSync(htmlPath, 'utf8') 121 | } catch (readError: any) { 122 | vscode.window.showErrorMessage( 123 | 'Better SVG: Failed to read HTML file!\n' + 124 | `Path: ${htmlPath}\n` + 125 | `Error: ${readError.message}` 126 | ) 127 | throw readError 128 | } 129 | 130 | // Replace placeholders 131 | html = html 132 | .replace(/{{cspSource}}/g, webview.cspSource) 133 | .replace(/{{stylesUri}}/g, stylesUri.toString()) 134 | .replace(/{{scriptUri}}/g, scriptUri.toString()) 135 | .replace(/{{svgContent}}/g, () => svgContent) 136 | .replace(/{{defaultColor}}/g, defaultColor) 137 | 138 | return html 139 | } catch (error: any) { 140 | vscode.window.showErrorMessage( 141 | 'Better SVG: Error in getHtmlForWebview!\n' + 142 | `Message: ${error.message}\n` + 143 | `Stack: ${error.stack?.substring(0, 200)}` 144 | ) 145 | throw error 146 | } 147 | } 148 | 149 | private updateTextDocument (document: vscode.TextDocument, content: string) { 150 | const edit = new vscode.WorkspaceEdit() 151 | edit.replace( 152 | document.uri, 153 | new vscode.Range(0, 0, document.lineCount, 0), 154 | content 155 | ) 156 | vscode.workspace.applyEdit(edit) 157 | } 158 | 159 | 160 | } 161 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Better SVG 2 | 3 |
4 | 5 | ![image](./static/screenshot.png) 6 | 7 |
8 | 9 |
10 | A Visual Studio Code extension for editing SVG files with live preview and integrated optimization. 11 |
12 | 13 |

14 | 15 |
16 | 17 | VS Marketplace 18 | 19 |  ❖  20 | 21 | Features 22 | 23 |  ❖  24 | 25 | Usage 26 | 27 |  ❖  28 | 29 | Configuration 30 | 31 |  ❖  32 | 33 | Contributing 34 | 35 |  ❖  36 | 37 | License 38 | 39 |
40 | 41 |

42 | 43 |
44 | 45 | [![made-for-VSCode](https://img.shields.io/badge/Made%20for-VSCode-1f425f.svg)](https://code.visualstudio.com/) 46 | ![SVG Badge](https://img.shields.io/badge/SVG-FFB13B?logo=svg&logoColor=fff&style=flat) 47 | ![SVGO Badge](https://img.shields.io/badge/SVGO-3E7FC1?logo=svgo&logoColor=fff&style=flat) 48 | ![GitHub stars](https://img.shields.io/github/stars/midudev/better-svg) 49 | ![GitHub forks](https://img.shields.io/github/forks/midudev/better-svg) 50 | ![GitHub issues](https://img.shields.io/github/issues/midudev/better-svg) 51 | ![GitHub PRs](https://img.shields.io/github/issues-pr/midudev/better-svg) 52 | 53 |
54 | 55 | ## Features 56 | 57 | - ✨ **Editor with side preview**: Edit your SVG in a textarea with real-time preview in the Explorer panel 58 | - 🎨 **currentColor control**: Change the `currentColor` value to preview different color schemes 59 | - 🌓 **Dark background**: Toggle dark background to better visualize SVGs with light colors 60 | - 🔍 **Zoom and pan**: Zoom in/out with click or Alt+click, scroll with Alt, and drag to pan 61 | - ⚡ **SVGO optimization**: Integrated toolbar button to optimize your SVG 62 | - 📐 **Grid background**: Preview includes a grid background to better see transparent SVGs 63 | 64 | ## Usage 65 | 66 | 1. Open any `.svg` file 67 | 2. The extension will automatically open the custom editor with: 68 | - Code editor taking up the full panel 69 | - Preview panel 70 | 3. Click the ⚡ icon in the toolbar to optimize the SVG 71 | 72 | ### Preview controls 73 | 74 | - **Drag panel**: Click on the "Preview" header and drag 75 | - **Resize**: Use the resize handle in the bottom right corner 76 | - **Zoom in**: Normal click on the preview 77 | - **Zoom out**: Hold Alt + Click 78 | - **Zoom with scroll**: Hold Alt + use mouse wheel 79 | - **Pan**: When zoomed, drag the SVG with left button 80 | - **Change currentColor**: Click the palette icon + color circle 81 | - **Dark background**: Click the moon icon 82 | 83 | ## Configuration 84 | 85 | The extension includes the following configurable options (accessible from Settings → Extensions → Better SVG): 86 | 87 | ### `betterSvg.autoReveal` 88 | 89 | - **Type**: `boolean` 90 | - **Default value**: `true` 91 | - **Description**: Automatically expand the "SVG Preview" panel in Explorer when opening an SVG file. If disabled, you'll need to manually open the panel each time. 92 | 93 | ### `betterSvg.autoCollapse` 94 | 95 | - **Type**: `boolean` 96 | - **Default value**: `true` 97 | - **Description**: Automatically collapse the "SVG Preview" panel when closing all SVG files or switching to a non-SVG file. If disabled, the panel will remain open even when no SVG files are active. 98 | 99 | ### `betterSvg.defaultColor` 100 | 101 | - **Type**: `string` 102 | - **Default value**: `"#ffffff"` 103 | - **Description**: Default color for `currentColor` in the SVG preview. Must be a valid hexadecimal color (e.g., `#ffffff`, `#000`, `#ff5733`). This color will be applied when opening an SVG file and can be manually changed using the color picker in the preview panel. 104 | 105 | ### Example configuration in `settings.json` 106 | 107 | ```json 108 | { 109 | "betterSvg.autoReveal": true, 110 | "betterSvg.autoCollapse": true, 111 | "betterSvg.defaultColor": "#ffffff" 112 | } 113 | ``` 114 | 115 | ## Contributing 116 | 117 | ### Project structure 118 | 119 | ```text 120 | better-svg/ 121 | ├── src/ 122 | │ ├── extension.ts # Extension entry point 123 | │ ├── svgEditorProvider.ts # Custom editor provider 124 | │ └── webview/ # Webview files 125 | │ ├── index.html # HTML template 126 | │ ├── styles.css # CSS styles 127 | │ └── main.js # Webview JavaScript logic 128 | └── package.json 129 | ``` 130 | 131 | ### Installation 132 | 133 | ```bash 134 | cd better-svg 135 | npm install 136 | npm run compile 137 | ``` 138 | 139 | Then press `F5` in VS Code to open an extension window for testing. 140 | 141 | ### Commands 142 | 143 | ```bash 144 | # Single compilation 145 | npm run compile 146 | 147 | # Watch mode (automatically recompiles on save) 148 | npm run watch 149 | 150 | # Optimized production build (minified) 151 | npm run package 152 | ``` 153 | 154 | The extension uses **esbuild** for bundling, which means: 155 | 156 | - ✅ **Faster**: Bundle loads instantly 157 | - ✅ **Smaller**: ~500KB vs multiple files 158 | - ✅ **Web compatible**: Works on github.dev and vscode.dev 159 | - ✅ **Type checking**: TypeScript verifies types without emitting files 160 | 161 | ### Create `.vsix` 162 | 163 | ```bash 164 | npm install -g @vscode/vsce 165 | vsce package 166 | ``` 167 | 168 | ## License 169 | 170 | [Apache-2.0 license](https://github.com/midudev/better-svg?tab=Apache-2.0-1-ov-file#readme) 171 | -------------------------------------------------------------------------------- /src/webview/styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | :root { 18 | color-scheme: var(--vscode-color-scheme, light dark); 19 | } 20 | 21 | * { 22 | margin: 0; 23 | padding: 0; 24 | box-sizing: border-box; 25 | } 26 | 27 | html, body { 28 | height: 100%; 29 | overflow: hidden; 30 | } 31 | 32 | body { 33 | font-family: var(--vscode-font-family); 34 | background-color: var(--vscode-editor-background); 35 | color: var(--vscode-editor-foreground); 36 | } 37 | 38 | button { 39 | border: 0; 40 | background: none; 41 | } 42 | 43 | .preview-wrapper { 44 | min-height: 30vh; 45 | height: 100%; 46 | width: 100%; 47 | display: flex; 48 | flex-direction: column; 49 | } 50 | 51 | .preview-header { 52 | height: 40px; 53 | display: flex; 54 | align-items: center; 55 | justify-content: space-between; 56 | padding: 0 16px; 57 | gap: 8px; 58 | background-color: var(--vscode-sideBar-background); 59 | border-bottom: 1px solid var(--vscode-panel-border); 60 | font-size: 12px; 61 | font-weight: 600; 62 | letter-spacing: 0.06em; 63 | text-transform: uppercase; 64 | color: var(--vscode-foreground); 65 | user-select: none; 66 | flex-shrink: 0; 67 | } 68 | 69 | .preview-header-title { 70 | flex: 1; 71 | } 72 | 73 | .zoom-level, .svg-size{ 74 | font-size: 10px; 75 | font-style: italic; 76 | font-weight: normal; 77 | opacity: 0.7; 78 | margin-left: 4px; 79 | } 80 | 81 | .preview-header-controls { 82 | display: flex; 83 | align-items: center; 84 | gap: 8px; 85 | cursor: default; 86 | } 87 | 88 | .preview-header-controls svg path { 89 | fill: none; 90 | } 91 | 92 | .toggle-dark-bg-wrapper { 93 | display: flex; 94 | align-items: center; 95 | cursor: pointer; 96 | border-radius: 4px; 97 | transition: background-color 0.2s; 98 | padding: 4px; 99 | margin: -4px; 100 | 101 | &:hover { 102 | background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); 103 | } 104 | 105 | &:active { 106 | background-color: color-mix(in srgb, var(--vscode-foreground) 15%, transparent); 107 | } 108 | } 109 | 110 | .toggle-dark-bg { 111 | width: 18px; 112 | height: 18px; 113 | color: var(--vscode-foreground); 114 | opacity: 0.8; 115 | pointer-events: none; 116 | transition: opacity 0.2s; 117 | } 118 | 119 | .toggle-dark-bg-wrapper:hover .toggle-dark-bg { 120 | opacity: 1; 121 | } 122 | 123 | .center-icon-wrapper { 124 | display: flex; 125 | align-items: center; 126 | cursor: pointer; 127 | border-radius: 4px; 128 | transition: background-color 0.2s; 129 | padding: 4px; 130 | margin: -4px; 131 | 132 | &:hover { 133 | background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); 134 | } 135 | 136 | &:active { 137 | background-color: color-mix(in srgb, var(--vscode-foreground) 15%, transparent); 138 | } 139 | } 140 | 141 | .center-icon { 142 | width: 18px; 143 | height: 18px; 144 | color: var(--vscode-foreground); 145 | opacity: 0.8; 146 | pointer-events: none; 147 | transition: opacity 0.2s; 148 | } 149 | 150 | .center-icon-wrapper:hover .center-icon { 151 | opacity: 1; 152 | } 153 | 154 | .optimize-wrapper { 155 | display: flex; 156 | align-items: center; 157 | cursor: pointer; 158 | border-radius: 4px; 159 | transition: background-color 0.2s; 160 | padding: 4px; 161 | margin: -4px; 162 | 163 | &:hover { 164 | background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); 165 | } 166 | 167 | &:active { 168 | background-color: color-mix(in srgb, var(--vscode-foreground) 15%, transparent); 169 | } 170 | } 171 | 172 | .optimize-icon { 173 | width: 18px; 174 | height: 18px; 175 | color: var(--vscode-foreground); 176 | opacity: 0.8; 177 | pointer-events: none; 178 | transition: opacity 0.2s; 179 | } 180 | 181 | .optimize-wrapper:hover .optimize-icon { 182 | opacity: 1; 183 | } 184 | 185 | .color-picker-icon { 186 | width: 18px; 187 | height: 18px; 188 | color: var(--vscode-foreground); 189 | opacity: 0.8; 190 | pointer-events: none; 191 | } 192 | 193 | .color-picker-wrapper { 194 | position: relative; 195 | display: flex; 196 | align-items: center; 197 | gap: 8px; 198 | cursor: pointer; 199 | padding: 4px; 200 | margin: -4px; 201 | border-radius: 4px; 202 | transition: background-color 0.2s; 203 | } 204 | 205 | .color-picker-wrapper:hover { 206 | background-color: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); 207 | } 208 | 209 | .color-picker-wrapper:active { 210 | background-color: color-mix(in srgb, var(--vscode-foreground) 15%, transparent); 211 | } 212 | 213 | .color-picker-swatch { 214 | width: 16px; 215 | height: 16px; 216 | border-radius: 50%; 217 | border: 1px solid var(--vscode-panel-border); 218 | pointer-events: none; 219 | } 220 | 221 | input[type="color"] { 222 | position: absolute; 223 | opacity: 0; 224 | width: 0; 225 | height: 0; 226 | pointer-events: none; 227 | } 228 | 229 | .preview-content { 230 | flex: 1; 231 | padding: 16px; 232 | background-color: var(--vscode-editorWidget-background); 233 | background: repeating-conic-gradient( 234 | color-mix(in srgb, var(--vscode-editorWidget-background) 90%, transparent) 0% 25%, 235 | color-mix(in srgb, var(--vscode-editorWidget-border) 85%, transparent) 0% 50% 236 | ) 50% / 18px 18px; 237 | display: flex; 238 | align-items: center; 239 | justify-content: center; 240 | overflow: hidden; 241 | position: relative; 242 | cursor: zoom-in; 243 | transition: background-color 0.3s ease; 244 | } 245 | 246 | .preview-content.dark-background { 247 | background-color: rgba(0, 0, 0, 0.7); 248 | background-image: none; 249 | } 250 | 251 | .preview-content.zoom-out-cursor { 252 | cursor: zoom-out; 253 | } 254 | 255 | .preview-content.grabbing { 256 | cursor: grabbing; 257 | } 258 | 259 | .preview-svg-wrapper { 260 | transform-origin: center center; 261 | transition: transform 0.1s ease-out; 262 | display: flex; 263 | align-items: center; 264 | justify-content: center; 265 | width: 100%; 266 | height: 100%; 267 | } 268 | 269 | .preview-svg-wrapper svg { 270 | max-width: none; 271 | max-height: none; 272 | display: block; 273 | } 274 | -------------------------------------------------------------------------------- /src/webview/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const $ = document.querySelector.bind(document) 18 | const vscode = acquireVsCodeApi() 19 | 20 | ;(function () { 21 | const preview = $('#preview') 22 | const svgWrapper = $('#svgWrapper') 23 | const colorPicker = $('#colorPicker') 24 | const colorPickerWrapper = $('#colorPickerWrapper') 25 | const colorSwatch = $('#colorSwatch') 26 | const toggleDarkBg = $('#toggleDarkBg') 27 | const toggleDarkBgWrapper = $('#toggleDarkBgWrapper') 28 | const centerIconWrapper = $('#centerIconWrapper') 29 | const optimizeWrapper = $('#optimizeWrapper') 30 | const zoomLevel = $('#zoomLevel') 31 | const svgSize = $('#svgSize') 32 | 33 | // Get default color from the color picker value (set by template) 34 | let currentColor = colorPicker.value 35 | let isDarkBackground = false 36 | 37 | // Zoom and pan state 38 | let scale = 1 39 | let translateX = 0 40 | let translateY = 0 41 | let isPanning = false 42 | let panStartX = 0 43 | let panStartY = 0 44 | let isAltPressed = false 45 | 46 | // Initialize color 47 | colorSwatch.style.backgroundColor = currentColor 48 | svgWrapper.style.color = currentColor 49 | 50 | // Ensure SVG has a width if it's missing both width and height 51 | const ensureSvgWidth = () => { 52 | const wrapper = $('#svgWrapper') 53 | if (wrapper) { 54 | const svg = wrapper.querySelector('svg') 55 | if (svg && !svg.hasAttribute('width') && !svg.hasAttribute('height')) { 56 | const previewWidth = preview.clientWidth 57 | svg.setAttribute('width', Math.floor(previewWidth * 0.7) + 'px') 58 | } 59 | } 60 | } 61 | 62 | // Ensure initial SVG has width if needed 63 | setTimeout(() => ensureSvgWidth(), 0) 64 | 65 | // Update preview with currentColor 66 | const updatePreviewWithColor = (content) => { 67 | const wrapper = $('#svgWrapper') 68 | if (wrapper) { 69 | wrapper.innerHTML = content 70 | wrapper.style.color = currentColor 71 | ensureSvgWidth() 72 | updateTransform() 73 | } 74 | } 75 | 76 | const updateTransform = () => { 77 | svgWrapper.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})` 78 | zoomLevel.textContent = `(${Math.round(scale * 100)}%)` 79 | } 80 | 81 | const resetZoom = () => { 82 | scale = 1 83 | translateX = 0 84 | translateY = 0 85 | updateTransform() 86 | } 87 | 88 | const updateSvgFileSize = () => { 89 | const wrapper = $('#svgWrapper') 90 | if (wrapper) { 91 | const byteSize = wrapper.getHTML().length 92 | const size = (byteSize / 1024).toFixed(1) 93 | svgSize.textContent = `(${size} KB)` 94 | } 95 | } 96 | 97 | colorPickerWrapper.addEventListener('click', () => { 98 | colorPicker.click() 99 | }) 100 | 101 | colorPicker.addEventListener('input', (e) => { 102 | currentColor = e.target.value 103 | colorSwatch.style.backgroundColor = currentColor 104 | svgWrapper.style.color = currentColor 105 | }) 106 | 107 | // Toggle dark background 108 | toggleDarkBgWrapper.addEventListener('click', () => { 109 | isDarkBackground = !isDarkBackground 110 | 111 | if (isDarkBackground) { 112 | preview.classList.add('dark-background') 113 | toggleDarkBg.innerHTML = '' 114 | toggleDarkBg.setAttribute('fill', 'currentColor') 115 | toggleDarkBg.removeAttribute('stroke') 116 | toggleDarkBg.removeAttribute('stroke-width') 117 | toggleDarkBg.removeAttribute('stroke-linecap') 118 | toggleDarkBg.removeAttribute('stroke-linejoin') 119 | } else { 120 | preview.classList.remove('dark-background') 121 | toggleDarkBg.innerHTML = '' 122 | toggleDarkBg.setAttribute('fill', 'none') 123 | toggleDarkBg.setAttribute('stroke', 'currentColor') 124 | toggleDarkBg.setAttribute('stroke-width', '1.5') 125 | toggleDarkBg.setAttribute('stroke-linecap', 'round') 126 | toggleDarkBg.setAttribute('stroke-linejoin', 'round') 127 | } 128 | }) 129 | 130 | // Center icon functionality 131 | centerIconWrapper.addEventListener('click', () => { 132 | resetZoom() 133 | }) 134 | 135 | // Optimize functionality 136 | optimizeWrapper.addEventListener('click', () => { 137 | vscode.postMessage({ 138 | type: 'optimize' 139 | }) 140 | }) 141 | 142 | // Zoom and pan functionality 143 | preview.addEventListener('click', (e) => { 144 | if (e.target === preview || e.target === svgWrapper || e.target.closest('svg')) { 145 | // Check both the stored state and the event's altKey 146 | if (isAltPressed || e.altKey) { 147 | // Zoom out 148 | scale = Math.max(0.1, scale - 0.2) 149 | } else { 150 | // Zoom in 151 | scale = Math.min(10, scale + 0.5) 152 | } 153 | updateTransform() 154 | } 155 | }) 156 | 157 | preview.addEventListener('wheel', (e) => { 158 | // Check both the stored state and the event's altKey 159 | if (isAltPressed || e.altKey) { 160 | e.preventDefault() 161 | const delta = e.deltaY > 0 ? -0.1 : 0.1 162 | scale = Math.max(0.1, Math.min(10, scale + delta)) 163 | updateTransform() 164 | } 165 | }, { passive: false }) 166 | 167 | preview.addEventListener('mousedown', (e) => { 168 | // Only start panning if clicking on the SVG with left button and not on color picker 169 | if (e.button === 0 && scale > 1 && !e.target.closest('.preview-header-controls')) { 170 | isPanning = true 171 | panStartX = e.clientX - translateX 172 | panStartY = e.clientY - translateY 173 | preview.classList.add('grabbing') 174 | e.preventDefault() 175 | } 176 | }) 177 | 178 | window.addEventListener('mousemove', (e) => { 179 | if (isPanning) { 180 | translateX = e.clientX - panStartX 181 | translateY = e.clientY - panStartY 182 | updateTransform() 183 | } 184 | 185 | // Update cursor based on altKey state 186 | if (e.altKey && !isAltPressed) { 187 | isAltPressed = true 188 | preview.classList.add('zoom-out-cursor') 189 | } else if (!e.altKey && isAltPressed) { 190 | isAltPressed = false 191 | preview.classList.remove('zoom-out-cursor') 192 | } 193 | }) 194 | 195 | window.addEventListener('mouseup', () => { 196 | if (isPanning) { 197 | isPanning = false 198 | preview.classList.remove('grabbing') 199 | } 200 | }) 201 | 202 | // Track Alt key state 203 | window.addEventListener('keydown', (e) => { 204 | if (e.key === 'Alt' || e.key === 'Option') { 205 | isAltPressed = true 206 | preview.classList.add('zoom-out-cursor') 207 | } 208 | }) 209 | 210 | window.addEventListener('keyup', (e) => { 211 | if (e.key === 'Alt' || e.key === 'Option') { 212 | isAltPressed = false 213 | preview.classList.remove('zoom-out-cursor') 214 | } 215 | }) 216 | 217 | // Reset Alt state when window loses focus 218 | window.addEventListener('blur', () => { 219 | isAltPressed = false 220 | preview.classList.remove('zoom-out-cursor') 221 | }) 222 | 223 | // Listen for updates from extension 224 | window.addEventListener('message', event => { 225 | const message = event.data 226 | if (message.type === 'update') { 227 | updateSvgFileSize() 228 | updatePreviewWithColor(message.content) 229 | resetZoom() 230 | } else if (message.type === 'clear') { 231 | updateSvgFileSize() 232 | svgWrapper.innerHTML = '' 233 | resetZoom() 234 | } 235 | }) 236 | })() 237 | -------------------------------------------------------------------------------- /src/svgTransform.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Map of JSX camelCase attributes to SVG kebab-case attributes 19 | */ 20 | export const jsxToSvgAttributeMap: Record = { 21 | // Stroke attributes 22 | strokeWidth: 'stroke-width', 23 | strokeLinecap: 'stroke-linecap', 24 | strokeLinejoin: 'stroke-linejoin', 25 | strokeDasharray: 'stroke-dasharray', 26 | strokeDashoffset: 'stroke-dashoffset', 27 | strokeMiterlimit: 'stroke-miterlimit', 28 | strokeOpacity: 'stroke-opacity', 29 | // Fill attributes 30 | fillOpacity: 'fill-opacity', 31 | fillRule: 'fill-rule', 32 | // Clip attributes 33 | clipPath: 'clip-path', 34 | clipRule: 'clip-rule', 35 | // Font attributes 36 | fontFamily: 'font-family', 37 | fontSize: 'font-size', 38 | fontStyle: 'font-style', 39 | fontWeight: 'font-weight', 40 | // Text attributes 41 | textAnchor: 'text-anchor', 42 | textDecoration: 'text-decoration', 43 | dominantBaseline: 'dominant-baseline', 44 | alignmentBaseline: 'alignment-baseline', 45 | baselineShift: 'baseline-shift', 46 | // Gradient/filter attributes 47 | stopColor: 'stop-color', 48 | stopOpacity: 'stop-opacity', 49 | colorInterpolation: 'color-interpolation', 50 | colorInterpolationFilters: 'color-interpolation-filters', 51 | floodColor: 'flood-color', 52 | floodOpacity: 'flood-opacity', 53 | lightingColor: 'lighting-color', 54 | // Marker attributes 55 | markerStart: 'marker-start', 56 | markerMid: 'marker-mid', 57 | markerEnd: 'marker-end', 58 | // Other attributes 59 | paintOrder: 'paint-order', 60 | vectorEffect: 'vector-effect', 61 | shapeRendering: 'shape-rendering', 62 | imageRendering: 'image-rendering', 63 | pointerEvents: 'pointer-events', 64 | xlinkHref: 'xlink:href' 65 | } 66 | 67 | /** 68 | * Create the reverse map: SVG kebab-case to JSX camelCase 69 | */ 70 | export const svgToJsxAttributeMap: Record = Object.fromEntries( 71 | Object.entries(jsxToSvgAttributeMap).map(([jsx, svg]) => [svg, jsx]) 72 | ) 73 | 74 | /** 75 | * Detects if the SVG content contains JSX-specific syntax 76 | * (camelCase attributes, expression values like {2}, className, etc.) 77 | */ 78 | export function isJsxSvg (svgContent: string): boolean { 79 | // Check for JSX expression values like ={2} or ={variable} 80 | if (/=\{[^}]+\}/.test(svgContent)) { 81 | return true 82 | } 83 | 84 | // Check for spread attributes like {...props} 85 | if (/\{\.\.\.[^}]+\}/.test(svgContent)) { 86 | return true 87 | } 88 | 89 | // Check for className attribute 90 | if (/\bclassName=/.test(svgContent)) { 91 | return true 92 | } 93 | 94 | // Check for any known JSX camelCase attributes 95 | for (const jsxAttr of Object.keys(jsxToSvgAttributeMap)) { 96 | const regex = new RegExp(`\\b${jsxAttr}=`, 'g') 97 | if (regex.test(svgContent)) { 98 | return true 99 | } 100 | } 101 | 102 | return false 103 | } 104 | 105 | /** 106 | * Converts JSX SVG syntax to valid SVG XML 107 | * - Converts expression values {2} to "2" 108 | * - Converts className to class 109 | * - Converts camelCase attributes to kebab-case 110 | */ 111 | /** 112 | * Helper to replace JSX expressions like ={...} with ="..." 113 | * Handles nested braces and strings correctly 114 | */ 115 | function replaceJsxExpressions (content: string): string { 116 | let result = '' 117 | let currentIndex = 0 118 | 119 | while (currentIndex < content.length) { 120 | const startIdx = content.indexOf('={', currentIndex) 121 | if (startIdx === -1) { 122 | result += content.slice(currentIndex) 123 | break 124 | } 125 | 126 | // Append everything before "={" 127 | result += content.slice(currentIndex, startIdx) 128 | 129 | // Find matching brace 130 | let balance = 1 131 | let j = startIdx + 2 132 | let found = false 133 | let inString = false 134 | let stringChar = '' 135 | 136 | while (j < content.length) { 137 | const char = content[j] 138 | const prevChar = content[j - 1] 139 | 140 | if (inString) { 141 | if (char === stringChar && prevChar !== '\\') { 142 | inString = false 143 | } 144 | } else { 145 | if (char === '"' || char === '\'' || char === '`') { 146 | inString = true 147 | stringChar = char 148 | } else if (char === '{') { 149 | balance++ 150 | } else if (char === '}') { 151 | balance-- 152 | } 153 | } 154 | 155 | j++ 156 | 157 | if (!inString && balance === 0) { 158 | found = true 159 | break 160 | } 161 | } 162 | 163 | if (found) { 164 | const expression = content.slice(startIdx + 2, j - 1) 165 | // Escape special XML characters inside the attribute value 166 | const escapedExpression = expression 167 | .replace(/&/g, '&') 168 | .replace(//g, '>') 170 | .replace(/"/g, '"') 171 | result += `="${escapedExpression}"` 172 | currentIndex = j 173 | } else { 174 | // Failed to find matching brace, just skip "={" 175 | result += '={' 176 | currentIndex = startIdx + 2 177 | } 178 | } 179 | 180 | return result 181 | } 182 | 183 | /** 184 | * Converts JSX SVG syntax to valid SVG XML 185 | * - Converts expression values {2} to "2" 186 | * - Converts className to class 187 | * - Converts camelCase attributes to kebab-case 188 | */ 189 | export function convertJsxToSvg (svgContent: string): string { 190 | // Convert JSX expression values like {2} to "2" 191 | // Handle both simple values and expressions using the robust parser 192 | svgContent = replaceJsxExpressions(svgContent) 193 | 194 | // Convert spread attributes {...props} to data-spread-i="props" 195 | let spreadIndex = 0 196 | svgContent = svgContent.replace(/\{\.\.\.([^}]+)\}/g, (_match, expression) => { 197 | // Escape double quotes inside the attribute value 198 | const escapedExpression = expression.replace(/"/g, '"') 199 | return `data-spread-${spreadIndex++}="${escapedExpression}"` 200 | }) 201 | 202 | // Convert className to class 203 | svgContent = svgContent.replace(/\bclassName=/g, 'class=') 204 | 205 | // Convert all JSX camelCase attributes to SVG kebab-case 206 | for (const [jsx, svg] of Object.entries(jsxToSvgAttributeMap)) { 207 | const regex = new RegExp(`\\b${jsx}=`, 'g') 208 | svgContent = svgContent.replace(regex, `${svg}=`) 209 | } 210 | 211 | // Rename event handlers to avoid security blocking in previews 212 | // data-jsx-event-onClick="..." 213 | svgContent = svgContent.replace(/\b(on[A-Z]\w*)=/g, 'data-jsx-event-$1=') 214 | 215 | return svgContent 216 | } 217 | 218 | /** 219 | * Converts SVG XML syntax back to JSX 220 | * - Converts class to className 221 | * - Converts kebab-case attributes to camelCase 222 | */ 223 | export function convertSvgToJsx (svgContent: string): string { 224 | // Convert class to className 225 | svgContent = svgContent.replace(/\bclass=/g, 'className=') 226 | 227 | // Convert all SVG kebab-case attributes to JSX camelCase 228 | for (const [svg, jsx] of Object.entries(svgToJsxAttributeMap)) { 229 | // Need to escape hyphens and colons for regex 230 | const escapedSvg = svg.replace(/[-:]/g, '\\$&') 231 | const regex = new RegExp(`\\b${escapedSvg}=`, 'g') 232 | svgContent = svgContent.replace(regex, `${jsx}=`) 233 | } 234 | 235 | // Convert data-spread-i="props" back to {...props} 236 | svgContent = svgContent.replace(/\bdata-spread-\d+="([^"]*)"/g, (_match, value) => { 237 | // Unescape " back to " and other html entities 238 | const unescaped = value 239 | .replace(/"/g, '"') 240 | .replace(/>/g, '>') 241 | .replace(/</g, '<') 242 | .replace(/&/g, '&') 243 | return `{...${unescaped}}` 244 | }) 245 | 246 | // Restore event handlers to expressions 247 | // Matches data-jsx-event-onClick="..." and converts back to onClick={() => ...} 248 | svgContent = svgContent.replace(/\bdata-jsx-event-(on[A-Z]\w*)="([^"]*)"/g, (match, attr, value) => { 249 | // Unescape " back to " and other html entities 250 | // Note: the value was escaped in replaceJsxExpressions or originally in the attribute 251 | const unescaped = value 252 | .replace(/"/g, '"') 253 | .replace(/>/g, '>') 254 | .replace(/</g, '<') 255 | .replace(/&/g, '&') 256 | return `${attr}={${unescaped}}` 257 | }) 258 | 259 | // Also handle single occurrences of restored logic for generic expressions if valid? 260 | // For now, restricting to event handlers is safer to avoid converting string props to invalid code. 261 | 262 | return svgContent 263 | } 264 | 265 | /** 266 | * Prepares JSX SVG content for SVGO optimization 267 | * Returns the converted SVG and metadata about whether conversion was applied 268 | */ 269 | export function prepareForOptimization (svgContent: string): { 270 | preparedSvg: string 271 | wasJsx: boolean 272 | } { 273 | const wasJsx = isJsxSvg(svgContent) 274 | 275 | if (wasJsx) { 276 | return { 277 | preparedSvg: convertJsxToSvg(svgContent), 278 | wasJsx: true 279 | } 280 | } 281 | 282 | return { 283 | preparedSvg: svgContent, 284 | wasJsx: false 285 | } 286 | } 287 | 288 | /** 289 | * Converts optimized SVG back to JSX if the original was JSX 290 | */ 291 | export function finalizeAfterOptimization (optimizedSvg: string, wasJsx: boolean): string { 292 | if (wasJsx) { 293 | return convertSvgToJsx(optimizedSvg) 294 | } 295 | 296 | return optimizedSvg 297 | } 298 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | https://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Miguel Ángel Durán 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | https://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as vscode from 'vscode' 18 | import { SvgPreviewProvider } from './svgEditorProvider' 19 | import { SvgGutterPreview, SvgHoverProvider } from './svgGutterPreview' 20 | import { optimize } from 'svgo/browser' 21 | import { prepareForOptimization, finalizeAfterOptimization } from './svgTransform' 22 | import { SUPPORTED_LANGUAGES } from './consts' 23 | 24 | let previewProvider: SvgPreviewProvider 25 | let gutterPreview: SvgGutterPreview 26 | 27 | export function activate (context: vscode.ExtensionContext) { 28 | try { 29 | if (!context.extensionUri) { 30 | vscode.window.showErrorMessage('Better SVG: Extension context.extensionUri is undefined!') 31 | throw new Error('Extension context.extensionUri is undefined') 32 | } 33 | 34 | // Initialize context for view visibility 35 | vscode.commands.executeCommand('setContext', 'betterSvg.hasSvgOpen', false) 36 | 37 | // Register preview provider 38 | previewProvider = new SvgPreviewProvider(context) 39 | context.subscriptions.push( 40 | vscode.window.registerWebviewViewProvider( 41 | 'betterSvg.preview', 42 | previewProvider, 43 | { webviewOptions: { retainContextWhenHidden: true } } 44 | ) 45 | ) 46 | 47 | // Initialize SVG Gutter Preview 48 | gutterPreview = new SvgGutterPreview() 49 | if (vscode.window.activeTextEditor) { 50 | gutterPreview.updateDecorations(vscode.window.activeTextEditor) 51 | } 52 | 53 | // Register SVG Hover Provider for all supported languages 54 | const svgHoverProvider = new SvgHoverProvider() 55 | 56 | 57 | context.subscriptions.push( 58 | vscode.languages.registerHoverProvider( 59 | SUPPORTED_LANGUAGES.map(lang => ({ language: lang })), 60 | svgHoverProvider 61 | ) 62 | ) 63 | 64 | // Update decorations when active editor changes 65 | context.subscriptions.push( 66 | vscode.window.onDidChangeActiveTextEditor(editor => { 67 | if (editor) { 68 | gutterPreview.updateDecorations(editor) 69 | } 70 | }) 71 | ) 72 | 73 | // Update decorations when document changes 74 | let timeout: NodeJS.Timeout | undefined 75 | const triggerUpdate = (editor: vscode.TextEditor) => { 76 | if (timeout) { 77 | clearTimeout(timeout) 78 | } 79 | timeout = setTimeout(() => { 80 | gutterPreview.updateDecorations(editor) 81 | }, 500) 82 | } 83 | 84 | context.subscriptions.push( 85 | vscode.workspace.onDidChangeTextDocument(e => { 86 | const editor = vscode.window.activeTextEditor 87 | if (editor && editor.document === e.document) { 88 | triggerUpdate(editor) 89 | } 90 | }) 91 | ) 92 | 93 | // Update decorations when theme changes 94 | context.subscriptions.push( 95 | vscode.window.onDidChangeActiveColorTheme(() => { 96 | const editor = vscode.window.activeTextEditor 97 | if (editor) { 98 | gutterPreview.updateDecorations(editor) 99 | } 100 | }) 101 | ) 102 | 103 | const updateContext = (editor: vscode.TextEditor | undefined) => { 104 | if (editor && editor.document.fileName.toLowerCase().endsWith('.svg')) { 105 | // Show the view 106 | vscode.commands.executeCommand('setContext', 'betterSvg.hasSvgOpen', true) 107 | 108 | const config = vscode.workspace.getConfiguration('betterSvg') 109 | const autoReveal = config.get('autoReveal', true) 110 | 111 | if (autoReveal) { 112 | vscode.commands.executeCommand('betterSvg.preview.focus') 113 | } 114 | 115 | if (previewProvider) { 116 | previewProvider.updatePreview(editor.document) 117 | } 118 | } else { 119 | // If we switched to a non-SVG file, collapse the panel 120 | const config = vscode.workspace.getConfiguration('betterSvg') 121 | const autoCollapse = config.get('autoCollapse', true) 122 | 123 | if (autoCollapse) { 124 | vscode.commands.executeCommand('setContext', 'betterSvg.hasSvgOpen', false) 125 | 126 | if (previewProvider) { 127 | previewProvider.clearPreview() 128 | } 129 | } 130 | } 131 | } 132 | 133 | // Auto-reveal panel when SVG file is opened 134 | context.subscriptions.push( 135 | vscode.window.onDidChangeActiveTextEditor(editor => { 136 | updateContext(editor) 137 | }) 138 | ) 139 | 140 | // Update preview when document changes 141 | context.subscriptions.push( 142 | vscode.workspace.onDidChangeTextDocument(e => { 143 | const editor = vscode.window.activeTextEditor 144 | if (editor && 145 | editor.document === e.document && 146 | editor.document.fileName.toLowerCase().endsWith('.svg') && 147 | previewProvider) { 148 | previewProvider.updatePreview(e.document) 149 | } 150 | }) 151 | ) 152 | 153 | // Collapse preview when SVG file is closed 154 | context.subscriptions.push( 155 | vscode.workspace.onDidCloseTextDocument(document => { 156 | if (document.fileName.toLowerCase().endsWith('.svg')) { 157 | const config = vscode.workspace.getConfiguration('betterSvg') 158 | const autoCollapse = config.get('autoCollapse', true) 159 | 160 | if (!autoCollapse) { 161 | return 162 | } 163 | 164 | // Check if there are any other SVG files still open 165 | const hasOpenSvg = vscode.window.visibleTextEditors.some( 166 | editor => editor.document.fileName.toLowerCase().endsWith('.svg') 167 | ) 168 | 169 | // If no SVG files are open, hide the view 170 | if (!hasOpenSvg) { 171 | vscode.commands.executeCommand('setContext', 'betterSvg.hasSvgOpen', false) 172 | 173 | if (previewProvider) { 174 | previewProvider.clearPreview() 175 | } 176 | } 177 | } 178 | }) 179 | ) 180 | 181 | // Auto-reveal if an SVG is already open on activation 182 | // Add a small delay to ensure everything is ready 183 | setTimeout(() => { 184 | const activeEditor = vscode.window.activeTextEditor 185 | updateContext(activeEditor) 186 | }, 100) 187 | 188 | // Register optimize command 189 | context.subscriptions.push( 190 | vscode.commands.registerCommand('betterSvg.optimize', async (uri?: vscode.Uri) => { 191 | let document: vscode.TextDocument | undefined 192 | 193 | // If URI is provided (e.g. from context menu or title button), open that document 194 | if (uri && uri instanceof vscode.Uri) { 195 | try { 196 | document = await vscode.workspace.openTextDocument(uri) 197 | await vscode.window.showTextDocument(document) 198 | } catch (error) { 199 | vscode.window.showErrorMessage(`Failed to open document: ${error}`) 200 | return 201 | } 202 | } else { 203 | // Fallback to active text editor 204 | const editor = vscode.window.activeTextEditor 205 | if (editor) { 206 | document = editor.document 207 | } 208 | } 209 | 210 | if (!document) { 211 | vscode.window.showErrorMessage('No active editor or file selected') 212 | return 213 | } 214 | 215 | if (!document.fileName.toLowerCase().endsWith('.svg')) { 216 | vscode.window.showErrorMessage('Not an SVG file') 217 | return 218 | } 219 | 220 | await optimizeSvgDocument(document) 221 | }) 222 | ) 223 | 224 | // Register optimize command from hover 225 | context.subscriptions.push( 226 | vscode.commands.registerCommand( 227 | 'betterSvg.optimizeFromHover', 228 | async (args?: { uri: string, start: number, length: number }) => { 229 | if (!args || !args.uri) { 230 | vscode.window.showErrorMessage('No SVG metadata provided') 231 | return 232 | } 233 | 234 | try { 235 | const uri = vscode.Uri.parse(args.uri) 236 | const document = await vscode.workspace.openTextDocument(uri) 237 | await vscode.window.showTextDocument(document) 238 | 239 | const start = args.start ?? 0 240 | const length = args.length ?? 0 241 | 242 | if (length <= 0) { 243 | vscode.window.showErrorMessage('Invalid SVG bounds') 244 | return 245 | } 246 | 247 | const range = new vscode.Range( 248 | document.positionAt(start), 249 | document.positionAt(start + length) 250 | ) 251 | 252 | const svgContent = document.getText(range) 253 | await optimizeSvgInline(document, svgContent, range) 254 | } catch (error) { 255 | vscode.window.showErrorMessage(`No SVG found at cursor position (${error})`) 256 | } 257 | } 258 | ) 259 | ) 260 | } catch (error: any) { 261 | vscode.window.showErrorMessage( 262 | 'Better SVG: Failed to activate extension!\n' + 263 | `Error: ${error.message}\n` + 264 | `Stack: ${error.stack?.substring(0, 200)}` 265 | ) 266 | throw error 267 | } 268 | } 269 | 270 | function getSvgoPlugins (removeClasses: boolean): any[] { 271 | const plugins: any[] = [ 272 | { 273 | name: 'preset-default', 274 | params: { 275 | overrides: { 276 | // Preserve important attributes by default 277 | cleanupIds: false, 278 | // Disable removing unknown attributes (like onClick, data-*) when preserving classes (inline mode) 279 | removeUnknownsAndDefaults: removeClasses 280 | } 281 | } 282 | }, 283 | 'removeDoctype', 284 | 'removeComments', 285 | { 286 | name: 'removeAttrs', 287 | params: { 288 | // Remove attributes that are not useful in most cases 289 | attrs: [ 290 | 'xmlns:xlink', 291 | 'xml:space', 292 | ...(removeClasses ? ['class'] : []) 293 | ] 294 | } 295 | } 296 | ] 297 | 298 | return plugins 299 | } 300 | 301 | export async function optimizeSvgDocument (document: vscode.TextDocument) { 302 | const svgContent = document.getText() 303 | 304 | try { 305 | const plugins = getSvgoPlugins(true) 306 | 307 | const result = optimize(svgContent, { 308 | multipass: true, 309 | plugins 310 | }) 311 | 312 | const edit = new vscode.WorkspaceEdit() 313 | const fullRange = new vscode.Range( 314 | document.positionAt(0), 315 | document.positionAt(svgContent.length) 316 | ) 317 | edit.replace(document.uri, fullRange, result.data) 318 | 319 | await vscode.workspace.applyEdit(edit) 320 | 321 | // Calculate savings 322 | const originalSize = Buffer.byteLength(svgContent, 'utf8') 323 | const optimizedSize = Buffer.byteLength(result.data, 'utf8') 324 | const savingPercent = ((originalSize - optimizedSize) / originalSize * 100).toFixed(2) 325 | const originalSizeKB = (originalSize / 1024).toFixed(2) 326 | const optimizedSizeKB = (optimizedSize / 1024).toFixed(2) 327 | 328 | vscode.window.showInformationMessage( 329 | `SVG optimized. Reduced from ${originalSizeKB} KB to ${optimizedSizeKB} KB (${savingPercent}% saved)` 330 | ) 331 | } catch (error) { 332 | vscode.window.showErrorMessage(`Failed to optimize SVG: ${error}`) 333 | } 334 | } 335 | 336 | export async function optimizeSvgInline (document: vscode.TextDocument, svgContent: string, range: vscode.Range) { 337 | try { 338 | const plugins = getSvgoPlugins(false) 339 | 340 | // Prepare SVG for optimization (convert JSX to valid SVG if needed) 341 | const { preparedSvg, wasJsx } = prepareForOptimization(svgContent) 342 | 343 | const result = optimize(preparedSvg, { 344 | multipass: true, 345 | plugins 346 | }) 347 | 348 | // Convert back to JSX if the original was JSX 349 | const finalSvg = finalizeAfterOptimization(result.data, wasJsx) 350 | 351 | const edit = new vscode.WorkspaceEdit() 352 | edit.replace(document.uri, range, finalSvg) 353 | 354 | await vscode.workspace.applyEdit(edit) 355 | 356 | // Calculate savings 357 | const originalSize = Buffer.byteLength(svgContent, 'utf8') 358 | const optimizedSize = Buffer.byteLength(finalSvg, 'utf8') 359 | const savingPercent = ((originalSize - optimizedSize) / originalSize * 100).toFixed(2) 360 | const originalSizeBytes = originalSize 361 | const optimizedSizeBytes = optimizedSize 362 | 363 | const formatBytes = (bytes: number): string => { 364 | if (bytes < 1024) { 365 | return `${bytes} bytes` 366 | } 367 | return `${(bytes / 1024).toFixed(2)} KB` 368 | } 369 | 370 | vscode.window.showInformationMessage( 371 | `SVG optimized. Reduced from ${formatBytes(originalSizeBytes)} to ${formatBytes(optimizedSizeBytes)} (${savingPercent}% saved)` 372 | ) 373 | } catch (error) { 374 | vscode.window.showErrorMessage(`Failed to optimize SVG: ${error}`) 375 | } 376 | } 377 | 378 | export function deactivate () {} 379 | -------------------------------------------------------------------------------- /src/svgGutterPreview.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2025 Miguel Ángel Durán 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as vscode from 'vscode' 18 | import { convertJsxToSvg } from './svgTransform' 19 | 20 | interface SvgCacheEntry { 21 | dataUri: string 22 | sizeBytes: number 23 | timestamp: number 24 | } 25 | 26 | interface HoverCommandArgs { 27 | uri: string 28 | start: number 29 | length: number 30 | } 31 | 32 | export class SvgHoverProvider implements vscode.HoverProvider { 33 | private cache: Map = new Map() 34 | private cacheMaxAge = 5000 // 5 seconds 35 | 36 | public provideHover ( 37 | document: vscode.TextDocument, 38 | position: vscode.Position 39 | ): vscode.Hover | null { 40 | // Check if hover is enabled in settings 41 | const config = vscode.workspace.getConfiguration('betterSvg') 42 | const enableHover = config.get('enableHover', true) 43 | if (!enableHover) { 44 | return null 45 | } 46 | 47 | const text = document.getText() 48 | const svgRegex = /[\s\S]*?<\/svg>/g 49 | let match 50 | 51 | while ((match = svgRegex.exec(text))) { 52 | const startPos = document.positionAt(match.index) 53 | const endPos = document.positionAt(match.index + match[0].length) 54 | const range = new vscode.Range(startPos, endPos) 55 | 56 | if (range.contains(position)) { 57 | const originalSvg = match[0] 58 | const sizeBytes = Buffer.byteLength(originalSvg, 'utf8') 59 | 60 | // Check cache 61 | const cacheKey = `${document.uri.toString()}:${match.index}:${originalSvg.length}` 62 | const cached = this.cache.get(cacheKey) 63 | const now = Date.now() 64 | 65 | if (cached && (now - cached.timestamp) < this.cacheMaxAge) { 66 | return this.createHoverFromCache(cached, range, document) 67 | } 68 | 69 | let svgContent = originalSvg 70 | 71 | // Convert JSX syntax to valid SVG 72 | svgContent = convertJsxToSvg(svgContent) 73 | 74 | // Add xmlns if missing (do this early so SVG is valid) 75 | // Check ONLY inside the opening tag 76 | const svgOpenTagMatch = svgContent.match(/]*>/i) 77 | const hasXmlnsInRoot = svgOpenTagMatch && /xmlns\s*=\s*["']/.test(svgOpenTagMatch[0]) 78 | 79 | if (!hasXmlnsInRoot) { 80 | svgContent = svgContent.replace(/ element 159 | const svgOpenTagMatch = svgContent.match(/]*>/i) 160 | if (!svgOpenTagMatch) return svgContent 161 | 162 | const svgOpenTag = svgOpenTagMatch[0] 163 | 164 | // Extract stroke attribute from svg tag 165 | const strokeMatch = svgOpenTag.match(/\bstroke\s*=\s*["']([^"']+)["']/) 166 | const stroke = strokeMatch ? strokeMatch[1] : null 167 | 168 | // If there's a stroke on the parent, propagate it to child elements that don't have one 169 | if (stroke) { 170 | const shapeElements = ['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect'] 171 | const shapeRegex = new RegExp(`<(${shapeElements.join('|')})([^>]*?)(\\/?>)`, 'gi') 172 | 173 | svgContent = svgContent.replace(shapeRegex, (match, tagName, attrs, ending) => { 174 | // Check if stroke is already present in attrs 175 | if (attrs && /\bstroke\s*=/.test(attrs)) { 176 | return match 177 | } 178 | return `<${tagName}${attrs || ''} stroke="${stroke}"${ending}` 179 | }) 180 | } 181 | 182 | return svgContent 183 | } 184 | 185 | private ensureMinimumSize (svgContent: string, minSize: number): string { 186 | // Check if SVG has width/height attributes (only in svg tag, not child elements) 187 | const svgOpenTagMatch = svgContent.match(/]*>/i) 188 | if (!svgOpenTagMatch) return svgContent 189 | 190 | const svgOpenTag = svgOpenTagMatch[0] 191 | const hasWidth = /\bwidth\s*=\s*["'][^"']+["']/.test(svgOpenTag) 192 | const hasHeight = /\bheight\s*=\s*["'][^"']+["']/.test(svgOpenTag) 193 | 194 | // Try to get dimensions from viewBox if no explicit width/height 195 | const viewBoxMatch = svgOpenTag.match(/viewBox\s*=\s*["']([^"']+)["']/) 196 | 197 | if (!hasWidth && !hasHeight) { 198 | if (viewBoxMatch) { 199 | // Use viewBox dimensions scaled to minSize 200 | const viewBoxParts = viewBoxMatch[1].split(/\s+/) 201 | if (viewBoxParts.length >= 4) { 202 | const vbWidth = parseFloat(viewBoxParts[2]) 203 | const vbHeight = parseFloat(viewBoxParts[3]) 204 | const scale = minSize / Math.max(vbWidth, vbHeight) 205 | const newWidth = Math.round(vbWidth * scale) 206 | const newHeight = Math.round(vbHeight * scale) 207 | svgContent = svgContent.replace(' = new Map() 246 | 247 | public updateDecorations (editor: vscode.TextEditor) { 248 | if (!editor) { 249 | return 250 | } 251 | 252 | const docUri = editor.document.uri.toString() 253 | 254 | // Dispose existing decorations for this document 255 | this.disposeDecorationsForUri(docUri) 256 | 257 | // Check if gutter preview is enabled in settings 258 | const config = vscode.workspace.getConfiguration('betterSvg') 259 | const showGutterPreview = config.get('showGutterPreview', true) 260 | if (!showGutterPreview) { 261 | return 262 | } 263 | 264 | const text = editor.document.getText() 265 | const svgRegex = /[\s\S]*?<\/svg>/g 266 | const newDecorationTypes: vscode.TextEditorDecorationType[] = [] 267 | 268 | let match 269 | while ((match = svgRegex.exec(text))) { 270 | const startPos = editor.document.positionAt(match.index) 271 | // Use a zero-length range at the start of the SVG to ensure only one gutter icon is shown 272 | const range = new vscode.Range(startPos, startPos) 273 | 274 | let svgContent = match[0] 275 | 276 | // Convert JSX syntax to valid SVG 277 | svgContent = convertJsxToSvg(svgContent) 278 | 279 | // Add xmlns if missing (do this early so SVG is valid) 280 | // Check ONLY inside the opening tag 281 | const svgOpenTagMatch = svgContent.match(/]*>/i) 282 | const hasXmlnsInRoot = svgOpenTagMatch && /xmlns\s*=\s*["']/.test(svgOpenTagMatch[0]) 283 | 284 | if (!hasXmlnsInRoot) { 285 | svgContent = svgContent.replace(/ t.dispose()) 323 | this.decorationTypes.delete(uri) 324 | } 325 | } 326 | 327 | private propagateStrokeAndFill (svgContent: string): string { 328 | // Extract stroke and fill from the root element 329 | const svgOpenTagMatch = svgContent.match(/]*>/i) 330 | if (!svgOpenTagMatch) return svgContent 331 | 332 | const svgOpenTag = svgOpenTagMatch[0] 333 | 334 | // Extract stroke attribute from svg tag 335 | const strokeMatch = svgOpenTag.match(/\bstroke\s*=\s*["']([^"']+)["']/) 336 | const stroke = strokeMatch ? strokeMatch[1] : null 337 | 338 | // If there's a stroke on the parent, propagate it to child elements that don't have one 339 | if (stroke) { 340 | const shapeElements = ['path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect'] 341 | const shapeRegex = new RegExp(`<(${shapeElements.join('|')})([^>]*?)(\\/?>)`, 'gi') 342 | 343 | svgContent = svgContent.replace(shapeRegex, (match, tagName, attrs, ending) => { 344 | // Check if stroke is already present in attrs 345 | if (attrs && /\bstroke\s*=/.test(attrs)) { 346 | return match 347 | } 348 | return `<${tagName}${attrs || ''} stroke="${stroke}"${ending}` 349 | }) 350 | } 351 | 352 | return svgContent 353 | } 354 | 355 | private ensureMinimumSize (svgContent: string, minSize: number): string { 356 | // Check if SVG has width/height attributes (only in svg tag, not child elements) 357 | const svgOpenTagMatch = svgContent.match(/]*>/i) 358 | if (!svgOpenTagMatch) return svgContent 359 | 360 | const svgOpenTag = svgOpenTagMatch[0] 361 | const hasWidth = /\bwidth\s*=\s*["'][^"']+["']/.test(svgOpenTag) 362 | const hasHeight = /\bheight\s*=\s*["'][^"']+["']/.test(svgOpenTag) 363 | 364 | // Try to get dimensions from viewBox if no explicit width/height 365 | const viewBoxMatch = svgOpenTag.match(/viewBox\s*=\s*["']([^"']+)["']/) 366 | 367 | if (!hasWidth && !hasHeight) { 368 | if (viewBoxMatch) { 369 | const viewBoxParts = viewBoxMatch[1].split(/\s+/) 370 | if (viewBoxParts.length >= 4) { 371 | const vbWidth = parseFloat(viewBoxParts[2]) 372 | const vbHeight = parseFloat(viewBoxParts[3]) 373 | const scale = minSize / Math.max(vbWidth, vbHeight) 374 | const newWidth = Math.round(vbWidth * scale) 375 | const newHeight = Math.round(vbHeight * scale) 376 | svgContent = svgContent.replace(' types.forEach(t => t.dispose())) 390 | this.decorationTypes.clear() 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /src/svgTransform.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from 'node:test' 2 | import assert from 'node:assert' 3 | import { 4 | isJsxSvg, 5 | convertJsxToSvg, 6 | convertSvgToJsx, 7 | prepareForOptimization, 8 | finalizeAfterOptimization, 9 | jsxToSvgAttributeMap, 10 | svgToJsxAttributeMap 11 | } from './svgTransform' 12 | 13 | describe('isJsxSvg', () => { 14 | it('should detect JSX expression values like {2}', () => { 15 | const svg = '' 16 | assert.strictEqual(isJsxSvg(svg), true) 17 | }) 18 | 19 | it('should detect className attribute', () => { 20 | const svg = '' 21 | assert.strictEqual(isJsxSvg(svg), true) 22 | }) 23 | 24 | it('should detect camelCase attributes', () => { 25 | const svg = '' 26 | assert.strictEqual(isJsxSvg(svg), true) 27 | }) 28 | 29 | it('should return false for standard SVG', () => { 30 | const svg = '' 31 | assert.strictEqual(isJsxSvg(svg), false) 32 | }) 33 | 34 | it('should return false for SVG with class attribute', () => { 35 | const svg = '' 36 | assert.strictEqual(isJsxSvg(svg), false) 37 | }) 38 | 39 | it('should detect multiple JSX patterns', () => { 40 | const svg = ` 41 | 42 | ` 43 | assert.strictEqual(isJsxSvg(svg), true) 44 | }) 45 | }) 46 | 47 | describe('convertJsxToSvg', () => { 48 | it('should convert expression values {number} to quoted strings', () => { 49 | const input = '' 50 | const expected = '' 51 | assert.strictEqual(convertJsxToSvg(input), expected) 52 | }) 53 | 54 | it('should convert className to class', () => { 55 | const input = '' 56 | const expected = '' 57 | assert.strictEqual(convertJsxToSvg(input), expected) 58 | }) 59 | 60 | it('should convert strokeLinecap to stroke-linecap', () => { 61 | const input = '' 62 | const expected = '' 63 | assert.strictEqual(convertJsxToSvg(input), expected) 64 | }) 65 | 66 | it('should convert strokeLinejoin to stroke-linejoin', () => { 67 | const input = '' 68 | const expected = '' 69 | assert.strictEqual(convertJsxToSvg(input), expected) 70 | }) 71 | 72 | it('should convert fillRule to fill-rule', () => { 73 | const input = '' 74 | const expected = '' 75 | assert.strictEqual(convertJsxToSvg(input), expected) 76 | }) 77 | 78 | it('should convert clipPath to clip-path', () => { 79 | const input = '' 80 | const expected = '' 81 | assert.strictEqual(convertJsxToSvg(input), expected) 82 | }) 83 | 84 | it('should convert xlinkHref to xlink:href', () => { 85 | const input = '' 86 | const expected = '' 87 | assert.strictEqual(convertJsxToSvg(input), expected) 88 | }) 89 | 90 | it('should handle complex JSX SVG', () => { 91 | const input = ` 97 | 103 | ` 104 | 105 | const result = convertJsxToSvg(input) 106 | 107 | assert.ok(result.includes('class='), 'should convert className to class') 108 | assert.ok(result.includes('stroke-linecap='), 'should convert strokeLinecap') 109 | assert.ok(result.includes('stroke-linejoin='), 'should convert strokeLinejoin') 110 | assert.ok(result.includes('stroke-width="2"'), 'should convert strokeWidth={2}') 111 | assert.ok(!result.includes('className='), 'should not contain className') 112 | assert.ok(!result.includes('{2}'), 'should not contain expression syntax') 113 | }) 114 | 115 | it('should preserve non-JSX attributes', () => { 116 | const input = '' 117 | const expected = '' 118 | assert.strictEqual(convertJsxToSvg(input), expected) 119 | }) 120 | }) 121 | 122 | describe('convertSvgToJsx', () => { 123 | it('should convert class to className', () => { 124 | const input = '' 125 | const expected = '' 126 | assert.strictEqual(convertSvgToJsx(input), expected) 127 | }) 128 | 129 | it('should convert stroke-linecap to strokeLinecap', () => { 130 | const input = '' 131 | const expected = '' 132 | assert.strictEqual(convertSvgToJsx(input), expected) 133 | }) 134 | 135 | it('should convert stroke-linejoin to strokeLinejoin', () => { 136 | const input = '' 137 | const expected = '' 138 | assert.strictEqual(convertSvgToJsx(input), expected) 139 | }) 140 | 141 | it('should convert stroke-width to strokeWidth', () => { 142 | const input = '' 143 | const expected = '' 144 | assert.strictEqual(convertSvgToJsx(input), expected) 145 | }) 146 | 147 | it('should convert fill-rule to fillRule', () => { 148 | const input = '' 149 | const expected = '' 150 | assert.strictEqual(convertSvgToJsx(input), expected) 151 | }) 152 | 153 | it('should convert clip-path to clipPath', () => { 154 | const input = '' 155 | const expected = '' 156 | assert.strictEqual(convertSvgToJsx(input), expected) 157 | }) 158 | 159 | it('should convert xlink:href to xlinkHref', () => { 160 | const input = '' 161 | const expected = '' 162 | assert.strictEqual(convertSvgToJsx(input), expected) 163 | }) 164 | 165 | it('should handle optimized SVG output', () => { 166 | const input = '' 167 | 168 | const result = convertSvgToJsx(input) 169 | 170 | assert.ok(result.includes('className='), 'should convert class to className') 171 | assert.ok(result.includes('strokeLinecap='), 'should convert stroke-linecap') 172 | assert.ok(result.includes('strokeLinejoin='), 'should convert stroke-linejoin') 173 | assert.ok(result.includes('strokeWidth='), 'should convert stroke-width') 174 | assert.ok(!result.includes('class='), 'should not contain class=') 175 | assert.ok(!result.includes('stroke-linecap='), 'should not contain kebab-case') 176 | }) 177 | }) 178 | 179 | describe('roundtrip conversion', () => { 180 | it('should preserve attributes after JSX -> SVG -> JSX roundtrip', () => { 181 | const jsxInput = '' 182 | 183 | const svg = convertJsxToSvg(jsxInput) 184 | const backToJsx = convertSvgToJsx(svg) 185 | 186 | assert.strictEqual(backToJsx, jsxInput) 187 | }) 188 | 189 | it('should handle all stroke attributes in roundtrip', () => { 190 | const jsxInput = '' 191 | 192 | const svg = convertJsxToSvg(jsxInput) 193 | const backToJsx = convertSvgToJsx(svg) 194 | 195 | assert.strictEqual(backToJsx, jsxInput) 196 | }) 197 | 198 | it('should handle font attributes in roundtrip', () => { 199 | const jsxInput = '' 200 | 201 | const svg = convertJsxToSvg(jsxInput) 202 | const backToJsx = convertSvgToJsx(svg) 203 | 204 | assert.strictEqual(backToJsx, jsxInput) 205 | }) 206 | }) 207 | 208 | describe('prepareForOptimization', () => { 209 | it('should convert JSX SVG and return wasJsx: true', () => { 210 | const input = '' 211 | const result = prepareForOptimization(input) 212 | 213 | assert.strictEqual(result.wasJsx, true) 214 | assert.ok(result.preparedSvg.includes('class=')) 215 | assert.ok(result.preparedSvg.includes('stroke-width="2"')) 216 | }) 217 | 218 | it('should not modify standard SVG and return wasJsx: false', () => { 219 | const input = '' 220 | const result = prepareForOptimization(input) 221 | 222 | assert.strictEqual(result.wasJsx, false) 223 | assert.strictEqual(result.preparedSvg, input) 224 | }) 225 | }) 226 | 227 | describe('finalizeAfterOptimization', () => { 228 | it('should convert back to JSX when wasJsx is true', () => { 229 | const optimized = '' 230 | const result = finalizeAfterOptimization(optimized, true) 231 | 232 | assert.ok(result.includes('className=')) 233 | assert.ok(result.includes('strokeWidth=')) 234 | }) 235 | 236 | it('should not modify when wasJsx is false', () => { 237 | const optimized = '' 238 | const result = finalizeAfterOptimization(optimized, false) 239 | 240 | assert.strictEqual(result, optimized) 241 | }) 242 | }) 243 | 244 | describe('attribute maps', () => { 245 | it('should have matching entries in both maps', () => { 246 | const jsxKeys = Object.keys(jsxToSvgAttributeMap) 247 | const svgKeys = Object.keys(svgToJsxAttributeMap) 248 | 249 | assert.strictEqual(jsxKeys.length, svgKeys.length, 'Maps should have same number of entries') 250 | 251 | for (const [jsx, svg] of Object.entries(jsxToSvgAttributeMap)) { 252 | assert.strictEqual(svgToJsxAttributeMap[svg], jsx, `${svg} should map back to ${jsx}`) 253 | } 254 | }) 255 | 256 | it('should include all common stroke attributes', () => { 257 | const strokeAttrs = ['strokeWidth', 'strokeLinecap', 'strokeLinejoin', 'strokeDasharray', 'strokeOpacity'] 258 | for (const attr of strokeAttrs) { 259 | assert.ok(attr in jsxToSvgAttributeMap, `${attr} should be in jsxToSvgAttributeMap`) 260 | } 261 | }) 262 | 263 | it('should include all common fill attributes', () => { 264 | const fillAttrs = ['fillOpacity', 'fillRule'] 265 | for (const attr of fillAttrs) { 266 | assert.ok(attr in jsxToSvgAttributeMap, `${attr} should be in jsxToSvgAttributeMap`) 267 | } 268 | }) 269 | }) 270 | 271 | describe('edge cases', () => { 272 | it('should handle SVG with no attributes to convert', () => { 273 | const input = '' 274 | assert.strictEqual(convertJsxToSvg(input), input) 275 | assert.strictEqual(convertSvgToJsx(input), input) 276 | }) 277 | 278 | it('should handle empty SVG', () => { 279 | const input = '' 280 | assert.strictEqual(convertJsxToSvg(input), input) 281 | assert.strictEqual(convertSvgToJsx(input), input) 282 | }) 283 | 284 | it('should handle self-closing SVG elements', () => { 285 | const input = '' 286 | const result = convertJsxToSvg(input) 287 | assert.ok(result.includes('stroke-width="2"')) 288 | assert.ok(result.includes('stroke-width="3"')) 289 | }) 290 | 291 | it('should handle expression with variable name', () => { 292 | const input = '' 293 | const result = convertJsxToSvg(input) 294 | assert.ok(result.includes('stroke-width="strokeSize"')) 295 | }) 296 | 297 | it('should handle nested SVG elements', () => { 298 | const input = ` 299 | 300 | 301 | 302 | ` 303 | const result = convertJsxToSvg(input) 304 | 305 | // Count occurrences of class= 306 | const classCount = (result.match(/class=/g) || []).length 307 | assert.strictEqual(classCount, 2, 'Should convert both className occurrences') 308 | }) 309 | 310 | it('should not convert partial attribute names', () => { 311 | // Make sure we don't accidentally convert "mystrokeWidth" or similar 312 | const input = '' 313 | const result = convertJsxToSvg(input) 314 | // The data-strokeWidth should remain unchanged because it's prefixed with data- 315 | assert.ok(result.includes('data-strokeWidth=') || result.includes('data-stroke-width=')) 316 | }) 317 | 318 | it('should handle attributes with single quotes', () => { 319 | const input = "" 320 | const result = convertJsxToSvg(input) 321 | assert.ok(result.includes("class='icon'")) 322 | assert.ok(result.includes("stroke-linecap='round'")) 323 | }) 324 | 325 | it('should handle mixed quote styles', () => { 326 | const input = '' 327 | const result = convertJsxToSvg(input) 328 | assert.ok(result.includes('class="icon"')) 329 | assert.ok(result.includes("stroke-linecap='round'")) 330 | assert.ok(result.includes('stroke-width="2"')) 331 | }) 332 | 333 | it('should handle nested brackets in expressions (e.g. onClick handlers)', () => { 334 | const input = ' { console.log("click") }} strokeWidth={2}>' 335 | const result = convertJsxToSvg(input) 336 | 337 | // Check that we got a valid attribute format 338 | // The inner quotes should be escaped as " 339 | assert.ok(result.includes('data-jsx-event-onClick="() => { console.log("click") }"')) 340 | assert.ok(result.includes('stroke-width="2"')) 341 | 342 | // Check that we can round-trip it back 343 | // Simulate what SVGO might do (escape >) 344 | // Our logic restores onClick={...} and unescapes quotes and HTML entities. 345 | const backToJsx = convertSvgToJsx(result) 346 | assert.ok(backToJsx.includes('onClick={() => { console.log("click") }}')) 347 | assert.ok(backToJsx.includes('strokeWidth=')) 348 | }) 349 | 350 | it('should handle SVG with inner element having xmlns attribute', () => { 351 | const input = ` 352 | check-box-solid 353 | 354 | ` 355 | 356 | // Just ensure it doesn't crash and preserves content 357 | const result = convertJsxToSvg(input) 358 | assert.ok(result.includes('check-box-solid')) 359 | }) 360 | }) 361 | 362 | describe('spread attributes', () => { 363 | it('should detect spread attributes in isJsxSvg', () => { 364 | const input = '' 365 | assert.strictEqual(isJsxSvg(input), true) 366 | }) 367 | 368 | it('should convert spread attributes in convertJsxToSvg', () => { 369 | const input = '' 370 | const expected = '' 371 | assert.strictEqual(convertJsxToSvg(input), expected) 372 | }) 373 | 374 | it('should restore spread attributes in convertSvgToJsx', () => { 375 | const input = '' 376 | const expected = '' 377 | assert.strictEqual(convertSvgToJsx(input), expected) 378 | }) 379 | 380 | it('should handle roundtrip with spread attributes', () => { 381 | const input = '' 382 | const svg = convertJsxToSvg(input) 383 | const output = convertSvgToJsx(svg) 384 | assert.strictEqual(output, input) 385 | }) 386 | 387 | it('should handle multiple spread attributes', () => { 388 | const input = '' 389 | const svg = convertJsxToSvg(input) 390 | 391 | // Ensure we have distinct attributes 392 | assert.ok(svg.includes('data-spread-0="props"')) 393 | assert.ok(svg.includes('data-spread-1="user"')) 394 | 395 | const output = convertSvgToJsx(svg) 396 | assert.strictEqual(output, input) 397 | }) 398 | 399 | it('should handle tricky SVG with onClick and class', () => { 400 | const input = ` { console.log('hola') }} class="hola" data-a="hola" id="hola" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" 401 | viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 402 | 403 | 404 | ` 405 | 406 | // Should classify as JSX because of strokeLinecap, strokeWidth, onClick expression 407 | assert.strictEqual(isJsxSvg(input), true) 408 | 409 | const svg = convertJsxToSvg(input) 410 | // onClick should be converted to string attribute with quotes escaped if needed 411 | // In this case inner quotes are single quotes so they might stay as is or be friendly 412 | assert.ok(svg.includes('data-jsx-event-onClick="() => { console.log(\'hola\') }"')) 413 | assert.ok(svg.includes('stroke-linecap="round"')) 414 | 415 | const output = convertSvgToJsx(svg) 416 | 417 | // Original had class="hola". Output will have className="hola" because convertSvgToJsx enforces className 418 | const expected = input.replace('class="hola"', 'className="hola"') 419 | assert.strictEqual(output, expected) 420 | }) 421 | }) 422 | --------------------------------------------------------------------------------