code-highlight.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. "use client";
  2. import { useEffect, useState } from "react";
  3. import { codeToHtml, type BundledLanguage } from "shiki";
  4. const vercelDarkTheme = {
  5. name: "vercel-dark",
  6. type: "dark" as const,
  7. colors: {
  8. "editor.background": "transparent",
  9. "editor.foreground": "#EDEDED",
  10. },
  11. settings: [
  12. {
  13. scope: ["comment", "punctuation.definition.comment"],
  14. settings: { foreground: "#666666" },
  15. },
  16. {
  17. scope: ["string", "string.quoted", "string.template"],
  18. settings: { foreground: "#50E3C2" },
  19. },
  20. {
  21. scope: [
  22. "constant.numeric",
  23. "constant.language.boolean",
  24. "constant.language.null",
  25. ],
  26. settings: { foreground: "#50E3C2" },
  27. },
  28. {
  29. scope: ["keyword", "storage.type", "storage.modifier"],
  30. settings: { foreground: "#FF0080" },
  31. },
  32. {
  33. scope: ["keyword.operator", "keyword.control"],
  34. settings: { foreground: "#FF0080" },
  35. },
  36. {
  37. scope: ["entity.name.function", "support.function", "meta.function-call"],
  38. settings: { foreground: "#7928CA" },
  39. },
  40. {
  41. scope: ["variable", "variable.other", "variable.parameter"],
  42. settings: { foreground: "#EDEDED" },
  43. },
  44. {
  45. scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
  46. settings: { foreground: "#FF0080" },
  47. },
  48. {
  49. scope: ["punctuation", "meta.brace", "meta.bracket"],
  50. settings: { foreground: "#888888" },
  51. },
  52. {
  53. scope: [
  54. "support.type.property-name",
  55. "entity.name.tag.json",
  56. "meta.object-literal.key",
  57. ],
  58. settings: { foreground: "#EDEDED" },
  59. },
  60. {
  61. scope: ["entity.other.attribute-name"],
  62. settings: { foreground: "#50E3C2" },
  63. },
  64. {
  65. scope: ["support.type.primitive", "entity.name.type.primitive"],
  66. settings: { foreground: "#50E3C2" },
  67. },
  68. ],
  69. };
  70. interface CodeHighlightProps {
  71. code: string;
  72. language?: BundledLanguage;
  73. }
  74. export function CodeHighlight({ code, language = "tsx" }: CodeHighlightProps) {
  75. const [html, setHtml] = useState<string>("");
  76. const [isLoading, setIsLoading] = useState(true);
  77. useEffect(() => {
  78. let cancelled = false;
  79. async function highlight() {
  80. setIsLoading(true);
  81. try {
  82. const result = await codeToHtml(code, {
  83. lang: language,
  84. theme: vercelDarkTheme,
  85. });
  86. if (!cancelled) {
  87. setHtml(result);
  88. }
  89. } catch {
  90. // Fallback to plain text on error
  91. if (!cancelled) {
  92. setHtml(`<pre><code>${escapeHtml(code)}</code></pre>`);
  93. }
  94. } finally {
  95. if (!cancelled) {
  96. setIsLoading(false);
  97. }
  98. }
  99. }
  100. highlight();
  101. return () => {
  102. cancelled = true;
  103. };
  104. }, [code, language]);
  105. if (isLoading) {
  106. return (
  107. <pre
  108. style={{
  109. margin: 0,
  110. padding: 0,
  111. overflow: "auto",
  112. fontSize: 13,
  113. lineHeight: 1.6,
  114. fontFamily:
  115. 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
  116. color: "#EDEDED",
  117. }}
  118. >
  119. <code>{code}</code>
  120. </pre>
  121. );
  122. }
  123. return (
  124. <div
  125. style={{
  126. overflow: "auto",
  127. fontSize: 13,
  128. lineHeight: 1.6,
  129. fontFamily:
  130. 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
  131. }}
  132. dangerouslySetInnerHTML={{ __html: html }}
  133. />
  134. );
  135. }
  136. function escapeHtml(text: string): string {
  137. return text
  138. .replace(/&/g, "&amp;")
  139. .replace(/</g, "&lt;")
  140. .replace(/>/g, "&gt;")
  141. .replace(/"/g, "&quot;")
  142. .replace(/'/g, "&#039;");
  143. }
  144. /**
  145. * Get the appropriate language for a file extension
  146. */
  147. export function getLanguageFromPath(path: string): BundledLanguage {
  148. const ext = path.split(".").pop()?.toLowerCase();
  149. switch (ext) {
  150. case "tsx":
  151. return "tsx";
  152. case "ts":
  153. return "typescript";
  154. case "jsx":
  155. return "jsx";
  156. case "js":
  157. return "javascript";
  158. case "json":
  159. return "json";
  160. case "css":
  161. return "css";
  162. case "md":
  163. return "markdown";
  164. default:
  165. return "typescript";
  166. }
  167. }