
前端开发工具【免费下载链接】dillingerThe last Markdown editor, ever.项目地址https://gitcode.com/gh_mirrors/di/dillinger点击查看免费下载本文基于 DillingerThe last Markdown editor, ever.从 Express.js 迁移到 Next.js 过程中沉淀的工程笔记docs/AGENTS.md整理而成完整覆盖迁移期间遇到并解决的 8 类高价值问题Vercel 子目录部署、API 路由静态生成报错、Google Drive/OneDrive/Bitbucket 的 OAuth 作用域与文件去重、React SSR 水合异常、滚动同步与暗色模式以及生产环境上线前的 OAuth 检查清单。读完本文你将获得一套可以直接复用的多云盘 OAuth 接入与 Next.js 服务端渲染排错方法论并在文末拿到与本仓库源码一一对应的验证路径。一、迁移背景一次云盘 Markdown 编辑器的重构Dillinger 是一个把 Markdown 编辑、实时预览与 Google Drive、OneDrive、GitHub、Dropbox、Bitbucket 等云存储深度集成的在线编辑器。迁移的核心目标是把原来的 Express.js 服务端应用整体迁到 Next.js App Router 上让 API 路由OAuth 回调、文件读写、导出转换与前端编辑体验Monaco 编辑器 实时预览统一在一个框架内管理。从仓库结构看迁移后的形态非常清晰API 层app/api/ 下按云盘服务分组每个服务内部都遵循oauth / callback / files / save / status / unlink的路由划分例如 app/api/google-drive/、app/api/onedrive/、app/api/github/、app/api/dropbox/、app/api/bitbucket/前端层components/editor/MonacoEditor.tsx 负责编辑与自动保存components/preview/MarkdownPreview.tsx 负责渲染与滚动联动状态层stores/store.ts 以 Zustand 管理文档与设置components/providers/StoreProvider.tsx 负责客户端水合。docs/AGENTS.md记录的就是这次迁移过程中最有价值的排错经验——每一节都是问题 → 根因 → 解决方案 → 代码实现的完整闭环下面逐一展开。二、Vercel 子目录部署vercel.json必须放在 Root Directory 内问题现象Vercel 把仓库根目录的旧 Express.js 应用当成了部署目标而不是子目录next-app/里的 Next.js 应用。解决方案三步在 Vercel DashboardSettings → General将Root Directory设置为next-app在next-app/目录内而不是仓库根创建vercel.json使用最小化配置即可让框架自动检测生效{ $schema: https://openapi.vercel.sh/vercel.json, framework: nextjs }关键洞察当 Root Directory 指向子目录时Vercel 会在该子目录内部寻找vercel.json而非仓库根目录。配置文件放对位置后框架检测会自动完成无需自定义 build 命令。当前仓库的实际状态迁移完成后Next.js 应用已经位于仓库根目录app/即 App Router 源码目录根目录的 vercel.json 采用如下配置{ $schema: https://openapi.vercel.sh/vercel.json, framework: nextjs, functions: { app/api/export/pdf/route.ts: { includeFiles: node_modules/sparticuz/chromium/bin/**/* } } }注意其中的functions块PDF 导出路由依赖sparticuz/chromium无头浏览器二进制通过includeFiles显式把 chromium 二进制打包进 Serverless Function否则 Vercel 会因函数包缺少二进制导致 PDF 渲染失败。这是迁移文档之外的、位于仓库根目录 vercel.json 的补充配置部署时务必保留。三、Next.js API 路由静态生成错误force-dynamic的批量修复问题现象生产构建失败报错形如Dynamic server usage: Route /api/bitbucket/branches couldnt be rendered statically because it used cookies根因Next.js 默认会在构建期对 API 路由做静态预渲染。凡是使用了cookies()如读取 OAuth token cookie或其他动态服务端特性的路由都必须显式标记为动态渲染。解决方案在每个 API 路由文件顶部加入export const dynamic force-dynamic;涉及范围迁移时共涉及 45 个 API 路由文件分布在app/api/google-drive/、app/api/onedrive/、app/api/github/、app/api/dropbox/、app/api/bitbucket/、app/api/export/、app/api/upload/等目录。当前仓库中app/api/ 下所有route.ts的第一行都是这条导出语句例如 app/api/google-drive/oauth/route.ts、app/api/bitbucket/callback/route.ts、app/api/onedrive/files/route.ts 均以此开头你可以直接用正则^export const dynamic force-dynamic在 app/api/ 下验证该模式覆盖所有路由。当时的批量实施脚本文档记录的原始写法用于一次性给所有路由加导出语句for file in $(find app/api -name route.ts); do echo export const dynamic force-dynamic; | cat - $file /tmp/tempfile mv /tmp/tempfile $file done最佳实践后续新增任何需要读 cookie、用request.headers、或依赖运行时环境的 API 路由第一行都要写export const dynamic force-dynamic这是迁移后所有云盘路由能正常工作的前提。四、Google Drive OAuth作用域Scope决定你能看到哪些文件4.1 问题一Files API 返回空数组现象用户 Drive 里明明有.md文件files接口却返回空数组。根因https://www.googleapis.com/auth/drive.file这个作用域只授予应用自己创建的文件的访问权限无法枚举用户已有的文件。解决方案改用https://www.googleapis.com/auth/drive授予对整个 Drive 的读写访问。当前实现见 app/api/google-drive/oauth/route.tsconst params new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: code, scope: openid email https://www.googleapis.com/auth/drive, access_type: offline, prompt: consent, });4.2 问题二Status 接口返回 401 missing authentication credential根因/oauth2/v2/userinfo端点需要openid和email作用域缺了它们就无法解析用户身份。解决方案作用域补齐为scope: openid email https://www.googleapis.com/auth/drive即上面的最终形态。4.3 关键配套getAppUrl()统一生成回调地址注意 app/api/google-drive/oauth/route.ts 中的回调地址由getAppUrl()生成其实现位于 lib/env.tsconst DEFAULT_APP_URL http://localhost:3000; export function getAppUrl() { return ( process.env.NEXT_PUBLIC_APP_URL || process.env.NEXT_PUBLIC_BASE_URL || DEFAULT_APP_URL ).replace(/\/$/, ); }这意味着本地开发默认回调到localhost:3000生产环境必须设置NEXT_PUBLIC_APP_URL否则 OAuth 回调地址会全部指向本地导致生产环境登录失败。这个变量是所有云盘服务的公共依赖也是 docs/PRODUCTION-OAUTH-CHECKLIST.md 中反复强调的 CRITICAL 配置。五、Google Drive 保存去重同名文件覆盖而不是新建问题现象保存同名文件时产生重复文件而不是覆盖原文件。根因Google Drive 默认允许同名文件并存。原实现只有在调用方显式传入fileId时才更新文件否则一律新建。解决方案改造 app/api/google-drive/save/route.ts保存流程变为先查重、命中则更新、未命中才新建// If no fileId provided, check if file with same name exists in the folder if (!targetFileId) { const parentId folderId || root; const query name ${fileName.replace(//g, \\)} and ${parentId} in parents and trashed false; const searchResponse await fetch( https://www.googleapis.com/drive/v3/files?q${encodeURIComponent(query)}fieldsfiles(id), { headers: { Authorization: Bearer ${accessToken}, }, } ); if (searchResponse.ok) { const searchData await searchResponse.json(); if (searchData.files searchData.files.length 0) { // File exists, use its ID to update it targetFileId searchData.files[0].id; } } } const metadata { name: fileName, mimeType: text/markdown, ...(!targetFileId folderId ? { parents: [folderId] } : {}), };随后根据targetFileId是否存在决定请求方式对应源码 app/api/google-drive/save/route.tslet url https://www.googleapis.com/upload/drive/v3/files?uploadTypemultipart; let method POST; // If targetFileId exists (either provided or found), update existing file if (targetFileId) { url https://www.googleapis.com/upload/drive/v3/files/${targetFileId}?uploadTypemultipart; method PATCH; }实现细节值得注意的两点查询条件做了单引号转义fileName.replace(//g, \\)防止文件名里的单引号破坏 Drive 的q查询语法上传采用 multipart 方式元数据name、mimeType、parents与文件内容text/markdown拼装在同一请求体里边界符为-------314159265358979323846对应源码 app/api/google-drive/save/route.ts。这套查重 → PATCH/POST 二选一的模式同样适用于其他云盘服务是避免云端目录混乱的核心逻辑。六、OneDrive OAuth三个连环问题的逐个击破6.1 问题一消费者账号redirect_uri mismatch现象OAuth 回调报invalid_request - redirect_uri mismatch。根因Azure AD 的/common/端点面向工作/学校账号与个人账号的混合场景对纯个人账号outlook.com、live.com、hotmail.com会因租户语义不匹配而拒绝回调。解决方案把授权端点从/common/改为/consumers/见 app/api/onedrive/oauth/route.ts// Use /consumers/ for personal Microsoft accounts (outlook.com, live.com, hotmail.com) // Use /common/ if your Azure AD app supports both work/school AND personal accounts const authUrl https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?${params.toString()};源码注释给出了清晰的取舍规则只服务个人账号用/consumers/需要同时支持工作/学校和个人账号才用/common/。6.2 问题二Graph API 返回 401根因缺少User.Read作用域无法完成用户身份校验。解决方案作用域补齐为scope: User.Read Files.ReadWrite.All offline_access,Files.ReadWrite.All保证对 OneDrive 文件的读写权限offline_access则用于换取 refresh token对应源码 app/api/onedrive/oauth/route.ts。6.3 问题三$filter在个人版 OneDrive 上不可用现象Files API 返回 Operation not supported。根因个人版 OneDrive 的 Graph API 不支持$filter查询参数。解决方案移除$filter改为服务端过滤——拉取全部子项后在 Node 侧筛选目录和.md文件见 app/api/onedrive/files/route.ts// Filter for folders and .md files on the server side const files data.value .filter((item: { folder?: object; name: string }) { return item.folder || item.name.toLowerCase().endsWith(.md); }) .map((item: { id: string; name: string; folder?: object }) ({ id: item.id, name: item.name, isFolder: !!item.folder, }));同时注意文件内容获取的写法app/api/onedrive/files/route.ts元数据与文件内容用Promise.all并发请求两个 Graph 端点避免串行等待。通用经验当云盘 API 不支持服务端过滤时全量拉取 服务端过滤是最稳妥的兜底方案。七、Bitbucket OAuth三个协议层面的细节坑7.1 问题一token 交换报redirect_uri does not match根因Bitbucket 的 token 交换请求体里缺少redirect_uri服务端无法与授权阶段的回调地址比对。解决方案在 POST body 中加入redirect_uri见 app/api/bitbucket/callback/route.tsconst tokenResponse await fetch(https://bitbucket.org/site/oauth2/access_token, { method: POST, headers: { Content-Type: application/x-www-form-urlencoded, Authorization: Basic ${Buffer.from(${clientId}:${clientSecret}).toString(base64)}, }, body: new URLSearchParams({ grant_type: authorization_code, code, redirect_uri: redirectUri, }), });经验OAuth 2.0 的 token 交换阶段redirect_uri必须与授权请求中的完全一致包括协议、域名、路径这是多个云盘共同踩过的坑。7.2 问题二API 返回 403 credentials lack required privilege scopes根因应用只申请了默认作用域缺少访问仓库所需的权限。解决方案在 OAuth 授权请求中声明account和repository作用域scope: account repository注意除了代码层面声明作用域用户还必须在 Bitbucket OAuth 应用的设置里同步开启对应的权限项二者缺一不可。7.3 问题三列目录报 Resource not found根因Bitbucket 的srcAPI 在列出目录内容时对路径结尾的斜杠敏感。解决方案目录路径统一补上尾斜杠见 app/api/bitbucket/files/route.ts// For listing directory contents, we need to add trailing slash if no path const apiPath path ? /${path}/ : /; const url https://api.bitbucket.org/2.0/repositories/${workspace}/${repo}/src/${branch}${apiPath};同文件的 POST 分支读取文件内容则不加尾斜杠直接拼/${branch}/${path}。可见列目录加斜杠、读文件不加是 REST 语义差异导致的硬规则不能想当然统一处理。延伸文件列表同样做了服务端过滤app/api/bitbucket/files/route.ts只保留目录commit_directory与.md结尾的文件并提取path的最后一段作为展示名。八、React SSR/HydrationStoreProvider 的useContext空引用问题现象构建时报Cannot read properties of null (reading useContext)。根因StoreProvider 在 render 阶段直接调用useStore()钩子服务端与客户端各自执行时形成闭包错位破坏了 React 的 SSR 水合流程。解决方案改为在useEffect中通过useStore.getState()手动触发水合用useRef保证只执行一次见 components/providers/StoreProvider.tsxuse client; import { useEffect, useRef } from react; import { useStore } from /stores/store; export function StoreProvider({ children }: { children: React.ReactNode }) { const hasHydrated useRef(false); useEffect(() { if (!hasHydrated.current) { useStore.getState().hydrate(); hasHydrated.current true; } }, []); return {children}/; }要点useEffect只会在客户端执行useStore.getState().hydrate()在客户端读取本地存储并回填 Zustand store从而彻底绕开服务端渲染期间的上下文问题。这也解释了为什么该组件必须带use client指令。九、编辑体验修复滚动同步与暗色模式9.1 滚动同步开关失效问题现象开启滚动同步后编辑器滚动不联动预览。根因监听器原先挂在handleMount回调里捕获了过期的settings闭包值后续用户切换设置时监听器拿到的仍是旧值。解决方案把监听器移到useEffect并将settings.enableScrollSync放入依赖数组见 components/editor/MonacoEditor.tsx// Set up scroll sync listener (recreates when settings change) useEffect(() { const editor editorRef.current; if (!editor) return; const disposable editor.onDidScrollChange(() { if (enableScrollSync) { const scrollTop editor.getScrollTop(); const scrollHeight editor.getScrollHeight() - editor.getLayoutInfo().height; const percent scrollHeight 0 ? scrollTop / scrollHeight : 0; setEditorScrollPercent(percent); setEditorTopLine(editor.getVisibleRanges()[0]?.startLineNumber || 1); } }); return () disposable.dispose(); }, [enableScrollSync, setEditorScrollPercent, setEditorTopLine]);两个关键点清理函数return () disposable.dispose()保证依赖变化时旧监听器被正确移除避免重复订阅除了滚动百分比还同步了editorTopLine当前可见起始行供预览端做行级锚点对齐。预览端的消费逻辑在 components/preview/MarkdownPreview.tsx优先遍历渲染结果中带data-line-start的行锚点找到startLine editorTopLine的最后一个元素对齐滚动没有行锚点时才退化为按百分比滚动。行锚点由渲染管线lib/markdown.ts在生成 HTML 时注入并被 DOMPurify 的ADD_ATTR: [data-line-start, data-line-end]白名单放行components/preview/MarkdownPreview.tsx。9.2 夜间模式只作用于编辑器问题现象开启夜间模式后只有 Monaco 编辑器变暗预览面板仍是白底。解决方案给预览容器加条件 dark 类见 components/preview/MarkdownPreview.tsxclassName{preview-html h-full overflow-auto p-6 ${ enableNightMode ? dark bg-[#1e1e1e] : bg-transparent }}同时在 app/globals.css 补充整套暗色预览样式.dark.preview-html { color: #d4d4d4; } .dark.preview-html code { background: #2d2d2d; color: #d4d4d4; } .dark.preview-html pre { background: #2d2d2d; } /* ... 更多暗色样式标题、引用、表格、行内代码等 */对应地Monaco 编辑器侧也定义了两套自定义主题dillinger-light/dillinger-darkcomponents/editor/MonacoEditor.tsx夜间模式的整体效果由编辑器主题 预览 CSS 容器背景三处联动实现。十、生产部署OAuth 上线检查清单docs/AGENTS.md明确指向了迁移团队整理的上线清单 docs/PRODUCTION-OAUTH-CHECKLIST.md其中覆盖全部 5 类 OAuth 服务的配置项。核心要点提炼如下。10.1 回调地址统一模式生产环境的回调地址遵循统一模式https://your-production-domain.com/api/{service}/callback各服务的回调端点分别是https://your-production-domain.com/api/google-drive/callback https://your-production-domain.com/api/onedrive/callback https://your-production-domain.com/api/github/callback https://your-production-domain.com/api/dropbox/callback https://your-production-domain.com/api/bitbucket/callback对应各服务的 OAuth 配置台Google Cloud Consoleapp/api/google-drive/ 服务、Azure PortalOneDrive、GitHub Developer Settings、Dropbox App Console、Bitbucket OAuth Consumers。10.2 Vercel 环境变量全集生产环境需要在 Vercel Dashboard 为Production环境配置# Google Drive GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET # OneDrive ONEDRIVE_CLIENT_ID ONEDRIVE_CLIENT_SECRET # GitHub GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET # Dropbox DROPBOX_APP_KEY DROPBOX_APP_SECRET # Bitbucket BITBUCKET_CLIENT_ID BITBUCKET_CLIENT_SECRET # App URL (CRITICAL - used for all OAuth redirects) NEXT_PUBLIC_APP_URLhttps://your-production-domain.com # Node Environment NODE_ENVproductionNEXT_PUBLIC_APP_URL之所以是 CRITICAL是因为所有 OAuth 回调地址都由 lib/env.ts 的getAppUrl()拼接而成——缺了它生产环境的回调会全部指向localhost:3000。10.3 部署后回归测试清单上线后按清单逐项验证Google DriveConnect → List Files → Save File → Import FileOneDriveConnect → List Files → Save File → Import FileGitHubConnect → List Repos → List Files → Save FileDropboxConnect → List Files → Save File → Import FileBitbucketConnect → List Repos → List Files → Save File全部导出格式HTML、Markdown、PDF拖拽导入、图片上传Zen 模式、滚动同步、夜间模式开关10.4 四条容易忽略的注意事项本地开发不冲突大多数 OAuth 应用允许配置多个回调地址可以保留 localhost 地址与生产地址共存新增作用域可能触发审核如果这次改动新增了权限范围部分提供商的 OAuth 应用可能需要重新审核清 Cookie 重连部署后用户需要用新 token 重新连接账号必要时引导清理浏览器 Cookie先改配置台再部署严格按先在 OAuth 控制台加生产回调 → 再更新 Vercel 环境变量 → 再部署的顺序操作避免回调 404。十一、沉淀下来的通用模式OAuth 作用域问题始终对照提供商文档核对所需作用域区分drive.file仅应用自建文件与drive全部文件的访问级别差异openid和email是userinfo类接口的常见前置条件个人账号与组织/工作账号的要求可能不同如 OneDrive 的/consumers/与/common/之分。API 路由最佳实践所有使用cookies()的路由一律标记force-dynamic提供商 API 不支持过滤时改用服务端过滤token 交换请求务必携带redirect_uri谨慎处理 REST API 的尾斜杠语义Bitbucket 列目录 vs 读文件。Vercel 子目录部署在 Dashboard 设置 Root Directory把vercel.json放进该子目录让框架自动检测生效除非必要避免自定义 build 命令。十二、延伸阅读仓库内证据路径迁移设计文档docs/plans/2026-01-19-nextjs-migration-design.md、docs/plans/2026-01-19-nextjs-migration-phase2.md、docs/plans/2026-01-19-phase3-design.md生产 OAuth 上线清单docs/PRODUCTION-OAUTH-CHECKLIST.md部署配置vercel.json环境变量封装lib/env.tsGoogle Drive OAuth 与保存去重app/api/google-drive/oauth/route.ts、app/api/google-drive/save/route.tsOneDrive 授权与文件列表app/api/onedrive/oauth/route.ts、app/api/onedrive/files/route.tsBitbucket 回调与文件路由app/api/bitbucket/callback/route.ts、app/api/bitbucket/files/route.ts前端水合components/providers/StoreProvider.tsx滚动同步与暗色模式components/editor/MonacoEditor.tsx、components/preview/MarkdownPreview.tsx、app/globals.css赞分享前端开发工具【免费下载链接】dillingerThe last Markdown editor, ever.项目地址https://gitcode.com/gh_mirrors/di/dillinger点击查看免费下载相关推荐Spring Boot 3.3迁移实践pig平台JDK17适配踩坑记录Spring Boot 3.3迁移实践pig平台JDK17适配踩坑记录 引言为什么要迁移到Spring Boot 3.3和JDK17 随着Java生态的不断后端微服务认证鉴权API网关代码生成任务调度Dillinger 从 Angular.js 到 Next.js 14 App Router 的全栈迁移设计与实践Dillinger 从 Angular.js 到 Next.js 14 App Router 的全栈迁移设计与实践 本篇技术指南围绕 Dillinger 官方迁前端开发工具Fission 集成测试 Bash → Go 迁移全记录状态追踪、逐测试映射与六阶段实施拆解Fission 集成测试 Bash → Go 迁移全记录状态追踪、逐测试映射与六阶段实施拆解 FissionFast and Simple Serverle云原生后端上一篇Champ社区贡献指南如何参与项目开发下一篇解锁SiYuan v3.2强大数据库功能SQL查询与报表可视化完全指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考