|
| 1 | +--- |
| 2 | +id: 214 |
| 3 | +title: Obsidian plugin via hand-rolled LSP bridge |
| 4 | +status: "🔲" |
| 5 | +model: opus |
| 6 | +summary: >- |
| 7 | + Ship a desktop-only Obsidian plugin under |
| 8 | + `editors/obsidian/` that spawns `mdsmith lsp`, drives a |
| 9 | + hand-rolled JSON-RPC client, and renders diagnostics |
| 10 | + plus quick-fixes inside Obsidian's CodeMirror 6 editor. |
| 11 | +depends-on: [121, 168] |
| 12 | +--- |
| 13 | +# Obsidian plugin via hand-rolled LSP bridge |
| 14 | + |
| 15 | +## Goal |
| 16 | + |
| 17 | +Surface mdsmith inside Obsidian. A writer opening a |
| 18 | +`.md` file sees inline squiggles. A "Fix" lightbulb |
| 19 | +applies the matching quick-fix. Fix-on-save runs |
| 20 | +`fixAll` on the active buffer. The plugin reuses |
| 21 | +the LSP server from |
| 22 | +[plan 121](121_vscode-integration.md) unchanged. |
| 23 | + |
| 24 | +## Background |
| 25 | + |
| 26 | +Plan 121 shipped `mdsmith lsp` and a VS Code client. |
| 27 | +Plan 168 added the `obsidian` convention. Together |
| 28 | +they cover the rules. The gap is the editor wiring. |
| 29 | + |
| 30 | +Obsidian runs on Electron. Plugins are bundled to |
| 31 | +one `main.js` plus `manifest.json`, loaded from |
| 32 | +`<vault>/.obsidian/plugins/<id>/`. Desktop plugins |
| 33 | +have Node access. So spawning `mdsmith lsp` via |
| 34 | +`child_process` works. No `vscode-languageclient` |
| 35 | +analog exists in the Obsidian ecosystem. The plugin |
| 36 | +hand-rolls JSON-RPC framing and dispatch. |
| 37 | + |
| 38 | +[Mobile Obsidian][mob] runs no Node. It cannot |
| 39 | +spawn binaries. The manifest sets |
| 40 | +`isDesktopOnly: true`. Mobile is the scope of |
| 41 | +[plan 215](215_obsidian-wasm-mobile.md). |
| 42 | + |
| 43 | +Ship via GitHub Release only. The zip holds |
| 44 | +`main.js`, `manifest.json`, and `styles.css`. No |
| 45 | +PR to [obsidian-releases][cp]. |
| 46 | + |
| 47 | +[mob]: https://help.obsidian.md/mobile |
| 48 | +[cp]: https://github.com/obsidianmd/obsidian-releases |
| 49 | + |
| 50 | +## Design |
| 51 | + |
| 52 | +### Layout |
| 53 | + |
| 54 | +`editors/obsidian/` mirrors `editors/vscode/`: |
| 55 | + |
| 56 | +```text |
| 57 | +editors/obsidian/ |
| 58 | + manifest.json |
| 59 | + package.json |
| 60 | + tsconfig.json |
| 61 | + build.ts |
| 62 | + src/ |
| 63 | + main.ts |
| 64 | + lsp-client.ts |
| 65 | + diagnostics.ts |
| 66 | + actions.ts |
| 67 | + settings.ts |
| 68 | + binary.ts |
| 69 | + *.test.ts |
| 70 | + styles.css |
| 71 | + README.md |
| 72 | +``` |
| 73 | + |
| 74 | +The build emits one CommonJS bundle to |
| 75 | +`dist/main.js`. Obsidian requires CommonJS. Static |
| 76 | +files are copied as-is. The release zip holds |
| 77 | +those three files plus the staged binaries. |
| 78 | + |
| 79 | +### Binary resolution |
| 80 | + |
| 81 | +Reuse the VS Code bundle path. The shared |
| 82 | +`@mdsmith/cli` shim maps host to target. `build.ts` |
| 83 | +copies `npm/mdsmith/bin/mdsmith.js` and stages each |
| 84 | +platform binary under `dist/cli/@mdsmith/<target>/`. |
| 85 | +`binary.ts` loads the shim and calls |
| 86 | +`resolveBinary(process.platform, process.arch, …)`. |
| 87 | + |
| 88 | +The resolver falls back to a `mdsmith.path` setting, |
| 89 | +then `$PATH`. The fallback notice points at the |
| 90 | +[releases page][rel]. |
| 91 | + |
| 92 | +[rel]: https://github.com/jeduden/mdsmith/releases |
| 93 | + |
| 94 | +### JSON-RPC client |
| 95 | + |
| 96 | +`lsp-client.ts` owns the JSON-RPC surface. It |
| 97 | +spawns the binary, frames messages with |
| 98 | +`Content-Length` headers, and dispatches replies |
| 99 | +by id. The framing is around 80 lines. No |
| 100 | +third-party package is used. |
| 101 | + |
| 102 | +The client exposes: |
| 103 | + |
| 104 | +- `spawn(binary, args, cwd)` and `kill()`. |
| 105 | +- `request(method, params): Promise<unknown>`. |
| 106 | +- `notify(method, params): void`. |
| 107 | +- `onNotification(method, handler)`. |
| 108 | +- Lifecycle: `initialize` → `initialized` → |
| 109 | + requests → `shutdown` → `exit`. |
| 110 | + |
| 111 | +Methods the plugin sends or receives: |
| 112 | + |
| 113 | +| Direction | Method | Why | |
| 114 | +| --------- | --------------------------------- | ---------------------------------------- | |
| 115 | +| → server | `initialize` | Handshake | |
| 116 | +| → server | `textDocument/didOpen` | Buffer opened | |
| 117 | +| → server | `textDocument/didChange` | Debounced edit | |
| 118 | +| → server | `textDocument/didSave` | Triggers fix | |
| 119 | +| → server | `textDocument/didClose` | Buffer closed | |
| 120 | +| → server | `textDocument/codeAction` | Quick fixes plus `source.fixAll.mdsmith` | |
| 121 | +| ← server | `textDocument/publishDiagnostics` | Squiggles | |
| 122 | +| ← server | `window/showMessage` | `new Notice(...)` | |
| 123 | + |
| 124 | +Hover, completion, rename, and navigation are |
| 125 | +deferred. Obsidian exposes those through different |
| 126 | +surfaces. They warrant their own plan. |
| 127 | + |
| 128 | +### Diagnostics in CodeMirror 6 |
| 129 | + |
| 130 | +Obsidian's source and live-preview editors both |
| 131 | +use [CodeMirror 6][cm6]. The plugin adds a CM6 |
| 132 | +`StateField<DecorationSet>`. It holds active |
| 133 | +diagnostics per file. Decorations apply a |
| 134 | +severity-themed underline. Classes live in |
| 135 | +`styles.css`. |
| 136 | + |
| 137 | +Hover uses `hoverTooltip` from `@codemirror/view`. |
| 138 | +The tooltip shows `code + message`. The footer |
| 139 | +holds a "Fix" link. The link runs the same |
| 140 | +code-action flow as the lightbulb. |
| 141 | + |
| 142 | +[cm6]: https://codemirror.net/ |
| 143 | + |
| 144 | +A "mdsmith Diagnostics" [`ItemView`][iv] lists |
| 145 | +workspace-wide diagnostics as a sortable table. |
| 146 | +Click jumps to the source location. |
| 147 | + |
| 148 | +[iv]: https://docs.obsidian.md/Plugins/User+interface/Views |
| 149 | + |
| 150 | +### Code actions and fix-on-save |
| 151 | + |
| 152 | +Obsidian has no lightbulb. Three surfaces stand in: |
| 153 | + |
| 154 | +1. The hover tooltip "Fix" link. |
| 155 | +2. Per-line palette commands. Each active |
| 156 | + diagnostic on the cursor line registers a |
| 157 | + transient `mdsmith: Fix — {code}` command. The |
| 158 | + set clears on cursor move. |
| 159 | +3. The `mdsmith: Fix file` command. It sends |
| 160 | + `textDocument/codeAction` filtered to kind |
| 161 | + `source.fixAll.mdsmith` and applies the |
| 162 | + returned `WorkspaceEdit` — mirroring VS Code. |
| 163 | + |
| 164 | +`fixOnSave` (off by default) debounces |
| 165 | +`vault.on('modify')` and triggers `Fix file` 200 ms |
| 166 | +after the last save. The command path is shared |
| 167 | +with the palette entry. |
| 168 | + |
| 169 | +### Settings |
| 170 | + |
| 171 | +A `PluginSettingTab` renders five controls: |
| 172 | + |
| 173 | +| Setting | Default | Purpose | |
| 174 | +| ------------- | ---------- | -------------------------- | |
| 175 | +| `binaryPath` | `""` | Override resolver | |
| 176 | +| `configPath` | `""` | Pass `-c` to the server | |
| 177 | +| `runMode` | `"onSave"` | `onType`/`onSave`/`off` | |
| 178 | +| `fixOnSave` | `false` | Run `fixAll` after save | |
| 179 | +| `traceServer` | `"off"` | `off`/`messages`/`verbose` | |
| 180 | + |
| 181 | +Settings round-trip via `loadData` and `saveData`. |
| 182 | +Changing `binaryPath` or `configPath` restarts the |
| 183 | +server. Changing `runMode` or `fixOnSave` |
| 184 | +reconfigures listeners without a restart. |
| 185 | + |
| 186 | +### Lifecycle |
| 187 | + |
| 188 | +`onload` reads settings, resolves the binary, |
| 189 | +spawns the server, registers the CM6 extension, |
| 190 | +registers commands and the diagnostics view, and |
| 191 | +attaches vault listeners. `onunload` sends |
| 192 | +`shutdown` and `exit`, kills the child after 1 s, |
| 193 | +disposes views, and removes listeners. A |
| 194 | +`mdsmith: Restart server` command exists for the |
| 195 | +same reason VS Code has one. |
| 196 | + |
| 197 | +### Build, test, release |
| 198 | + |
| 199 | +`bun run build.ts --production` writes `dist/`, |
| 200 | +stages the platform binaries, and zips the |
| 201 | +artifact. CI runs `bun test`, then the build, |
| 202 | +then attaches the zip to the GitHub Release. |
| 203 | +The release pipeline picks it up the same way it |
| 204 | +picks up the `.vsix`. |
| 205 | + |
| 206 | +### Docs |
| 207 | + |
| 208 | +A new `docs/guides/editors/obsidian.md` covers |
| 209 | +install, settings, and troubleshooting. Update the |
| 210 | +[linter comparison][lc] to cite the new plugin in |
| 211 | +its Obsidian row. Add a note to the |
| 212 | +[conventions reference][conv] that |
| 213 | +`convention: obsidian` pairs with this plugin. |
| 214 | +Mention the artifact in [github-releases.md][gh]. |
| 215 | + |
| 216 | +[lc]: ../docs/background/markdown-linters.md |
| 217 | +[conv]: ../docs/reference/conventions.md |
| 218 | +[gh]: ../docs/development/release-channels/github-releases.md |
| 219 | + |
| 220 | +## Tasks |
| 221 | + |
| 222 | +1. Scaffold `editors/obsidian/`: `package.json`, |
| 223 | + `tsconfig.json`, `manifest.json` with |
| 224 | + `isDesktopOnly: true`, `build.ts`, a stub |
| 225 | + `src/main.ts` extending `Plugin`, and a |
| 226 | + `README.md` that passes default rules. |
| 227 | +2. Implement `lsp-client.ts`. Cover framing, |
| 228 | + request correlation, notification fan-out, and |
| 229 | + the `initialize`/`shutdown` cycle. Unit-test |
| 230 | + against an in-process `Duplex`. |
| 231 | +3. Implement `binary.ts`. Load the `@mdsmith/cli` |
| 232 | + shim. Fall back to setting, then `$PATH`. Match |
| 233 | + the test surface of the VS Code module. |
| 234 | +4. Implement `diagnostics.ts`. Add the CM6 |
| 235 | + `StateField`, the effect type, and a |
| 236 | + `hoverTooltip` provider rendering code, message, |
| 237 | + and a Fix link. |
| 238 | +5. Implement `actions.ts`. Add per-line palette |
| 239 | + commands from active diagnostics, the |
| 240 | + `Fix file` command via `executeCommand`, and the |
| 241 | + debounced `vault.on('modify')` handler. |
| 242 | +6. Implement `settings.ts`. Wire the five controls, |
| 243 | + the `loadData`/`saveData` round-trip, and the |
| 244 | + restart-on-change for `binaryPath` and |
| 245 | + `configPath`. |
| 246 | +7. Wire `main.ts`. `onload` spawns the server, |
| 247 | + registers the CM6 extension, the diagnostics |
| 248 | + view, the commands, and the settings tab. |
| 249 | + `onunload` cleans up. |
| 250 | +8. Add `styles.css` for severity underlines and |
| 251 | + tooltip styling. |
| 252 | +9. Add a `.github/workflows/` step that builds the |
| 253 | + plugin and uploads the zip as a release |
| 254 | + artifact, mirroring the existing `vscode` job. |
| 255 | +10. Write `docs/guides/editors/obsidian.md`. |
| 256 | + Update the conventions reference, the |
| 257 | + linter-comparison page, and the GitHub |
| 258 | + Releases page. |
| 259 | +11. Run `mdsmith fix .` and confirm `mdsmith check |
| 260 | + .` passes against the updated `PLAN.md`. |
| 261 | + |
| 262 | +## Acceptance Criteria |
| 263 | + |
| 264 | +- [ ] `editors/obsidian/` builds with `bun run |
| 265 | + build.ts --production`. The output is |
| 266 | + `dist/main.js`, `manifest.json`, and |
| 267 | + `styles.css`. |
| 268 | +- [ ] `bun test` passes. The suite covers framing, |
| 269 | + binary resolution, diagnostics decoration, |
| 270 | + and settings round-trip. |
| 271 | +- [ ] Loading the plugin in a vault that holds an |
| 272 | + `MDS001` violation shows a wavy underline |
| 273 | + within 500 ms of opening the file. Manual |
| 274 | + smoke step. |
| 275 | +- [ ] The hover tooltip shows the rule code and |
| 276 | + message. The "Fix" link applies the |
| 277 | + quick-fix. |
| 278 | +- [ ] `mdsmith: Fix file` produces the same buffer |
| 279 | + as `mdsmith fix` on the same input. |
| 280 | +- [ ] Toggling `fixOnSave: true` runs `Fix file` |
| 281 | + after each save without a plugin restart. |
| 282 | +- [ ] Editing `.mdsmith.yml` re-lints open files |
| 283 | + without a restart. The |
| 284 | + `didChangeWatchedFiles` event is forwarded. |
| 285 | +- [ ] `manifest.json` has `isDesktopOnly: true`. |
| 286 | + Mobile Obsidian treats the plugin as absent, |
| 287 | + not as a crash. |
| 288 | +- [ ] CI attaches `mdsmith-obsidian-<version>.zip` |
| 289 | + to the release artifacts. |
| 290 | +- [ ] `docs/guides/editors/obsidian.md` exists. |
| 291 | + The linter-comparison page cites the new |
| 292 | + plugin in its Obsidian row. |
| 293 | +- [ ] `mdsmith check .` passes against the |
| 294 | + updated `PLAN.md`. |
| 295 | + |
| 296 | +## Non-Goals |
| 297 | + |
| 298 | +- LSP hover, completion, rename, and symbol |
| 299 | + navigation. Each goes in its own follow-up. |
| 300 | +- Mobile support. That is plan 215. |
| 301 | +- Submission to the Obsidian Community Plugins |
| 302 | + catalog. The chosen channel is GitHub Releases. |
| 303 | +- Live-preview rendering changes. |
| 304 | +- New rule bindings beyond what the `obsidian` |
| 305 | + convention activates. |
| 306 | + |
| 307 | +## See also |
| 308 | + |
| 309 | +- [Plan 121: VS Code via LSP](121_vscode-integration.md) |
| 310 | +- [Plan 215: WASM for mobile](215_obsidian-wasm-mobile.md) |
| 311 | +- [Linter comparison][lc] |
0 commit comments