From b73604d7bae7ab9da93e58982eec6944e91db698 Mon Sep 17 00:00:00 2001 From: yoma Date: Sat, 11 Jul 2026 21:43:01 +0800 Subject: [PATCH] Add syntax highlighting for code blocks Refs #12 --- apps/web/package.json | 6 +- apps/web/src/components/EditorPane.tsx | 5 +- apps/web/src/components/EditorToolbar.tsx | 27 ++++ apps/web/src/i18n/resources/en-US.ts | 2 + apps/web/src/i18n/resources/zh-CN.ts | 2 + apps/web/src/lib/code-block.ts | 24 ++++ apps/web/src/styles/globals.css | 36 ++++++ bun.lock | 10 ++ packages/shared/src/content.ts | 149 +++++++++++++++------- 9 files changed, 215 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/lib/code-block.ts diff --git a/apps/web/package.json b/apps/web/package.json index 973ec5c0..c0b45fcc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,5 +2,9 @@ "name": "@edgeever/web", "version": "0.1.3", "private": true, - "type": "module" + "type": "module", + "dependencies": { + "@tiptap/extension-code-block-lowlight": "3.27.1", + "lowlight": "^3.3.0" + } } diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index a913ae07..ac3bbfec 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo, type PointerEvent as import { useQueryClient, useMutation } from "@tanstack/react-query"; import { NodeViewWrapper, ReactNodeViewRenderer, useEditor, EditorContent, type Editor, type NodeViewProps } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; +import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import Image from "@tiptap/extension-image"; import Placeholder from "@tiptap/extension-placeholder"; import { useTranslation } from "react-i18next"; @@ -54,6 +55,7 @@ import { api } from "@/lib/api"; import { consumeStandaloneMobileEditorReturn, openStandaloneMobileEditor } from "@/lib/mobile-editor"; import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; import { docToMarkdown, markdownToDoc, type Notebook, type MemoDetail, type MemoEditSession, type TiptapDoc } from "@edgeever/shared"; +import { codeBlockLowlight } from "@/lib/code-block"; import { compressImageForUpload } from "@/lib/image-compression"; import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db"; import { getMemoUpdateQueueId, queueMemoUpdate, shouldQueueMemoSaveError } from "@/lib/sync-queue"; @@ -1304,7 +1306,8 @@ const RichEditorPane = ({ const editor = useEditor({ extensions: [ - StarterKit, + StarterKit.configure({ codeBlock: false }), + CodeBlockLowlight.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }), ResizableImage.configure({ allowBase64: false, inline: false, diff --git a/apps/web/src/components/EditorToolbar.tsx b/apps/web/src/components/EditorToolbar.tsx index 08fbcc59..3cc94764 100644 --- a/apps/web/src/components/EditorToolbar.tsx +++ b/apps/web/src/components/EditorToolbar.tsx @@ -18,6 +18,7 @@ import { cn } from "@/lib/utils"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getActiveBlockValue } from "@/lib/app-helpers"; +import { CODE_BLOCK_LANGUAGES, getCodeBlockLanguageValue } from "@/lib/code-block"; const EditorToolbarButton = ({ active = false, @@ -87,6 +88,9 @@ export const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; rea const editorReady = isToolbarEditorReady(editor); const disabled = readOnly || !editorReady; const blockValue = getActiveBlockValue(editor); + const codeBlockLanguage = editorReady + ? getCodeBlockLanguageValue(editor.getAttributes("codeBlock").language) + : "plaintext"; const isActive = (name: string) => { if (!editorReady) { return false; @@ -257,6 +261,29 @@ export const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; rea > + {isActive("codeBlock") && ( + + )} { + if (typeof language !== "string" || !language) { + return "plaintext"; + } + + return CODE_BLOCK_LANGUAGES.some((option) => option.value === language) ? language : "plaintext"; +}; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index c8699385..1f857bc6 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -187,6 +187,42 @@ textarea { color: inherit; } +/* Highlight.js-compatible token classes emitted by Tiptap/lowlight. */ +.ProseMirror pre .hljs-comment, +.ProseMirror pre .hljs-quote { + color: #64748b; +} + +.ProseMirror pre .hljs-keyword, +.ProseMirror pre .hljs-selector-tag, +.ProseMirror pre .hljs-literal, +.ProseMirror pre .hljs-name, +.ProseMirror pre .hljs-attribute { + color: #7c3aed; +} + +.ProseMirror pre .hljs-string, +.ProseMirror pre .hljs-title, +.ProseMirror pre .hljs-section, +.ProseMirror pre .hljs-built_in, +.ProseMirror pre .hljs-type { + color: #047857; +} + +.ProseMirror pre .hljs-number, +.ProseMirror pre .hljs-symbol, +.ProseMirror pre .hljs-bullet, +.ProseMirror pre .hljs-variable, +.ProseMirror pre .hljs-template-variable { + color: #c2410c; +} + +.ProseMirror pre .hljs-meta, +.ProseMirror pre .hljs-regexp, +.ProseMirror pre .hljs-link { + color: #0369a1; +} + .ProseMirror hr { margin: 1.5rem 0; border: 0; diff --git a/bun.lock b/bun.lock index 0a209656..97f2fab8 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,10 @@ "apps/web": { "name": "@edgeever/web", "version": "0.1.3", + "dependencies": { + "@tiptap/extension-code-block-lowlight": "3.27.1", + "lowlight": "^3.3.0", + }, }, "packages/client": { "name": "@edgeever/client", @@ -1013,6 +1017,8 @@ "@tiptap/extension-code-block": ["@tiptap/extension-code-block@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/pm": "3.27.1" } }, "sha512-pHlzmZx2OlHfyQ0yRlT5UL4mGokz947DthZuYefN1OleVqOkHpWBG+2JQwqoNq6bmzMne92zbH32rhcJUEYSjA=="], + "@tiptap/extension-code-block-lowlight": ["@tiptap/extension-code-block-lowlight@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1", "@tiptap/extension-code-block": "3.27.1", "@tiptap/pm": "3.27.1", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "sha512-rDZYDwKpV3Uw8xjI5pzSQTLJ7pEyJkDrVhnzdh9mBY3sHVwP1maYh+0kRUrHi5N3EhiNp/GXG1kPjysPIQ4nqQ=="], + "@tiptap/extension-document": ["@tiptap/extension-document@3.27.1", "", { "peerDependencies": { "@tiptap/core": "3.27.1" } }, "sha512-8FbBTkfnRP4iVaoj+2h3iWa+H0eGDD3yTyVCwrmue/sQTkqUNUoSuAZa3GDG4Sd41xdPwTJxl9nUWGgM1qDCnw=="], "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@3.27.1", "", { "peerDependencies": { "@tiptap/extensions": "3.27.1" } }, "sha512-blFf9x9RG0Qr7P3FoAH/033ffa+mMLZn34trVs8Vi0Ppk6FmJAg5HpYFOtmYoeREdNDJ5rHJKV7SoACbOHgskQ=="], @@ -1747,6 +1753,8 @@ "hermes-parser": ["hermes-parser@0.36.0", "", { "dependencies": { "hermes-estree": "0.36.0" } }, "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w=="], + "highlight.js": ["highlight.js@11.11.1", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="], + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], @@ -1979,6 +1987,8 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lowlight": ["lowlight@3.3.0", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", "highlight.js": "~11.11.0" } }, "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ=="], + "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], "lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="], diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index cc0a5b5e..a3166144 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -22,53 +22,85 @@ export const emptyDoc = (): TiptapDoc => ({ }); export const markdownToDoc = (markdown: string): TiptapDoc => { - const blocks = markdown - .replace(/\r\n/g, "\n") - .split(/\n{2,}/) - .map((block) => block.trim()) - .filter(Boolean); + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + const content: TiptapNode[] = []; - if (blocks.length === 0) { + for (let index = 0; index < lines.length; ) { + if (!lines[index].trim()) { + index += 1; + continue; + } + + const fence = /^```([^\s`]*)\s*$/.exec(lines[index].trim()); + + if (fence) { + const codeLines: string[] = []; + index += 1; + + while (index < lines.length && lines[index].trim() !== "```") { + codeLines.push(lines[index]); + index += 1; + } + + if (index < lines.length) { + index += 1; + } + + content.push({ + type: "codeBlock", + attrs: { language: fence[1] || "plaintext" }, + content: [{ type: "text", text: codeLines.join("\n") }], + }); + continue; + } + + const blockLines: string[] = []; + while (index < lines.length && lines[index].trim()) { + blockLines.push(lines[index]); + index += 1; + } + + const block = blockLines.join("\n").trim(); + const heading = /^(#{1,3})\s+(.+)$/.exec(block); + const image = /^!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]+)")?\)$/.exec(block); + + if (heading) { + content.push({ + type: "heading", + attrs: { level: heading[1].length }, + content: [{ type: "text", text: heading[2] }], + }); + continue; + } + + if (image) { + content.push({ + type: "image", + attrs: { + src: image[2], + alt: image[1] || null, + title: image[3] || null, + }, + }); + continue; + } + + if (/^-{3,}$/.test(block)) { + content.push({ type: "horizontalRule" }); + continue; + } + + content.push({ + type: "paragraph", + content: [{ type: "text", text: block }], + }); + } + + if (content.length === 0) { return emptyDoc(); } - return { - type: "doc", - content: blocks.map((block) => { - const heading = /^(#{1,3})\s+(.+)$/.exec(block); - const image = /^!\[([^\]]*)\]\((\S+?)(?:\s+"([^"]+)")?\)$/.exec(block); - - if (heading) { - return { - type: "heading", - attrs: { level: heading[1].length }, - content: [{ type: "text", text: heading[2] }], - }; - } - - if (image) { - return { - type: "image", - attrs: { - src: image[2], - alt: image[1] || null, - title: image[3] || null, - }, - }; - } - - if (/^-{3,}$/.test(block)) { - return { - type: "horizontalRule", - }; - } - - return { - type: "paragraph", - content: [{ type: "text", text: block }], - }; - }), - }; + return { type: "doc", content }; }; export const docToText = (doc: unknown): string => { @@ -178,12 +210,41 @@ const blockToMarkdown = (node: unknown): string => { } if (current.type === "codeBlock") { - return `\`\`\`\n${docToText({ content: current.content })}\n\`\`\``; + const language = getStringAttr(current.attrs, "language"); + const languageSuffix = language && language !== "plaintext" ? language : ""; + const code = contentToPlainText(current.content); + return `\`\`\`${languageSuffix}\n${code}\n\`\`\``; } return inlineToMarkdown(current.content); }; +const contentToPlainText = (content: unknown): string => { + if (!Array.isArray(content)) { + return ""; + } + + return content + .map((node) => { + if (!node || typeof node !== "object") { + return ""; + } + + const current = node as { type?: unknown; text?: unknown; content?: unknown }; + + if (typeof current.text === "string") { + return current.text; + } + + if (current.type === "hardBreak") { + return "\n"; + } + + return contentToPlainText(current.content); + }) + .join(""); +}; + const inlineToMarkdown = (content: unknown): string => { if (!Array.isArray(content)) { return "";