diff --git a/prisma/dev.db b/prisma/dev.db index 8aa2a7d..f611a2a 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/src/app/api/pages/[id]/route.ts b/src/app/api/pages/[id]/route.ts index 1fe7cc4..c5bcac5 100644 --- a/src/app/api/pages/[id]/route.ts +++ b/src/app/api/pages/[id]/route.ts @@ -2,6 +2,7 @@ import { prisma } from '@/lib/prisma'; import type { Prisma } from '@prisma/client'; import { requireApiAuth } from '@/lib/api-auth'; +import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import'; type PageRef = { id: string; parentId: string | null }; @@ -119,11 +120,23 @@ export async function PUT( updateData.title = trimmed || '无标题'; } - if (body.content !== undefined) { - if (typeof body.content !== 'string') { + if (body.content !== undefined || body.importMarkdown !== undefined) { + if (body.content !== undefined && typeof body.content !== 'string') { return NextResponse.json({ error: 'Invalid content' }, { status: 400 }); } - updateData.content = body.content; + if (body.importMarkdown !== undefined && typeof body.importMarkdown !== 'string') { + return NextResponse.json({ error: 'Invalid importMarkdown' }, { status: 400 }); + } + const source = typeof body.importMarkdown === 'string' ? markdownToImportHtml(body.importMarkdown) : (body.content as string); + updateData.content = cleanupAccidentalStandaloneInlineCode(source); + if (typeof body.importMarkdown === 'string' && process.env.NODE_ENV !== 'production') { + const lines = body.importMarkdown.replace(/\r\n?/g, '\n').split('\n'); + const fenceLines = lines + .map((line: string, idx: number) => (line.trimStart().startsWith('```') || line.trimStart().startsWith('~~~') ? idx + 1 : -1)) + .filter((n: number) => n > 0); + const preEnd = String(updateData.content || '').indexOf(''); + console.info(`[import-md][PUT] id=${id} fences=${fenceLines.join(',') || 'none'} preEnd=${preEnd} len=${String(updateData.content || '').length}`); + } } if (body.icon !== undefined) { @@ -213,3 +226,5 @@ export async function DELETE( return NextResponse.json({ error: 'Error deleting page' }, { status: 500 }); } } + + diff --git a/src/app/api/pages/route.ts b/src/app/api/pages/route.ts index 8e58515..7abbe00 100644 --- a/src/app/api/pages/route.ts +++ b/src/app/api/pages/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { prisma } from '@/lib/prisma'; import { requireApiAuth } from '@/lib/api-auth'; +import { cleanupAccidentalStandaloneInlineCode, markdownToImportHtml } from '@/lib/markdown-import'; const MAX_TITLE_LENGTH = 200; const MAX_TAGS = 20; @@ -64,7 +65,19 @@ export async function POST(request: Request) { } const type = body.type === 'folder' ? 'folder' : 'file'; - const content = typeof body.content === 'string' ? body.content : ''; + const importMarkdown = typeof body.importMarkdown === 'string' ? body.importMarkdown : null; + const rawContent = typeof body.content === 'string' ? body.content : ''; + const content = cleanupAccidentalStandaloneInlineCode( + importMarkdown !== null ? markdownToImportHtml(importMarkdown) : rawContent + ); + if (importMarkdown !== null && process.env.NODE_ENV !== 'production') { + const lines = importMarkdown.replace(/\r\n?/g, '\n').split('\n'); + const fenceLines = lines + .map((line: string, idx: number) => (line.trimStart().startsWith('```') || line.trimStart().startsWith('~~~') ? idx + 1 : -1)) + .filter((n: number) => n > 0); + const preEnd = content.indexOf(''); + console.info(`[import-md][POST] title="${title}" fences=${fenceLines.join(',') || 'none'} preEnd=${preEnd} len=${content.length}`); + } const parentId = typeof body.parentId === 'string' ? body.parentId : null; const tags = normalizeTags(body.tags); const icon = typeof body.icon === 'string' ? body.icon : null; @@ -105,3 +118,6 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Error creating page' }, { status: 500 }); } } + + + diff --git a/src/app/api/settings/restore/route.ts b/src/app/api/settings/restore/route.ts index 36c8880..ec1020c 100644 --- a/src/app/api/settings/restore/route.ts +++ b/src/app/api/settings/restore/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import JSZip from "jszip"; -import { marked } from "marked"; import { prisma } from "@/lib/prisma"; import { requireApiAuth } from "@/lib/api-auth"; +import { markdownToImportHtml } from "@/lib/markdown-import"; type ZipEntry = { path: string; @@ -68,7 +68,7 @@ async function parseMarkdownWithFrontmatter(filePath: string, content: string): } } - const htmlContent = await marked(markdownBody, { gfm: true, breaks: true }); + const htmlContent = markdownToImportHtml(markdownBody); return { title, order, tags, htmlContent }; } diff --git a/src/components/editor.tsx b/src/components/editor.tsx index 111b0c7..ad6e1f4 100644 --- a/src/components/editor.tsx +++ b/src/components/editor.tsx @@ -47,6 +47,7 @@ import { Markdown } from "tiptap-markdown"; import { Fragment, type Node as ProseMirrorNode, type Schema } from "@tiptap/pm/model"; import { TextSelection } from "@tiptap/pm/state"; import { buildDropIndicatorText, mergeBlockTexts, moveContiguousSpan } from "@/lib/editor-block-ops"; +import { cleanupAccidentalStandaloneInlineCode } from "@/lib/markdown-import"; interface EditorProps { content: string; @@ -617,6 +618,8 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, targetInsertionIndex: number; }>({ sourceStartIndex: -1, sourceEndIndex: -1, targetInsertionIndex: -1 }); const activeBlockIndexRef = useRef(null); + const suppressNextUpdateRef = useRef(false); + const allowOnUpdateRef = useRef(true); const { fontFamily, fontSize, lineHeight, tableLineHeight } = useSettingsStore(); const editor = useEditor({ @@ -691,6 +694,11 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, ], content, onUpdate: ({ editor }) => { + if (suppressNextUpdateRef.current) { + suppressNextUpdateRef.current = false; + return; + } + if (!allowOnUpdateRef.current) return; if (editor.getHTML() !== content) { onChange(editor.getHTML()); } @@ -702,6 +710,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, spellcheck: "false", }, handleKeyDown: (_view, event) => { + allowOnUpdateRef.current = true; if (!editable) return false; const editorInstance = editor; @@ -727,6 +736,7 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, return false; }, handleClick: (view, pos, event) => { + allowOnUpdateRef.current = true; if (!onOpenWikiLink) return false; const pointerEvent = event as MouseEvent; if (!pointerEvent.metaKey && !pointerEvent.ctrlKey) return false; @@ -738,6 +748,20 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, onOpenWikiLink(title); return true; }, + handleDOMEvents: { + mousedown: () => { + allowOnUpdateRef.current = true; + return false; + }, + paste: () => { + allowOnUpdateRef.current = true; + return false; + }, + beforeinput: () => { + allowOnUpdateRef.current = true; + return false; + }, + }, }, editable, immediatelyRender: false, @@ -882,9 +906,15 @@ export function Editor({ content, onChange, onEditorReady, onToggleAI, onExport, useEffect(() => { if (editor && content !== editor.getHTML()) { + const normalizedContent = cleanupAccidentalStandaloneInlineCode(content); + suppressNextUpdateRef.current = true; + allowOnUpdateRef.current = false; queueMicrotask(() => { - editor.commands.setContent(content); + editor.commands.setContent(normalizedContent); }); + if (normalizedContent !== content) { + onChange(normalizedContent); + } } if (editor && onEditorReady) { onEditorReady(editor); diff --git a/src/components/import-context.tsx b/src/components/import-context.tsx index 9438898..cf9b3b9 100644 --- a/src/components/import-context.tsx +++ b/src/components/import-context.tsx @@ -2,8 +2,8 @@ import React, { createContext, useContext, useRef, useState, useCallback } from "react"; import { useEditorStore } from "@/lib/store"; -import { marked } from "marked"; import JSZip from "jszip"; +import { markdownToImportHtml } from "@/lib/markdown-import"; interface ImportContextType { triggerImport: (parentId?: string | null) => void; @@ -27,13 +27,14 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const targetParentIdRef = useRef(null); - const { fetchPages } = useEditorStore(); + const { pages, fetchPages } = useEditorStore(); const postPage = useCallback( async ( payload: { title: string; content?: string; + importMarkdown?: string; parentId?: string | null; type: "file" | "folder"; }, @@ -62,6 +63,41 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { [] ); + const putPage = useCallback( + async ( + id: string, + payload: { + title?: string; + content?: string; + importMarkdown?: string; + parentId?: string | null; + type?: "file" | "folder"; + }, + contextLabel: string + ) => { + const res = await fetch(`/api/pages/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + credentials: "same-origin", + }); + + if (!res.ok) { + let detail = ""; + try { + const data = await res.json(); + detail = typeof data?.error === "string" ? data.error : JSON.stringify(data); + } catch { + detail = await res.text(); + } + throw new ImportApiError(`${contextLabel}失败(${res.status})${detail ? `: ${detail}` : ""}`, res.status, detail); + } + + return res.json(); + }, + [] + ); + const triggerImport = useCallback((parentId: string | null = null) => { targetParentIdRef.current = parentId; if (fileInputRef.current) { @@ -94,13 +130,30 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const handleSingleFileImport = async (file: File) => { const text = await file.text(); const title = file.name.replace(/\.md$/i, ""); - const html = await marked.parse(text); + const html = markdownToImportHtml(text); + const parentId = targetParentIdRef.current; + const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim()); + if (existing) { + await putPage( + existing.id, + { + title, + content: html, + importMarkdown: text, + parentId, + type: "file", + }, + "更新导入文件" + ); + return; + } await postPage( { title, content: html, - parentId: targetParentIdRef.current, + importMarkdown: text, + parentId, type: "file", }, "创建导入文件" @@ -199,17 +252,32 @@ export function ImportProvider({ children }: { children: React.ReactNode }) { const markdown = await zipFile.async("string"); const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, ""); - const html = await marked.parse(markdown); - - await postPage( - { - title, - content: html, - parentId, - type: "file", - }, - `创建文件 ${path}` - ); + const html = markdownToImportHtml(markdown); + const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim()); + if (existing) { + await putPage( + existing.id, + { + title, + content: html, + importMarkdown: markdown, + parentId, + type: "file", + }, + `更新文件 ${path}` + ); + } else { + await postPage( + { + title, + content: html, + importMarkdown: markdown, + parentId, + type: "file", + }, + `创建文件 ${path}` + ); + } } }; diff --git a/src/lib/markdown-import.ts b/src/lib/markdown-import.ts new file mode 100644 index 0000000..0030823 --- /dev/null +++ b/src/lib/markdown-import.ts @@ -0,0 +1,127 @@ +import { marked } from "marked"; + +type MarkdownToHtmlOptions = { + breaks?: boolean; +}; + +type CodeFenceBlock = { + placeholder: string; + html: string; +}; + +function escapeHtml(raw: string): string { + return raw + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeRegExp(raw: string): string { + return raw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function buildCodeBlockHtml(langRaw: string, codeRaw: string): string { + const language = (langRaw || "").trim().split(/\s+/)[0]; + const classAttr = language ? ` class="language-${escapeHtml(language)}"` : ""; + return `
${escapeHtml(codeRaw)}
`; +} + +function parseWithMarked(markdown: string, breaks: boolean): string { + return marked.parse(markdown, { gfm: true, breaks }) as string; +} + +function scanAndMaskFencedCodeBlocks(markdown: string): { masked: string; blocks: CodeFenceBlock[] } { + const normalized = markdown.replace(/\r\n?/g, "\n"); + const lines = normalized.split("\n"); + const output: string[] = []; + const blocks: CodeFenceBlock[] = []; + let blockIndex = 0; + + let inFence = false; + let fenceChar = ""; + let fenceLength = 0; + let fenceInfo = ""; + let placeholder = ""; + let contentLines: string[] = []; + + for (const line of lines) { + if (!inFence) { + const open = line.match(/^ {0,3}(`{3,}|~{3,})([^\n]*)\s*$/); + if (!open) { + output.push(line); + continue; + } + + inFence = true; + fenceChar = open[1][0]; + fenceLength = open[1].length; + fenceInfo = open[2] || ""; + contentLines = []; + placeholder = `@@CODE_BLOCK_${blockIndex++}@@`; + output.push(placeholder); + continue; + } + + const closeRe = new RegExp(`^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}\\s*$`); + if (closeRe.test(line)) { + blocks.push({ + placeholder, + html: buildCodeBlockHtml(fenceInfo, contentLines.join("\n")), + }); + inFence = false; + fenceChar = ""; + fenceLength = 0; + fenceInfo = ""; + placeholder = ""; + contentLines = []; + continue; + } + + contentLines.push(line); + } + + if (inFence && placeholder) { + blocks.push({ + placeholder, + html: buildCodeBlockHtml(fenceInfo, contentLines.join("\n")), + }); + } + + return { masked: output.join("\n"), blocks }; +} + +function parseWithExplicitFences(markdown: string, breaks: boolean): string { + const { masked, blocks } = scanAndMaskFencedCodeBlocks(markdown); + let html = parseWithMarked(masked, breaks); + + for (const block of blocks) { + html = html.replace(`

${block.placeholder}

`, block.html); + html = html.split(block.placeholder).join(block.html); + } + + return html; +} + +export function cleanupAccidentalStandaloneInlineCode(html: string): string { + const standaloneMatches = html.match(/<(p|h[1-6]|li)>[\s\S]*?<\/code><\/\1>/g) || []; + if (standaloneMatches.length < 3) return html; + + const headingCodeMatches = html.match(/[\s\S]*?<\/code><\/h[1-6]>/g) || []; + const suspiciousCount = standaloneMatches.filter((block) => /https?:\/\/|@\w|\/\s*\d{3,}|[一-龥]/.test(block)).length; + const hasPreBlock = /
\s*)/.test(html);
+
+    const looksMalformed =
+        headingCodeMatches.length > 0 ||
+        (hasPreBlock && standaloneMatches.length >= 5 && suspiciousCount >= 3);
+    if (!looksMalformed) return html;
+
+    return html.replace(/<(p|h[1-6]|li)>([\s\S]*?)<\/code><\/\1>/g, "<$1>$2");
+}
+
+export function markdownToImportHtml(markdown: string, options: MarkdownToHtmlOptions = {}): string {
+    const breaks = options.breaks ?? true;
+    const html = parseWithExplicitFences(markdown, breaks);
+    return cleanupAccidentalStandaloneInlineCode(html);
+}