# dsh-sidebar-superdoc-docx

[English](README.md) | 中文

在 [DSH](https://github.com/deepseek-ai/DeepSeek-Harness) Web 界面的 **better-sidebar** 侧边栏里直接打开并编辑 **.docx** 文件 —— 由 [SuperDoc](https://github.com/superdoc/docx-editor) 驱动的浏览器原生 DOCX 编辑器,直接读写真实 OOXML,无需任何服务端文档服务。

> **依赖**:本插件向 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar)(`>= 0.13.0`)注册文件查看器,它是**必选 peer 依赖** —— 不安装它,查看器不会出现。请先(或一并)安装。
>
> **⚠️ 许可**:本插件自身代码为 MIT,但运行时集成了 **AGPL-3.0** 的 `superdoc` 与**专有许可**的 `@superdoc/docx-engine`,安装即表示接受相应条款。详见文末[许可证](#许可证)与 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。

## 功能简介

- **浏览器原生 DOCX 编辑** —— 在侧边栏里查看、编辑 `.docx`,支持批注与修订痕迹(tracked changes),三种打开模式可在设置页切换:编辑 / 修订 / 查看。
- **保存写回磁盘** —— 「保存」按钮把编辑后的文档导出为 DOCX,经专用路由**原子覆盖**原文件(临时文件 + rename),不会产生半写状态;未保存修改以 `●` 圆点提示。
- **跟随外部修改** —— 每 3 秒轮询磁盘:编辑器干净时自动原地换入新版本(`replaceFile`);有未保存修改时只显示提示条并提供手动「重新加载」,绝不静默丢弃你的编辑。
- **完全自托管、可离线** —— SuperDoc 编辑器构建与 DOCX 引擎(含其 web worker)从本包 `node_modules` 经同源路由下发:无 CDN 流量、无第三方文档服务、默认关闭遥测。`pnpm install` 之后全程可离线。
- **下载兜底** —— 所有界面(包括全部错误态)都保留普通下载链接。
- **侧栏自适应** —— 工具栏随面板宽度折叠进「…」溢出菜单,页面按面板宽度自动缩放(fit-to-pane),亮 / 暗主题均可读。

## 适用场景

- **人机协同编辑同一份 Word 文档**:AI 代理在会话里改了 `.docx`,你在侧栏几秒内看到新版并继续人工润色;保存后 AI 的下一轮编辑又基于最新版本 —— 双向往返不打断。
- **内网 / 离线 / 合规环境**:不允许出网到 jsdelivr 等 CDN,或不允许接入 SaaS 文档服务的部署;所有资产同源自托管。
- **不想为编辑 Word 部署服务端**:相比 OnlyOffice / Collabora 需要单独的 Document Server,本插件零服务依赖,装上即用。
- **文档评审流程**:以「修订」模式打开,批注与建议以修订痕迹记录,适合审阅-回评的协作。
- **快速预览**:替代内置的代码 / 下载查看器,侧栏点击 `.docx` 即见排版后的文档,随时可另存下载。

## 如何安装

### 前提

- Node.js `>= 20`;
- DSH Web 界面及其 `web` profile;
- 同一 profile 内已安装 `dsh-better-sidebar >= 0.13.0`(见顶部依赖说明)。

### 从 npm 安装(推荐)

包已发布到 npmjs,包名 `dsh-sidebar-superdoc-docx`:

```sh
dsh plugin --profile web add dsh-better-sidebar dsh-sidebar-superdoc-docx
```

或手工编辑 profile 的 `package.json`(如 `~/.dsh/profiles/web/package.json`),加入两个 npm
依赖与 bundle 条目,然后在 profile 目录执行 `pnpm install`:

```jsonc
{
  "dependencies": {
    "dsh-better-sidebar": ">=0.13.0",
    "dsh-sidebar-superdoc-docx": "^0.1.0"
  },
  "dsh": { "profile": { "bundles": ["…", "dsh-better-sidebar", "dsh-sidebar-superdoc-docx"] } }
}
```

最后**重启 `dsh web`**(host 半需重新加载),浏览器硬刷新(Ctrl/Cmd+Shift+R)。

### 从 GitHub 源码安装(开发用)

```sh
dsh plugin --profile web add github:chendefine/dsh-sidebar-superdoc-docx
```

本地开发:克隆、构建、link:

```sh
git clone https://github.com/chendefine/dsh-sidebar-superdoc-docx
cd dsh-sidebar-superdoc-docx
pnpm install
pnpm build        # → lib/index.js + lib/client.js + lib/types
```

然后在 profile 的 `package.json` 里把依赖指向克隆目录,并在 profile 目录执行 `pnpm install`:

```jsonc
{
  "dependencies": {
    "dsh-sidebar-superdoc-docx": "link:/绝对路径/dsh-sidebar-superdoc-docx"
  }
}
```

### 插件配置(profile 的 `cordis.patch.yml`)

```yaml
- id: dsh-sidebar-superdoc-docx
  config:
    fileLimitMb: 100            # 保存路由的大小上限(MB),默认 100
    allowOutsideWorkspace: false # 允许保存解析后会话工作目录之外的文件,默认 false
```

## 如何使用

### 打开文档

侧栏文件树点击任意 `.docx` —— 将以 **DOCX(SuperDoc 编辑)** 查看器打开(而不是内置的代码 / 下载查看器)。

### 切换打开模式

**设置 → 侧边卡片 → 文件预览 → DOCX(SuperDoc 编辑)** 的齿轮里,把「打开模式」设为 编辑 / 修订 / 查看。选择持久化在 `pluginSettings['superdoc:docx'].mode`,切换后编辑器以新模式重新挂载,即时生效;「查看」模式同时隐藏保存按钮。

### 编辑与保存

- 顶部工具栏为 SuperDoc 原生工具栏(加粗、列表、批注等),随面板宽度自动折叠;
- 修改后标题行出现 `● 有未保存修改`,点击 **保存** 导出并原子写回原路径;
- 状态机:保存中… → 已保存 / 保存失败(失败会带原因,可重试);保存进行中再做的编辑会继续保持「未保存」提示,可再次保存。

### 跟随外部修改(例如 AI 代理编辑了该文件)

- 编辑器**干净**时:3 秒轮询发现磁盘变化 → 自动重新拉取并以 `replaceFile` 原地换到新版本,并重新适配缩放;
- 编辑器**有未保存修改**时:仅显示「文件已在磁盘上被修改」提示条,由你决定是否点「重新加载」(重载会丢弃当前未保存编辑)。

### 与其他查看器共存

| 查看器 | id | priority |
|---|---|---|
| 内置代码查看器 | `code` | -100 |
| 内置下载查看器 | `binary-download` | -50 |
| office 预览插件 | `docx` | 0 |
| OnlyOffice 插件 | `onlyoffice:docx` | 10 |
| **本插件** | `superdoc:docx` | 10 |

同优先级(如与 OnlyOffice)按注册顺序取胜。每个查看器都可在「设置 → 侧边卡片 → 文件预览」单独停用,互不影响。

## 技术架构

### 双端架构

```
浏览器(client 半,极小 CJS bundle,经 window.__ModuleLoader__ 注册)
  └─ ctx.betterSidebar.registerFileViewer('superdoc:docx', exts:['docx'], priority:10, fetchStrategy:'mediaUrl')
      └─ SuperDocView: <script src="/sidebar/superdoc/assets/superdoc.min.js">(暴露全局 `SuperDoc`)
          读:fetch(/sidebar/file?sessionId=&path=)        → Blob → new SuperDoc({ document: blob, contained: true })
          存:superdoc.export({triggerDownload:false}) → Blob → PUT /sidebar/superdoc/save?sessionId=&path=

Node(host 半,4 条带围栏的路由)
  ├─ GET  /sidebar/superdoc/info                    版本 / 健康检查 / 缓存种子
  ├─ GET  /sidebar/superdoc/assets/<file>           superdoc/dist-cdn(封闭白名单)
  ├─ GET  /sidebar/superdoc/engine/dist-cdn/<path>  @superdoc/docx-engine/dist-cdn 镜像(引擎 + worker)
  └─ PUT  /sidebar/superdoc/save                    原始 DOCX 字节 → 会话 cwd 内原子写回
```

- client 半只做三件事:注册查看器、经 better-sidebar 的 media 路由取文件字节、把 SuperDoc 实例挂进侧栏面板;
- host 半不跑任何文档逻辑,只负责**同源资产下发**与**带围栏的保存**;
- 挂载版本:`superdoc@2.10.0` + `@superdoc/docx-engine@0.9.0`(以 `package.json` 依赖为准,`info` 路由上报实际版本并兼作缓存种子)。

### 为什么需要引擎镜像

client 在脚本加载前设置 `globalThis.SUPERDOC_ENGINE_CDN_BASE_URL = '/sidebar/superdoc/engine'`,把 SuperDoc 的引擎解析指到本插件路由;引擎随后动态 import `…/dist-cdn/docx-engine.es.js`,其 web worker 也相对这个**同源** URL 解析 —— 浏览器不允许跨源创建 worker,否则 jsdelivr 会成为运行时依赖。这正是 host 半镜像整个 `dist-cdn` 目录的全部理由。

### 安全边界

- **信任围栏**(`src/trust-fence.ts`,与 better-sidebar 行为一致):Host 头必须是 loopback 或 `webRuntime.trustedHosts` 中的可信授权,`sec-fetch-site: cross-site` 与 Origin 不匹配一律拒绝 —— 防 DNS rebinding / 跨站请求,不是身份认证。
- **工作区围栏**(`src/paths.ts` + 保存路由):仅接受绝对路径;`isWithin` 做段级包含比较(`/a/bc` 不算在 `/a/b` 内);对父目录做 `realpath` 封闭符号链接逃逸;仅允许 `.docx`;请求体超过 `fileLimitMb` 返回 413;写入走 tmp + rename 原子替换。
- **资产白名单**(`src/assets.ts`):superdoc 构建只暴露 3 个文件的封闭白名单;引擎子路径做形状校验、拒绝 `.`/`..` 段、realpath 必须落在 `dist-cdn` 内且为普通文件。
- **无外泄**:遥测默认关闭(`telemetry: { enabled: false }`),插件自身不在磁盘保存任何状态。

## 开发细节和规范

### 目录结构

```
src/
  index.ts            宿主半:构建并注册 4 条路由(buildRoutes 纯函数,便于测试)
  assets.ts           node_modules 资产定位 / 白名单 / realpath 包含检查 / 内容类型
  config.ts           配置解析(fileLimitMb、allowOutsideWorkspace;纯 TS,零依赖)
  paths.ts            绝对路径要求 + 段级包含 + symlink 安全的父目录 realpath
  trust-fence.ts      浏览器信任围栏(复制而非 import 上游,插件不得依赖其内部)
  wire.ts             {ok,...} / {ok:false,error:{code,message}} JSON 形状 + 限长原始字节读取
  client/
    index.ts          客户端半:注册 superdoc:docx 查看器 + 挂载词典
    SuperDocView.tsx  编辑器组件(挂载 / 保存状态机 / 磁盘轮询 / fit-to-pane 缩放)
    loader.ts         运行时加载器(script/stylesheet 单例、引擎基址、contained 布局 CSS)
    settings.ts       读取打开模式(带校验,回退 editing)
    urls.ts           /sidebar/file 与保存路由的 URL 构造(对齐 better-sidebar 请求契约)
    i18n.ts / locales.ts / icons.tsx   zh/en 词典、注册与图标
tests/                vitest:routes / save-flow / viewers / trust-fence / locales
```

### 构建产物

- **host**:`lib/index.js`,ESM(es2023),运行时零第三方依赖;
- **client**:`lib/client.js` —— `window.__ModuleLoader__.load({ id, factory })` 注册的 CJS bundle,与 `dsh-sidebar-onlyoffice`、`dsh-web-search-aggregation` 相同的官方外置客户端投递形态;
- SuperDoc 编辑器本体**不打包**进 bundle:由 host 路由在运行时以经典 `<script>` 注入(与 onlyoffice 加载 `api.js` 的方式同构)。

### 客户端纯度门禁

`tsdown.config.ts` 内置 rolldown 插件,构建期直接报错:client bundle **不得** import 任何 Node 内建模块、不得值导入 `@deepseek-ai/*`;React / react-dom / cordis 作为 external 由宿主模块表提供。浏览器半必须自包含。

### 代码约定

- **不 import monorepo 内部类型**:宿主与客户端都定义结构化的 context faces(`RouteContext`、`ClientContextFace`),外部插件不得触达 monorepo 的 Context augmentation 图;
- 浏览器 JSON 一律 `{ok:...}` / `{ok:false,error:{code,message}}`(对齐 better-sidebar 的 wire 格式),错误码:`forbidden` / `method-error` / `bad-request` / `not-found` / `fs-error` / `internal`;
- viewer id 命名空间化(`superdoc:docx`),避免与内置及 `onlyoffice:docx` 冲突;priority 10 > 内置;`fetchStrategy: 'mediaUrl'`;
- 词典 zh / en 的 key 集合必须完全一致(locales 测试强制),注册在插件唯一的 `dshSidebarSuperdoc` 命名空间;
- 所有页面级注入幂等(stylesheet、布局 CSS、editor script 均为单例,重挂载安全);
- 保存路由是**唯一的** fs 写入面;better-sidebar 自带 `fs.write` 仅支持 UTF-8 文本,二进制导出必须走本路由。

### 测试

`pnpm test`(vitest run)覆盖:

| 文件 | 覆盖 |
|---|---|
| `routes.test.ts` | 4 条路由:白名单命中 / traversal 与符号链接拒绝 / 工作区围栏开与关 / 413 / 405 / 403 |
| `save-flow.test.ts` | 保存状态机:成功后清除未保存提示、保存中编辑保持未保存、头部按钮位置稳定 |
| `viewers.test.ts` | 查看器契约:id / exts / priority / fetchStrategy / 设置行,与既有 viewer 无 id 冲突 |
| `trust-fence.test.ts` | loopback 与可信授权通过;未知 Host / 跨站标记 / Origin 不匹配拒绝 |
| `locales.test.ts` | zh / en key 一致、值非空、命名空间唯一 |

### 常用命令

```sh
pnpm typecheck   # tsc --noEmit
pnpm test        # vitest run
pnpm build       # host ESM + client ModuleLoader bundle(纯度门禁强制)
```

## 已知局限

- 只支持 `.docx`(SuperDoc 不打开旧版 `.doc`);
- 有未保存修改时直接关 tab 无法拦截 —— 请留意 `●` 未保存圆点;
- better-sidebar 的 `workspaceFence` 关闭时可以*打开*工作区外的文件,但*保存*它们仍需本插件 `allowOutsideWorkspace: true`;
- 字体:SuperDoc 核心不带字体,文档以系统字体渲染(如需一致排版可后续接入 `@superdoc-dev/fonts`,本插件未含)。

## 许可证

本插件代码为 MIT;整合(未修改、随 `pnpm install` 安装并由路由原样下发)两个 SuperDoc 组件:

| 包 | 许可证 | 说明 |
|---|---|---|
| `superdoc` | **AGPL-3.0** | 未修改的 npm 产物;以网络服务形式提供时触发 AGPL 源码提供义务 |
| `@superdoc/docx-engine` | **专有许可**([DOCX Engine Proprietary License](https://docs.superdoc.dev/resources/docx-engine-license)) | 无商业协议时,仅可作为 SuperDoc 的依赖用于 AGPL 允许的用途(评估 / 开发 / 测试);商业使用需向 SuperDoc 购买授权 |

详见 [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md)。

## 致谢

- [SuperDoc](https://github.com/superdoc/docx-editor) by Harbour Enterprises —— 编辑器本体;
- [dsh-sidebar-onlyoffice](https://github.com/chendefine/dsh-sidebar-onlyoffice) —— 本包遵循的插件形态(运行时脚本注入、信任围栏、宿主路由)。
