diff --git a/bun.lock b/bun.lock index b39d626..45917fc 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", + "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^16.5.4", @@ -29,6 +30,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", + "@types/prismjs": "^1.26.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.1.2", @@ -542,6 +544,8 @@ "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], + "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -1404,6 +1408,8 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "process-warning": ["process-warning@1.0.0", "", {}, "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q=="], diff --git a/package.json b/package.json index 7441e9d..a1a3c4b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "i18next": "^25.8.0", "i18next-browser-languagedetector": "^8.2.0", "jszip": "^3.10.1", + "prismjs": "^1.30.0", "react": "^19.1.0", "react-dom": "^19.1.0", "react-i18next": "^16.5.4", @@ -68,16 +69,17 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@playwright/test": "^1.57.0", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^13.5.0", + "@types/prismjs": "^1.26.6", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^5.1.2", "dotenv": "^17.2.3", "happy-dom": "^20.1.0", "vite": "^7.3.1", - "@testing-library/dom": "^10.4.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", - "@testing-library/user-event": "^13.5.0", "vitest": "^4.0.14" } } diff --git a/src/App.tsx b/src/App.tsx index 3a70f19..cfbb290 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import "./styles/rainbowkit.css"; import "./styles/responsive.css"; import "./styles/ai-analysis.css"; import "./styles/rpcs.css"; +import "./styles/code-highlight.css"; import "./styles/helper-tooltip.css"; import Loading from "./components/common/Loading"; diff --git a/src/components/common/CodeBlock.tsx b/src/components/common/CodeBlock.tsx new file mode 100644 index 0000000..4af973a --- /dev/null +++ b/src/components/common/CodeBlock.tsx @@ -0,0 +1,46 @@ +import { useMemo } from "react"; +import Prism from "prismjs"; +import "prismjs/components/prism-python"; +import "prismjs/components/prism-solidity"; + +function detectLanguage(fileName?: string): string | undefined { + if (!fileName) return undefined; + const ext = fileName.split(".").pop()?.toLowerCase(); + if (ext === "sol") return "solidity"; + if (ext === "vy") return "python"; + if (ext === "json") return "json"; + return undefined; +} + +interface CodeBlockProps { + code: string; + fileName?: string; + language?: string; +} + +const CodeBlock: React.FC = ({ code, fileName, language }) => { + const lang = language ?? detectLanguage(fileName); + const grammar = lang ? Prism.languages[lang] : undefined; + + const highlighted = useMemo(() => { + if (!grammar || !lang) return null; + return Prism.highlight(code, grammar, lang); + }, [code, grammar, lang]); + + if (highlighted) { + return ( +
+        {/* biome-ignore lint/security/noDangerouslySetInnerHtml: Prism.highlight output is safe — it only tokenizes source code we control */}
+        
+      
+ ); + } + + return ( +
+      {code}
+    
+ ); +}; + +export default CodeBlock; diff --git a/src/components/pages/evm/address/shared/ContractDetails.tsx b/src/components/pages/evm/address/shared/ContractDetails.tsx index a7b7700..d258897 100644 --- a/src/components/pages/evm/address/shared/ContractDetails.tsx +++ b/src/components/pages/evm/address/shared/ContractDetails.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { useState } from "react"; import type { ABI } from "../../../../../types"; import { useTranslation } from "react-i18next"; +import CodeBlock from "../../../../common/CodeBlock"; import FieldLabel from "../../../../common/FieldLabel"; import ContractInteraction from "./ContractInteraction"; @@ -201,7 +202,7 @@ const ContractDetails: React.FC = ({ {sourceFiles.map((file) => (
📄 {file.name || file.path}
-
{file.content}
+
))} diff --git a/src/components/pages/evm/address/shared/ContractInfoCard.tsx b/src/components/pages/evm/address/shared/ContractInfoCard.tsx index ccf55b6..6ee7766 100644 --- a/src/components/pages/evm/address/shared/ContractInfoCard.tsx +++ b/src/components/pages/evm/address/shared/ContractInfoCard.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Link } from "react-router-dom"; import type { Address, ABI } from "../../../../../types"; import { useTranslation } from "react-i18next"; +import CodeBlock from "../../../../common/CodeBlock"; import FieldLabel from "../../../../common/FieldLabel"; import ContractInteraction from "./ContractInteraction"; import type { VerificationSource } from "../../../../../hooks/useContractVerification"; @@ -357,7 +358,7 @@ const ContractInfoCard: React.FC = ({ {sourceFiles.map((file) => (
📄 {file.name || file.path}
-
{file.content}
+
))} diff --git a/src/hooks/useEtherscan.ts b/src/hooks/useEtherscan.ts index 151ea62..a8c50ec 100644 --- a/src/hooks/useEtherscan.ts +++ b/src/hooks/useEtherscan.ts @@ -39,6 +39,7 @@ interface StandardJsonSources { function parseSourceFiles( sourceCode: string, contractName: string, + compilerVersion?: string, ): { name: string; path: string; content: string }[] { if (!sourceCode) return []; @@ -62,8 +63,9 @@ function parseSourceFiles( } } - // Plain Solidity source - const fileName = `${contractName || "Contract"}.sol`; + // Detect language from compiler version — Vyper compilers start with "vyper:" + const ext = compilerVersion?.toLowerCase().startsWith("vyper:") ? ".vy" : ".sol"; + const fileName = `${contractName || "Contract"}${ext}`; return [{ name: fileName, path: fileName, content: sourceCode }]; } @@ -159,14 +161,20 @@ export function useEtherscan( abi = undefined; } - const files = parseSourceFiles(result.SourceCode, result.ContractName); + const files = parseSourceFiles( + result.SourceCode, + result.ContractName, + result.CompilerVersion, + ); const evmVersion = result.EVMVersion && result.EVMVersion !== "Default" ? result.EVMVersion : undefined; + const isVyper = result.CompilerVersion?.toLowerCase().startsWith("vyper:"); const contractDetails: SourcifyContractDetails = { name: result.ContractName || undefined, compilerVersion: result.CompilerVersion || undefined, evmVersion, + language: isVyper ? "Vyper" : "Solidity", abi, files, match: "perfect", diff --git a/src/styles/code-highlight.css b/src/styles/code-highlight.css new file mode 100644 index 0000000..09b11cc --- /dev/null +++ b/src/styles/code-highlight.css @@ -0,0 +1,59 @@ +/* Prism.js syntax highlighting theme for OpenScan */ +.source-file-code code[class*="language-"] { + text-shadow: none; +} + +.source-file-code .token.comment, +.source-file-code .token.prolog, +.source-file-code .token.doctype, +.source-file-code .token.cdata { + color: #6a9955; +} + +.source-file-code .token.punctuation { + color: #d4d4d4; +} + +.source-file-code .token.property, +.source-file-code .token.tag, +.source-file-code .token.boolean, +.source-file-code .token.number, +.source-file-code .token.constant, +.source-file-code .token.symbol { + color: #b5cea8; +} + +.source-file-code .token.selector, +.source-file-code .token.attr-name, +.source-file-code .token.string, +.source-file-code .token.char, +.source-file-code .token.builtin { + color: #ce9178; +} + +.source-file-code .token.operator, +.source-file-code .token.entity, +.source-file-code .token.url { + color: #d4d4d4; +} + +.source-file-code .token.atrule, +.source-file-code .token.attr-value, +.source-file-code .token.keyword { + color: #569cd6; +} + +.source-file-code .token.function, +.source-file-code .token.class-name { + color: #dcdcaa; +} + +.source-file-code .token.regex, +.source-file-code .token.important, +.source-file-code .token.variable { + color: #d16969; +} + +.source-file-code .token.decorator { + color: #dcdcaa; +}