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 "";