diff --git a/packages/app/package.json b/packages/app/package.json index 2ebc9af2..add96928 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,6 +19,7 @@ "debug": "^4.3.4", "dexie": "^3.2.4", "emojilib": "^3.0.10", + "highlight.js": "^11.8.0", "light-bolt11-decoder": "^2.1.0", "match-sorter": "^6.3.1", "qr-code-styling": "^1.6.0-rc.1", diff --git a/packages/app/src/Element/CodeBlock.css b/packages/app/src/Element/CodeBlock.css new file mode 100644 index 00000000..af8a21e8 --- /dev/null +++ b/packages/app/src/Element/CodeBlock.css @@ -0,0 +1,14 @@ +.codeblock { + overflow: auto; + position: relative; +} + +.codeblock pre { + overflow: auto; + line-height: 1.4; + font-size: var(--font-size); +} + +.hljs { + background: #f6f8fa; +} diff --git a/packages/app/src/Element/CodeBlock.tsx b/packages/app/src/Element/CodeBlock.tsx new file mode 100644 index 00000000..825c1ccb --- /dev/null +++ b/packages/app/src/Element/CodeBlock.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react"; +import "highlight.js/styles/github.css"; +import "./CodeBlock.css"; + +const CodeBlock = ({ content, language }: { content: string; language?: string }) => { + useEffect(() => { + const importHljs = async () => { + const hljs = (await import("highlight.js")).default; + hljs.highlightAll(); + }; + + importHljs(); + }); + + return ( +
+
+        {content.trim()}
+      
+
+ ); +}; + +export default CodeBlock; diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 31fea6dc..06f00ffd 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -11,6 +11,7 @@ import { ProxyImg } from "./ProxyImg"; import { SpotlightMediaModal } from "./Deck/SpotlightMedia"; import HighlightedText from "./HighlightedText"; import { useTextTransformer } from "Hooks/useTextTransformCache"; +import CodeBlock from "./CodeBlock"; export interface TextProps { id: string; @@ -254,6 +255,9 @@ export default function Text({ if (element.type === "custom_emoji") { chunks.push(); } + if (element.type === "code_block") { + chunks.push(); + } if (element.type === "text") { chunks.push(
diff --git a/packages/system/src/const.ts b/packages/system/src/const.ts index af06cced..28159825 100644 --- a/packages/system/src/const.ts +++ b/packages/system/src/const.ts @@ -34,3 +34,8 @@ export const CashuRegex = /(cashuA[A-Za-z0-9_-]{0,10000}={0,3})/i; * Regex to match any npub/nevent/naddr/nprofile/note */ export const MentionNostrEntityRegex = /@n(pub|profile|event|ote|addr|)1[acdefghjklmnpqrstuvwxyz023456789]+/g; + +/** + * Regex to match markdown code content + */ +export const MarkdownCodeRegex = /(```.+?```)/gms; diff --git a/packages/system/src/text.ts b/packages/system/src/text.ts index 89b9514f..5988b40a 100644 --- a/packages/system/src/text.ts +++ b/packages/system/src/text.ts @@ -1,13 +1,31 @@ import { unwrap } from "@snort/shared"; -import { CashuRegex, FileExtensionRegex, HashtagRegex, InvoiceRegex, MentionNostrEntityRegex } from "./const"; +import { + CashuRegex, + FileExtensionRegex, + HashtagRegex, + InvoiceRegex, + MarkdownCodeRegex, + MentionNostrEntityRegex, +} from "./const"; import { validateNostrLink } from "./nostr-link"; import { splitByUrl } from "./utils"; export interface ParsedFragment { - type: "text" | "link" | "mention" | "invoice" | "media" | "cashu" | "hashtag" | "custom_emoji" | "highlighted_text"; + type: + | "text" + | "link" + | "mention" + | "invoice" + | "media" + | "cashu" + | "hashtag" + | "custom_emoji" + | "highlighted_text" + | "code_block"; content: string; mimeType?: string; + language?: string; } export type Fragment = string | ParsedFragment; @@ -179,6 +197,31 @@ function extractCustomEmoji(fragments: Fragment[], tags: Array>) { .flat(); } +function extractMarkdownCode(fragments: Fragment[]): (string | ParsedFragment)[] { + return fragments + .map(f => { + if (typeof f === "string") { + return f.split(MarkdownCodeRegex).map(i => { + if (i.startsWith("```") && i.endsWith("```")) { + const firstLineBreakIndex = i.indexOf("\n"); + const lastLineBreakIndex = i.lastIndexOf("\n"); + + return { + type: "code_block", + content: i.substring(firstLineBreakIndex, lastLineBreakIndex), + language: i.substring(3, firstLineBreakIndex), + } as ParsedFragment; + } else { + return i; + } + }); + } + + return f; + }) + .flat(); +} + export function transformText(body: string, tags: Array>) { let fragments = extractLinks([body]); fragments = extractMentions(fragments); @@ -186,6 +229,7 @@ export function transformText(body: string, tags: Array>) { fragments = extractInvoices(fragments); fragments = extractCashuTokens(fragments); fragments = extractCustomEmoji(fragments, tags); + fragments = extractMarkdownCode(fragments); fragments = fragments .map(a => { if (typeof a === "string") { diff --git a/yarn.lock b/yarn.lock index 6ecf2593..2c26368b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2718,6 +2718,7 @@ __metadata: emojilib: ^3.0.10 eslint: ^8.48.0 eslint-webpack-plugin: ^4.0.1 + highlight.js: ^11.8.0 html-webpack-plugin: ^5.5.1 jest: ^29.5.0 jest-environment-jsdom: ^29.5.0 @@ -7278,6 +7279,13 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:^11.8.0": + version: 11.8.0 + resolution: "highlight.js@npm:11.8.0" + checksum: d2578a57aee7315946ff19379053fd0a28b127baabf7617ab1d28d62cdc4eaf3d75053569cb8479a5afdc7a68f1ba9a6c1d612d8ae399b4b9aa43093b4fb6831 + languageName: node + linkType: hard + "hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2"