├── .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 | 
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 |
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 |
--------------------------------------------------------------------------------