├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .husky └── pre-push ├── .npmrc ├── LICENSE ├── README.md ├── babel.config.cjs ├── package.json ├── pnpm-lock.yaml ├── rollup.config.mjs ├── src ├── bridge.ts └── index.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | quote_type = single 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /* 2 | !/src 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [require.resolve('@gera2ld/plaid/eslint')], 3 | rules: {}, 4 | }; 5 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npmjs 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - uses: pnpm/action-setup@v4 15 | name: Install pnpm 16 | with: 17 | version: 9 18 | run_install: false 19 | 20 | - name: Install Node.js 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | cache: 'pnpm' 25 | registry-url: 'https://registry.npmjs.org' 26 | 27 | - run: pnpm i && pnpm publish --no-git-checks 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | /.idea 4 | /dist 5 | /.nyc_output 6 | /coverage 7 | /types 8 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist = true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Gerald 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coc-markmap 2 | 3 | ![NPM](https://img.shields.io/npm/v/coc-markmap.svg) 4 | 5 | Visualize your Markdown as mindmaps with [markmap](https://markmap.js.org/). 6 | 7 | This is an extension for [coc.nvim](https://github.com/neoclide/coc.nvim). 8 | 9 | If you prefer a CLI version, see [markmap-cli](https://markmap.js.org/docs/packages--markmap-cli). 10 | 11 | Note: _coc-markmap_ uses _markmap-cli_ under the hood, and supports more features by connecting the Markmap with the current buffer, such as highlighting the node under cursor. 12 | 13 | markdown mindmap 14 | 15 | ## Installation 16 | 17 | First, make sure [coc.nvim](https://github.com/neoclide/coc.nvim) is started. 18 | 19 | Then install with the Vim command: 20 | 21 | ``` 22 | :CocInstall coc-markmap 23 | ``` 24 | 25 | ## Usage 26 | 27 | You can run the commands below **in a buffer of Markdown file**. 28 | 29 | ### Generating a markmap HTML 30 | 31 | ```viml 32 | :CocCommand markmap.create 33 | ``` 34 | 35 | Inline all assets to work offline: 36 | 37 | ```viml 38 | :CocCommand markmap.create --offline 39 | ``` 40 | 41 | **This command will create an HTML file rendering the markmap and can be easily shared.** 42 | 43 | The HTML file will have the same basename as the Markdown file and will be opened in your default browser. If there is a selection, it will be used instead of the file content. 44 | 45 | ### Watching mode 46 | 47 | ```viml 48 | :CocCommand markmap.watch 49 | ``` 50 | 51 | **This command will start a development server, watch the current buffer and track your cursor.** 52 | 53 | The markmap will update once the markdown file changes, and the node under cursor will always be visible in the viewport on cursor move. 54 | 55 | ```viml 56 | :CocCommand markmap.unwatch 57 | ``` 58 | 59 | **The command will unwatch the current buffer.** 60 | 61 | ## Configurations 62 | 63 | ### CocConfig 64 | 65 | You can change some global configurations for this extension in `coc-settings.json`. 66 | 67 | First open the settings file with `:CocConfig`. 68 | 69 | ### Key mappings 70 | 71 | There is no default key mapping, but you can easily add your own: 72 | 73 | ```viml 74 | " Create markmap from the whole file 75 | nmap m (coc-markmap-create) 76 | ``` 77 | 78 | ### Commands 79 | 80 | It is also possible to add a command to create markmaps. 81 | 82 | ```viml 83 | command! -range=% Markmap CocCommand markmap.create 84 | ``` 85 | 86 | Now you have the `:Markmap` command to create a Markmap, either from the whole file or selected lines. 87 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@babel/preset-env', '@babel/preset-typescript'], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coc-markmap", 3 | "version": "0.8.0", 4 | "description": "Visualize your Markdown as mindmaps with Markmap", 5 | "author": "Gerald ", 6 | "license": "MIT", 7 | "scripts": { 8 | "prepare": "husky install", 9 | "dev": "rollup -cw", 10 | "clean": "del-cli dist", 11 | "prepublishOnly": "run-s build", 12 | "ci": "run-s lint", 13 | "build:js": "rollup -c", 14 | "build": "run-s ci clean build:js", 15 | "lint": "eslint --ext .ts . && prettier -c src", 16 | "lint:fix": "eslint --ext .ts . --fix && prettier -c src -w" 17 | }, 18 | "publishConfig": { 19 | "access": "public", 20 | "registry": "https://registry.npmjs.org/" 21 | }, 22 | "main": "dist/index.js", 23 | "files": [ 24 | "dist" 25 | ], 26 | "devDependencies": { 27 | "@gera2ld/plaid": "~2.7.0", 28 | "@gera2ld/plaid-rollup": "~2.7.0", 29 | "@types/node": "^20.11.17", 30 | "coc.nvim": "0.0.83-next.9", 31 | "del-cli": "^6.0.0", 32 | "es-toolkit": "^1.31.0", 33 | "husky": "^9.1.7" 34 | }, 35 | "dependencies": { 36 | "markmap-cli": "0.18.7", 37 | "open": "^10.1.0" 38 | }, 39 | "engines": { 40 | "coc": ">=0.0.80", 41 | "node": ">=18" 42 | }, 43 | "keywords": [ 44 | "coc.nvim", 45 | "markmap" 46 | ], 47 | "activationEvents": [ 48 | "onLanguage:markdown" 49 | ], 50 | "contributes": { 51 | "configuration": { 52 | "title": "coc-markmap", 53 | "properties": {} 54 | } 55 | }, 56 | "repository": "git@github.com:gera2ld/coc-markmap.git", 57 | "browserslist": [ 58 | "node >= 18" 59 | ] 60 | } 61 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineExternal, definePlugins } from '@gera2ld/plaid-rollup'; 2 | import { defineConfig } from 'rollup'; 3 | import pkg from './package.json' with { type: 'json' }; 4 | 5 | export default defineConfig([ 6 | { 7 | input: './src/index.ts', 8 | plugins: definePlugins({ 9 | esm: true, 10 | }), 11 | external: defineExternal(['coc.nvim', ...Object.keys(pkg.dependencies)]), 12 | output: { 13 | format: 'cjs', 14 | dir: 'dist', 15 | }, 16 | }, 17 | { 18 | input: './src/bridge.ts', 19 | plugins: definePlugins({ 20 | esm: true, 21 | }), 22 | external: defineExternal(['coc.nvim', ...Object.keys(pkg.dependencies)]), 23 | output: { 24 | format: 'es', 25 | dir: 'dist', 26 | }, 27 | }, 28 | ]); 29 | -------------------------------------------------------------------------------- /src/bridge.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from 'crypto'; 2 | import { 3 | MarkmapDevServer, 4 | config, 5 | createMarkmap, 6 | develop, 7 | fetchAssets, 8 | } from 'markmap-cli'; 9 | import open from 'open'; 10 | 11 | let devServer: MarkmapDevServer | undefined; 12 | 13 | type MaybePromise = T | Promise; 14 | 15 | const handlers: Record MaybePromise> = { 16 | initialize(options: { assetsDir: string }) { 17 | config.assetsDir = options.assetsDir; 18 | }, 19 | async createMarkmap(options: Record) { 20 | await fetchAssets(); 21 | await createMarkmap({ 22 | open: true, 23 | toolbar: true, 24 | offline: false, 25 | ...options, 26 | }); 27 | }, 28 | async startServer(options?: Record) { 29 | if (!devServer) { 30 | await fetchAssets(); 31 | devServer = await develop({ 32 | toolbar: true, 33 | offline: true, 34 | ...options, 35 | }); 36 | } 37 | return ( 38 | devServer.serverInfo && { 39 | port: devServer.serverInfo.address.port, 40 | } 41 | ); 42 | }, 43 | addProvider(filePath: string) { 44 | const key = createHash('sha256') 45 | .update(filePath, 'utf8') 46 | .digest('hex') 47 | .slice(0, 7); 48 | const provider = invariant(devServer).addProvider({ key }); 49 | return provider.key; 50 | }, 51 | delProvider(key: string) { 52 | invariant(devServer).delProvider(key); 53 | }, 54 | setContent(data: { key: string; content: string }) { 55 | const provider = invariant(devServer?.providers[data.key]); 56 | provider.setContent(data.content); 57 | }, 58 | setCursor(data: { key: string; line: number }) { 59 | const provider = invariant(devServer?.providers[data.key]); 60 | provider.setCursor(data.line); 61 | }, 62 | stopServer() { 63 | if (!devServer) return; 64 | devServer.shutdown(); 65 | devServer = undefined; 66 | }, 67 | openUrl(url: string) { 68 | open(url); 69 | }, 70 | }; 71 | 72 | process.on( 73 | 'message', 74 | async ({ id, cmd, data }: { id: number; cmd: string; data: unknown }) => { 75 | const handler = handlers[cmd]; 76 | let result: unknown; 77 | let error: string | undefined; 78 | try { 79 | result = await handler?.(data); 80 | } catch (err) { 81 | error = `${err}`; 82 | } 83 | process.send?.({ 84 | id, 85 | cmd: '_setResult', 86 | data: { result, error }, 87 | }); 88 | }, 89 | ); 90 | 91 | function invariant(input: T | undefined, message?: string): T { 92 | if (!input) throw new Error(message || 'input is required'); 93 | return input; 94 | } 95 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Disposable, 3 | ExtensionContext, 4 | Logger, 5 | commands, 6 | events, 7 | window, 8 | workspace, 9 | } from 'coc.nvim'; 10 | import { spawn } from 'node:child_process'; 11 | import { basename, extname, resolve } from 'node:path'; 12 | // Note: only CJS is supported by coc.nvim, so we must bundle it 13 | import { debounce } from 'es-toolkit'; 14 | 15 | class CocMarkmapBridge { 16 | private _child = spawn(process.execPath, [resolve(__dirname, 'bridge.js')], { 17 | cwd: __dirname, 18 | stdio: ['inherit', 'inherit', 'inherit', 'ipc'], 19 | }); 20 | 21 | serverInfo: { port: number } | undefined; 22 | 23 | id = 0; 24 | 25 | private _callbacks: Record< 26 | number, 27 | (data: { result: unknown; error?: string }) => void 28 | > = {}; 29 | 30 | private _connectedBuffers: Record = {}; 31 | 32 | private _disposables: Disposable[] = []; 33 | 34 | constructor(private logger: Logger) { 35 | this._child.on( 36 | 'message', 37 | (message: { 38 | id: number; 39 | cmd: string; 40 | data: { result: unknown; error?: string }; 41 | }) => { 42 | this._callbacks[message.id]?.(message.data); 43 | delete this._callbacks[message.id]; 44 | }, 45 | ); 46 | this._disposables.push(Disposable.create(() => this.stopServer())); 47 | this._disposables.push(events.on('TextChanged', this.handleTextChange)); 48 | this._disposables.push(events.on('TextChangedI', this.handleTextChange)); 49 | this._disposables.push(events.on('CursorMoved', this.handleCursorChange)); 50 | this._disposables.push(events.on('CursorMovedI', this.handleCursorChange)); 51 | } 52 | 53 | private _send(cmd: string, data?: unknown): Promise { 54 | this.id += 1; 55 | this._child.send({ id: this.id, cmd, data }); 56 | return new Promise((resolve, reject) => { 57 | this._callbacks[this.id] = (data) => { 58 | if (data.error) reject(data.error); 59 | else resolve(data.result as T); 60 | }; 61 | }); 62 | } 63 | 64 | initialize(assetsDir: string) { 65 | return this._send('initialize', { assetsDir }); 66 | } 67 | 68 | destroy() { 69 | this._child.kill(); 70 | } 71 | 72 | isServerStarted() { 73 | return !!this.serverInfo; 74 | } 75 | 76 | async startServer() { 77 | this.serverInfo = await this._send('startServer'); 78 | } 79 | 80 | async stopServer() { 81 | if (!this.serverInfo) return; 82 | await this._send('stopServer'); 83 | this.serverInfo = undefined; 84 | } 85 | 86 | async setContent(key: string, content: string) { 87 | await this._send('setContent', { key, content }); 88 | } 89 | 90 | async setCursor(key: string, line: number) { 91 | await this._send('setCursor', { key, line }); 92 | } 93 | 94 | async connectBuffer() { 95 | await this.startServer(); 96 | const { nvim } = workspace; 97 | const buffer = await nvim.buffer; 98 | const filePath = (await nvim.eval('expand("%:p")')) as string; 99 | const filename = basename(filePath); 100 | const key = 101 | this._connectedBuffers[buffer.id] || 102 | (await this._send('addProvider', filePath)); 103 | this._connectedBuffers[buffer.id] = key; 104 | this.handleTextChange(buffer.id); 105 | const url = `http://localhost:${this.serverInfo?.port}/?key=${key}&filename=${encodeURIComponent(filename)}`; 106 | window.showInformationMessage( 107 | `Buffer ${buffer.id}: Markmap is served at ${url}`, 108 | ); 109 | this._send('openUrl', url); 110 | } 111 | 112 | async disconnectBuffer() { 113 | const { nvim } = workspace; 114 | const buffer = await nvim.buffer; 115 | const key = this._connectedBuffers[buffer.id]; 116 | if (key) { 117 | await this._send('delProvider', key); 118 | delete this._connectedBuffers[buffer.id]; 119 | window.showInformationMessage(`Buffer ${buffer.id}: Markmap is disposed`); 120 | } 121 | } 122 | 123 | async createMarkmap(options?: Record) { 124 | const { nvim } = workspace; 125 | const filePath = (await nvim.eval('expand("%:p")')) as string; 126 | const name = basename(filePath, extname(filePath)); 127 | const output = resolve(`${name}.html`); 128 | const doc = await workspace.document; 129 | const content = doc.textDocument.getText(); 130 | const createOptions = { 131 | content, 132 | output, 133 | ...options, 134 | }; 135 | await this._send('createMarkmap', createOptions); 136 | } 137 | 138 | private _bufferIds = new Set(); 139 | 140 | private _updateContents = debounce(async () => { 141 | const { nvim } = workspace; 142 | const buffers = await nvim.buffers; 143 | const matchedBuffers = buffers.filter((buffer) => 144 | this._bufferIds.has(buffer.id), 145 | ); 146 | this._bufferIds.clear(); 147 | for (const buffer of matchedBuffers) { 148 | const key = this._connectedBuffers[buffer.id]; 149 | if (!key) continue; 150 | const lines = await buffer.getLines(); 151 | await this.setContent(key, lines.join('\n')); 152 | } 153 | this.logger.info('Content updated'); 154 | }, 500); 155 | 156 | handleTextChange = (bufferId: number) => { 157 | if (!this._connectedBuffers[bufferId]) return; 158 | this.logger.info(`Buffer ${bufferId}: text change`); 159 | this._bufferIds.add(bufferId); 160 | this._updateContents(); 161 | }; 162 | 163 | handleCursorChange = debounce(async () => { 164 | const { nvim } = workspace; 165 | const buffer = await nvim.buffer; 166 | const key = this._connectedBuffers[buffer.id]; 167 | if (!key) return; 168 | this.logger.info('Cursor change:', events.cursor.lnum); 169 | await this._send('setCursor', { key, line: events.cursor.lnum - 1 }); 170 | }, 300); 171 | } 172 | 173 | export function activate(context: ExtensionContext) { 174 | // const config = workspace.getConfiguration('markmap'); 175 | const { logger, storagePath } = context; 176 | const loading = (async () => { 177 | logger.info('Initialize bridge...'); 178 | const bridge = new CocMarkmapBridge(logger); 179 | await bridge.initialize(storagePath); 180 | logger.info('Bridge loaded'); 181 | return bridge; 182 | })(); 183 | 184 | context.subscriptions.push( 185 | workspace.registerKeymap( 186 | ['n'], 187 | 'markmap-create', 188 | async () => { 189 | const bridge = await loading; 190 | await bridge.createMarkmap(); 191 | }, 192 | { sync: false }, 193 | ), 194 | ); 195 | 196 | context.subscriptions.push( 197 | commands.registerCommand('markmap.create', async (...args: string[]) => { 198 | const options = { 199 | offline: args.includes('--offline'), 200 | }; 201 | const bridge = await loading; 202 | await bridge.createMarkmap(options); 203 | }), 204 | ); 205 | 206 | context.subscriptions.push( 207 | commands.registerCommand('markmap.watch', async () => { 208 | const bridge = await loading; 209 | await bridge.connectBuffer(); 210 | }), 211 | ); 212 | 213 | context.subscriptions.push( 214 | commands.registerCommand('markmap.unwatch', async () => { 215 | const bridge = await loading; 216 | await bridge.disconnectBuffer(); 217 | }), 218 | ); 219 | 220 | context.subscriptions.push( 221 | commands.registerCommand('markmap.stop', async () => { 222 | const bridge = await loading; 223 | await bridge.stopServer(); 224 | }), 225 | ); 226 | } 227 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "Node", 6 | "outDir": "dist", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "strictNullChecks": true, 10 | "skipLibCheck": true 11 | }, 12 | "include": [ 13 | "src/**/*" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------