├── .editorconfig ├── .github └── workflows │ └── CI.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── bin └── lsp.js ├── eslint.config.js ├── lib ├── core │ ├── completions.js │ ├── indexer.js │ ├── linter.js │ ├── logger.js │ ├── markdown │ │ ├── extract-metadata.js │ │ ├── remark.js │ │ ├── tags.js │ │ └── tags │ │ │ ├── index.d.ts │ │ │ ├── mdast.js │ │ │ └── micromark.js │ ├── markmark.js │ ├── processor.js │ ├── references.js │ ├── types.d.ts │ ├── util.js │ ├── watcher.js │ └── workqueue.js ├── index.js └── language-server │ ├── language-server.js │ └── logger.js ├── package-lock.json ├── package.json ├── renovate.json ├── test ├── fixtures │ ├── completions │ │ ├── ANCHOR.md │ │ ├── BASE.md │ │ ├── HEADING.md │ │ └── TAGGED.md │ ├── linter │ │ ├── EXTERNAL.md │ │ ├── IMG.md │ │ ├── README.md │ │ └── nested │ │ │ └── README.md │ ├── notes │ │ ├── .markmarkrc │ │ ├── IDEAS.md │ │ ├── NOTES.md │ │ └── ideas │ │ │ ├── PUNCH_LINE.md │ │ │ └── nested │ │ │ └── NESTED_IDEAS.md │ └── special │ │ ├── EXTERNAL.md │ │ ├── IMG.md │ │ └── img.png └── spec │ └── core │ ├── completions.spec.js │ ├── helper.js │ ├── indexer.spec.js │ ├── linter.results.README.json │ ├── linter.spec.js │ ├── markdown.parseTree.LINKS.json │ ├── markdown.spec.js │ ├── markmark.spec.js │ ├── references.parseTree.EXTERNAL.json │ ├── references.parseTree.IDEAS.json │ ├── references.parseTree.IMG.json │ ├── references.parseTree.NOTES.json │ ├── references.spec.js │ └── watcher.spec.js └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | jobs: 4 | Build: 5 | 6 | strategy: 7 | matrix: 8 | os: [ ubuntu-latest ] 9 | 10 | runs-on: ${{ matrix.os }} 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | - name: Use Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'npm' 20 | - name: Install dependencies 21 | run: npm ci 22 | - name: Build 23 | run: npm run all 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to [markmark](https://github.com/nikku/markmark) are documented here. We use [semantic versioning](http://semver.org/) for releases. 4 | 5 | ## Unreleased 6 | 7 | ___Note:__ Yet to be released changes appear here._ 8 | 9 | ## 0.5.1 10 | 11 | * `FIX`: keep locally open file representation when unwatched ([#12](https://github.com/nikku/markmark/pull/12)) 12 | * `FIX`: remove files when unwatching folder ([#11](https://github.com/nikku/markmark/pull/11)) 13 | 14 | ## 0.5.0 15 | 16 | * `FEAT`: improve general robustness 17 | * `FIX`: improve node search (during completion) 18 | * `DEPS`: update to `chokidar@4.0.3` 19 | * `DEPS`: update `remark*` 20 | 21 | ## 0.4.0 22 | 23 | * `DEPS`: bump to `chokidar@4` 24 | 25 | ## 0.3.0 26 | 27 | * `DEPS`: update internal parsing libraries 28 | * `DEPS`: update language server dependencies 29 | 30 | ## 0.2.1 31 | 32 | * `FIX`: correctly reset invalid language server diagnostics 33 | 34 | ## 0.2.0 35 | 36 | * `FEAT`: validate Markdown links 37 | 38 | ## 0.1.0 39 | 40 | * `FEAT`: add client side file watching support 41 | * `FEAT`: list references to external resources 42 | * `FEAT`: support goto definition for external resources 43 | * `FIX`: handle non-existing files 44 | 45 | ## 0.0.1 46 | 47 | _Initial release._ 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2022 Nico Rehwaldt 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # markmark 2 | 3 | [![CI](https://github.com/nikku/markmark/actions/workflows/CI.yml/badge.svg)](https://github.com/nikku/markmark/actions/workflows/CI.yml) 4 | 5 | [Markdown](https://en.wikipedia.org/wiki/Markdown) language tooling. Use standalone or plug-in as a [language server](https://microsoft.github.io/language-server-protocol/) into your favorite IDE. 6 | 7 | 8 | ## Features 9 | 10 | Language tooling and code intelligence for [Markdown](https://en.wikipedia.org/wiki/Markdown) files: 11 | 12 | * [x] Go to definition 13 | * [x] Find references 14 | * [x] Complete links and tags 15 | * [X] Validate links 16 | 17 | Scalable across many Markdown files: 18 | 19 | * [x] Project awareness 20 | * [x] Built in or external file watching support 21 | 22 | Exposed as a [language server](#language-server), but also usable [standalone](#standalone). 23 | 24 | 25 | ## Installation 26 | 27 | ```sh 28 | npm install -g markmark 29 | ``` 30 | 31 | 32 | ## Usage 33 | 34 | ### Language Server 35 | 36 | Start using `markmark-lsp` binary (depends on the [language server protocol](https://microsoft.github.io/language-server-protocol/) integration of your editor): 37 | 38 | ``` 39 | markmark-lsp --stdio 40 | ``` 41 | 42 | 43 | ### Standalone 44 | 45 | Instantiate `markmark` yourself to integrate it into your applications: 46 | 47 | ```javascript 48 | import { Markmark } from 'markmark'; 49 | 50 | const markmark = new Markmark(console); 51 | 52 | // intialize 53 | markmark.init({ watch: true }); 54 | 55 | // add a search root 56 | markmark.addRoot('file:///some-folder'); 57 | 58 | // listen on 59 | markmark.on('ready', () => { 60 | console.log('Markmark is ready!'); 61 | }); 62 | 63 | // find references at position 64 | const refs = markmark.findReferences({ 65 | uri: 'file:///some-folder/foo.md', 66 | position: { 67 | start: { line: 1, column: 5 }, 68 | end: { line: 1, column: 5 } 69 | } 70 | }); 71 | 72 | // find definitions at document position 73 | const defs = markmark.findDefinitions({ 74 | uri: 'file:///some-folder/foo.md', 75 | position: { 76 | start: { line: 1, column: 5 }, 77 | end: { line: 1, column: 5 } 78 | } 79 | }); 80 | ``` 81 | 82 | 83 | ## License 84 | 85 | MIT 86 | -------------------------------------------------------------------------------- /bin/lsp.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import process from 'node:process'; 4 | 5 | import { createLanguageServer } from '../lib/index.js'; 6 | 7 | process.title = 'markmark-language-server'; 8 | 9 | createLanguageServer(); 10 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import bpmnIoPlugin from 'eslint-plugin-bpmn-io'; 2 | 3 | const files = { 4 | test: [ 5 | 'test/**/*.js' 6 | ] 7 | }; 8 | 9 | export default [ 10 | 11 | // all files 12 | ...bpmnIoPlugin.configs.node, 13 | 14 | // test 15 | ...bpmnIoPlugin.configs.mocha.map(config => { 16 | 17 | return { 18 | ...config, 19 | files: files.test 20 | }; 21 | }) 22 | ]; 23 | -------------------------------------------------------------------------------- /lib/core/completions.js: -------------------------------------------------------------------------------- 1 | import { visit, EXIT, SKIP } from 'unist-util-visit'; 2 | 3 | import { location } from 'vfile-location'; 4 | 5 | import path from 'node:path/posix'; 6 | 7 | import { 8 | fileURLToPath 9 | } from 'node:url'; 10 | 11 | import { 12 | inRange, 13 | real, 14 | isParent 15 | } from './util.js'; 16 | 17 | 18 | /** 19 | * @typedef { import('./types.js').Completion } Completion 20 | * @typedef { import('./types.js').DocumentLocation } DocumentLocation 21 | * 22 | * @typedef { import('./types.js').IndexItem } IndexItem 23 | * 24 | * @typedef { import('./types.js').Node } Node 25 | * @typedef { import('./types.js').Point } Point 26 | */ 27 | 28 | 29 | export default class Completions { 30 | 31 | /** 32 | * @param { import('./logger.js').default } logger 33 | * @param { import('./indexer.js').default } indexer 34 | * @param { import('./references.js').default } references 35 | */ 36 | constructor(logger, indexer, references) { 37 | this._logger = logger; 38 | this._indexer = indexer; 39 | this._references = references; 40 | } 41 | 42 | /** 43 | * @param { DocumentLocation } ref 44 | * 45 | * @return { Promise } completions 46 | */ 47 | async get(ref) { 48 | 49 | const { 50 | uri, 51 | position 52 | } = ref; 53 | 54 | const indexItem = await this._indexer.get(uri); 55 | 56 | if (!indexItem) { 57 | return []; 58 | } 59 | 60 | const node = findNode(indexItem.parseTree, position); 61 | 62 | if (!node) { 63 | return []; 64 | } 65 | 66 | if ( 67 | node.type === 'text' || 68 | node.type === 'tag' || 69 | node.type === 'heading' 70 | ) { 71 | return this._completeTag(indexItem, node, position); 72 | } 73 | 74 | if ( 75 | node.type === 'link' 76 | ) { 77 | return this._completeRef(indexItem, node, position); 78 | } 79 | 80 | 81 | this._logger.log('cannot complete node', node); 82 | 83 | return []; 84 | } 85 | 86 | /** 87 | * @param { IndexItem } indexItem 88 | * @param { Node } node 89 | * @param { Point } point 90 | * 91 | * @return { Completion[] } completions 92 | */ 93 | _completeTag(indexItem, node, point) { 94 | 95 | const value = String(indexItem.value); 96 | 97 | const loc = location(value); 98 | 99 | // complete after <#> 100 | if (node.type === 'text' || node.type === 'heading') { 101 | const offset = loc.toOffset(point); 102 | 103 | if (offset === undefined || value.charAt(offset - 1) !== '#') { 104 | return []; 105 | } 106 | 107 | return this._findTags(indexItem, '').map(tag => { 108 | return { 109 | label: '#' + tag, 110 | replace: { 111 | position: { 112 | start: real(loc.toPoint(offset - 1)), 113 | end: real(loc.toPoint(offset)) 114 | }, 115 | newText: '#' + tag 116 | } 117 | }; 118 | }); 119 | } 120 | 121 | // complete in <#tag> 122 | if (node.type === 'tag') { 123 | const nodeOffset = loc.toOffset(node.position?.start); 124 | const startOffset = loc.toOffset(point); 125 | 126 | if (nodeOffset === undefined || startOffset === undefined) { 127 | return []; 128 | } 129 | 130 | const prefix = value.substring(nodeOffset + 1, startOffset); 131 | 132 | return this._findTags(indexItem, prefix).map(tag => { 133 | return { 134 | label: '#' + tag, 135 | replace: { 136 | position: real(node.position), 137 | newText: '#' + tag 138 | } 139 | }; 140 | }); 141 | } 142 | 143 | return []; 144 | } 145 | 146 | /** 147 | * @param { IndexItem } indexItem 148 | * @param { string } prefix 149 | * 150 | * @return { string[] } tags 151 | */ 152 | _findTags(indexItem, prefix) { 153 | const roots = this._indexer.getRoots(); 154 | const root = findRoot(indexItem.uri, roots); 155 | 156 | return this._references.getTags() 157 | .filter(tag => tag.name !== prefix && tag.name.includes(prefix)) 158 | .filter(tag => Array.from(tag.references).some(ref => root === findRoot(ref.uri, roots))) 159 | .map(tag => tag.name); 160 | } 161 | 162 | /** 163 | * @param { IndexItem } indexItem 164 | * @param { Node } node 165 | * @param { Point } point 166 | * 167 | * @return { Completion[] } completions 168 | */ 169 | _completeRef(indexItem, node, point) { 170 | const value = String(indexItem.value); 171 | 172 | const loc = location(value); 173 | 174 | const nodeStartOffset = loc.toOffset(node.position?.start); 175 | const nodeEndOffset = loc.toOffset(node.position?.end); 176 | 177 | if (nodeStartOffset === undefined || nodeEndOffset === undefined) { 178 | return []; 179 | } 180 | 181 | const nodeValue = value.substring(nodeStartOffset, nodeEndOffset); 182 | const linkPartOffset = nodeValue.lastIndexOf('('); 183 | 184 | const startOffset = real(loc.toOffset(point)); 185 | 186 | const positionOffset = startOffset - nodeStartOffset; 187 | 188 | if (positionOffset <= linkPartOffset) { 189 | return []; 190 | } 191 | 192 | const prefix = nodeValue.slice(linkPartOffset + 1, -1); 193 | 194 | const refs = this._findRefs(indexItem, prefix); 195 | 196 | return refs.map(ref => { 197 | 198 | return { 199 | label: ref, 200 | replace: { 201 | position: { 202 | start: real(loc.toPoint(nodeStartOffset + linkPartOffset + 1)), 203 | end: real(loc.toPoint(nodeEndOffset - 1)) 204 | }, 205 | newText: ref 206 | } 207 | }; 208 | }); 209 | } 210 | 211 | /** 212 | * @param { IndexItem } indexItem 213 | * @param { string } prefix 214 | * 215 | * @return { string[] } refs 216 | */ 217 | _findRefs(indexItem, prefix) { 218 | 219 | const roots = this._indexer.getRoots(); 220 | 221 | const item = { 222 | root: findRoot(indexItem.uri, roots), 223 | base: fileURLToPath(indexItem.uri) 224 | }; 225 | 226 | const anchors = this._references.getAnchors().flatMap( 227 | anchor => { 228 | const { uri } = anchor; 229 | 230 | if (!uri) { 231 | return []; 232 | } 233 | 234 | const url = new URL(uri); 235 | const hash = url.hash; 236 | const base = fileURLToPath(url); 237 | 238 | const relative = base === item.base 239 | ? '' 240 | : path.join(path.relative(path.dirname(item.base), path.dirname(base)), path.basename(base)); 241 | 242 | return { 243 | base, 244 | relative: relative && !relative.startsWith('.') ? './' + relative : relative, 245 | root: findRoot(uri, roots), 246 | hash 247 | }; 248 | } 249 | ).filter( 250 | anchor => anchor.root === item.root && (anchor.hash || anchor.relative) 251 | ).map( 252 | anchor => anchor.relative + anchor.hash 253 | ); 254 | 255 | return Array.from( 256 | new Set( 257 | anchors.filter(anchor => anchor.includes(prefix)) 258 | ) 259 | ); 260 | } 261 | 262 | } 263 | 264 | /** 265 | * @param { Node } root 266 | * @param { Point } point 267 | * 268 | * @return { Node | undefined } 269 | */ 270 | function findNode(root, point) { 271 | let node; 272 | 273 | visit( 274 | root, 275 | n => { 276 | 277 | if (!inRange(point, n.position)) { 278 | return SKIP; 279 | } 280 | 281 | if (isParent(n)) { 282 | const childIndex = n.children.findIndex(c => inRange(point, c.position)); 283 | 284 | if (childIndex !== -1) { 285 | 286 | // move to found child 287 | return childIndex; 288 | } 289 | } 290 | 291 | node = n; 292 | 293 | return EXIT; 294 | } 295 | ); 296 | 297 | return node; 298 | } 299 | 300 | /** 301 | * @param { string } uri 302 | * @param { string[] } roots 303 | * 304 | * @return { string | undefined } 305 | */ 306 | function findRoot(uri, roots) { 307 | return roots.find(root => uri.startsWith(root)); 308 | } 309 | -------------------------------------------------------------------------------- /lib/core/indexer.js: -------------------------------------------------------------------------------- 1 | import { read } from 'to-vfile'; 2 | import { VFile } from 'vfile'; 3 | 4 | import { createIndexItem } from './util.js'; 5 | 6 | 7 | /** 8 | * @typedef { import('./types.js').IndexItem } IndexItem 9 | * @typedef { import('./types.js').TaggedRoot } TaggedRoot 10 | * 11 | * @typedef { IndexItem & { file: VFile } } ReadIndexItem 12 | * @typedef { import('./types.js').ParsedIndexItem } ParsedIndexItem 13 | * 14 | * @typedef { IndexItem & { 15 | * _read?: () => Promise, 16 | * _process?: () => Promise, 17 | * _parsed?: Promise, 18 | * parseTree?: TaggedRoot 19 | * } } InternalIndexItem 20 | */ 21 | 22 | 23 | export default class Indexer { 24 | 25 | /** 26 | * @type { Set } 27 | */ 28 | roots = new Set(); 29 | 30 | /** 31 | * @type { Map } 32 | */ 33 | items = new Map(); 34 | 35 | /** 36 | * @param { import('./logger.js').default } logger 37 | * @param { import('node:events').EventEmitter } eventBus 38 | * @param { import('./processor.js').default } processor 39 | * @param { import('./workqueue.js').default } workqueue 40 | */ 41 | constructor(logger, eventBus, processor, workqueue) { 42 | 43 | this._logger = logger; 44 | this._eventBus = eventBus; 45 | this._processor = processor; 46 | this._workqueue = workqueue; 47 | 48 | eventBus.on('watcher:add', (uri) => { 49 | this.add(uri); 50 | }); 51 | 52 | eventBus.on('watcher:remove', (uri) => { 53 | this.remove(uri); 54 | }); 55 | } 56 | 57 | on(event, callback) { 58 | this._eventBus.on('indexer:' + event, callback); 59 | } 60 | 61 | once(event, callback) { 62 | this._eventBus.once('indexer:' + event, callback); 63 | } 64 | 65 | _emit(event, ...args) { 66 | this._eventBus.emit('indexer:' + event, ...args); 67 | } 68 | 69 | /** 70 | * Add root 71 | * 72 | * @param { string } uri 73 | */ 74 | addRoot(uri) { 75 | this.roots.add(uri); 76 | 77 | this._emit('roots:add', uri); 78 | } 79 | 80 | /** 81 | * Remove root 82 | * 83 | * @param { string } uri 84 | */ 85 | removeRoot(uri) { 86 | this.roots.delete(uri); 87 | 88 | this._emit('roots:remove', uri); 89 | } 90 | 91 | /** 92 | * @return { string[] } roots 93 | */ 94 | getRoots() { 95 | return Array.from(this.roots); 96 | } 97 | 98 | /** 99 | * @param { string } uri 100 | * @param { string } [localValue] 101 | */ 102 | add(uri, localValue) { 103 | this._logger.log('indexer :: add', uri, !!localValue); 104 | 105 | let indexItem = this.items.get(uri); 106 | 107 | if (!indexItem) { 108 | indexItem = createIndexItem({ uri, localValue }); 109 | 110 | this.items.set(uri, indexItem); 111 | } 112 | 113 | if (localValue) { 114 | indexItem.value = indexItem.localValue = localValue; 115 | indexItem._read = () => Promise.resolve(indexItem); 116 | } else { 117 | indexItem._read = undefined; 118 | indexItem.global = true; 119 | } 120 | 121 | indexItem._process = undefined; 122 | 123 | return this._parseItem(indexItem); 124 | } 125 | 126 | /** 127 | * Notify file opened 128 | * 129 | * @param { { uri: string, value: string } } fileProps 130 | */ 131 | fileOpen(fileProps) { 132 | 133 | const { 134 | uri, 135 | value 136 | } = fileProps; 137 | 138 | return this.add(uri, value); 139 | } 140 | 141 | /** 142 | * Notify file content changed 143 | * 144 | * @param { { uri: string, value: string } } fileProps 145 | */ 146 | fileContentChanged(fileProps) { 147 | 148 | const { 149 | uri, 150 | value 151 | } = fileProps; 152 | 153 | return this.add(uri, value); 154 | } 155 | 156 | /** 157 | * Notify file closed 158 | * 159 | * @param {string} uri 160 | */ 161 | fileClosed(uri) { 162 | this.remove(uri, true); 163 | } 164 | 165 | /** 166 | * @param {string} uri 167 | * @param {boolean} [local] 168 | */ 169 | remove(uri, local = false) { 170 | this._logger.log('indexer :: remove', uri, local); 171 | 172 | const item = this.items.get(uri); 173 | 174 | if (!item) { 175 | return; 176 | } 177 | 178 | if (local) { 179 | item.value = item.localValue = undefined; 180 | 181 | item._read = undefined; 182 | item._process = undefined; 183 | 184 | if (item.global) { 185 | return this._parseItem(item); 186 | } 187 | } else { 188 | if (item.localValue) { 189 | item.global = false; 190 | 191 | return this._parseItem(item); 192 | } 193 | } 194 | 195 | this.items.delete(uri); 196 | 197 | return this._removed(item); 198 | } 199 | 200 | /** 201 | * @internal 202 | * 203 | * @param {InternalIndexItem} item 204 | * 205 | * @return {Promise} 206 | */ 207 | async _parseItem(item) { 208 | 209 | let { 210 | _read, 211 | _process, 212 | _parsed 213 | } = item; 214 | 215 | if (!_read) { 216 | this._logger.log('indexer :: reading item ' + item.uri); 217 | 218 | _read = item._read = () => this._readItem(item); 219 | _parsed = undefined; 220 | } 221 | 222 | if (!_process) { 223 | this._logger.log('indexer :: processing item ' + item.uri); 224 | 225 | _process = item._process = () => this._processItem(item); 226 | _parsed = undefined; 227 | } 228 | 229 | if (!_parsed) { 230 | _parsed = item._parsed = _read().then(_process).then((item) => { 231 | this._updated(item); 232 | 233 | return item; 234 | }, err => { 235 | this._logger.log('indexer :: failed to parse item ' + item.uri, err); 236 | 237 | throw err; 238 | }); 239 | } 240 | 241 | return this._queue(_parsed); 242 | } 243 | 244 | /** 245 | * @internal 246 | * 247 | * @param { IndexItem } item 248 | * 249 | * @return { Promise } 250 | */ 251 | async _readItem(item) { 252 | item.file = await read(new URL(item.uri)).catch(err => new VFile({ 253 | path: new URL(item.uri), 254 | value: '' 255 | })); 256 | 257 | return item; 258 | } 259 | 260 | /** 261 | * @internal 262 | * 263 | * @param { InternalIndexItem } item 264 | * 265 | * @return { Promise } 266 | */ 267 | async _processItem(item) { 268 | item.parseTree = await this._processor.process(item); 269 | 270 | return /** @type { ParsedIndexItem } */ (item); 271 | } 272 | 273 | /** 274 | * @internal 275 | * 276 | * @template T 277 | * 278 | * @param {Promise} value 279 | * 280 | * @return {Promise} 281 | */ 282 | _queue(value) { 283 | return this._workqueue.add(value); 284 | } 285 | 286 | /** 287 | * @internal 288 | * 289 | * @param {IndexItem} item 290 | */ 291 | _updated(item) { 292 | this._logger.log('indexer :: updated', item.uri); 293 | 294 | this._emit('updated', item); 295 | } 296 | 297 | /** 298 | * @internal 299 | * 300 | * @param {IndexItem} item 301 | */ 302 | _removed(item) { 303 | this._emit('removed', item); 304 | } 305 | 306 | /** 307 | * Get item with the given uri 308 | * 309 | * @param { string } uri 310 | * 311 | * @return { Promise } 312 | */ 313 | async get(uri) { 314 | 315 | const item = this.items.get(uri); 316 | 317 | if (!item) { 318 | return undefined; 319 | } 320 | 321 | return this._parseItem(item); 322 | } 323 | 324 | /** 325 | * Return known index items. 326 | * 327 | * @return { IndexItem[] } 328 | */ 329 | getItems() { 330 | return Array.from(this.items.values()); 331 | } 332 | 333 | } 334 | -------------------------------------------------------------------------------- /lib/core/linter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import('./types.js').LinterResult } LinterResult 3 | * @typedef { import('./types.js').LinterResults } LinterResults 4 | * 5 | * @typedef { import('./references.js').Link } Link 6 | */ 7 | 8 | export default class Linter { 9 | 10 | /** 11 | * @param { import('./logger.js').default } logger 12 | * @param { import('node:events').EventEmitter } eventBus 13 | * @param { import('./references.js').default } references 14 | */ 15 | constructor(logger, eventBus, references) { 16 | 17 | this._logger = logger; 18 | this._eventBus = eventBus; 19 | this._references = references; 20 | 21 | eventBus.on('references:changed', () => { 22 | this.lint(); 23 | }); 24 | } 25 | 26 | /** 27 | * @param { Link[] } links 28 | * 29 | * @return { LinterResults[] } 30 | */ 31 | check(links) { 32 | const externalPattern = /^https?:\/\//i; 33 | const mdPattern = /\.md(#.*)?$/i; 34 | 35 | /** 36 | * @type { Map } 37 | */ 38 | const resultsMap = new Map(); 39 | 40 | for (const link of links) { 41 | 42 | const { 43 | uri, 44 | targetUri, 45 | anchor 46 | } = link; 47 | 48 | if (!mdPattern.test(targetUri) || externalPattern.test(targetUri) || anchor) { 49 | continue; 50 | } 51 | 52 | /** 53 | * @type { Link[] | undefined } 54 | */ 55 | let results = resultsMap.get(uri); 56 | 57 | if (!results) { 58 | results = []; 59 | resultsMap.set(uri, results); 60 | } 61 | 62 | results.push(link); 63 | } 64 | 65 | /** 66 | * @type { LinterResults[] } 67 | */ 68 | const results = []; 69 | 70 | for (const [ uri, brokenLinks ] of resultsMap.entries()) { 71 | 72 | results.push({ 73 | uri, 74 | results: brokenLinks.map(link => ({ 75 | position: link.position, 76 | message: 'Target is unresolved', 77 | severity: 'warn' 78 | })) 79 | }); 80 | 81 | } 82 | 83 | return results; 84 | } 85 | 86 | lint() { 87 | const links = this._references.getLinks(); 88 | 89 | const results = this.check(links); 90 | 91 | this._logger.log('linter :: lint complete', results); 92 | 93 | this._eventBus.emit('linter:lint', results); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/core/logger.js: -------------------------------------------------------------------------------- 1 | export default class Logger { 2 | 3 | /** 4 | * @param { unknown[] } _args 5 | */ 6 | log(..._args) { } 7 | 8 | /** 9 | * @param { unknown[] } _args 10 | */ 11 | info(..._args) { } 12 | 13 | /** 14 | * @param { unknown[] } _args 15 | */ 16 | error(..._args) { } 17 | 18 | /** 19 | * @param { unknown[] } _args 20 | */ 21 | warn(..._args) { } 22 | } 23 | -------------------------------------------------------------------------------- /lib/core/markdown/extract-metadata.js: -------------------------------------------------------------------------------- 1 | import { visit } from 'unist-util-visit'; 2 | 3 | import { Index } from 'unist-util-index'; 4 | 5 | import GithubSlugger from 'github-slugger'; 6 | 7 | import { toString } from 'mdast-util-to-string'; 8 | import { documentRange, real } from '../util.js'; 9 | 10 | /** 11 | * @typedef { import('../types.js').LocalLink } Link 12 | * @typedef { import('../types.js').LocalTag } Tag 13 | * @typedef { import('../types.js').LocalAnchor } Anchor 14 | * 15 | * @typedef { import('../types.js').Root } Root 16 | * @typedef { import('../types.js').Definition } Definition 17 | * @typedef { import('../types.js').TaggedRoot } TaggedRoot 18 | * 19 | * @typedef { import('vfile').VFile } VFile 20 | * 21 | * @typedef { import('unist').Position } Position 22 | * 23 | * @typedef { {} } Options 24 | */ 25 | 26 | /** 27 | * Tag links plug-in. 28 | * 29 | * @type { import('unified').Plugin<[Options?]|void[], Root, TaggedRoot> } 30 | * 31 | * @param { Options } [_options] 32 | */ 33 | export default function tagLinks(_options) { 34 | 35 | /** 36 | * @param { Root } tree 37 | * @param { VFile } _file 38 | * 39 | * @return { TaggedRoot } 40 | */ 41 | return (tree, _file) => { 42 | 43 | const slugger = new GithubSlugger(); 44 | 45 | const definitionsById = new Index('identifier', tree, 'definition'); 46 | 47 | /** 48 | * @type { Link[] } 49 | */ 50 | const links = []; 51 | 52 | /** 53 | * @type { Anchor[] } 54 | */ 55 | const anchors = []; 56 | 57 | /** 58 | * @type { Tag[] } 59 | */ 60 | const tags = []; 61 | 62 | visit(tree, (node) => { 63 | 64 | if (!node.position) { 65 | return; 66 | } 67 | 68 | if ( 69 | node.type === 'tag' 70 | ) { 71 | tags.push({ 72 | position: real(node.position), 73 | value: node.value 74 | }); 75 | } 76 | 77 | if ( 78 | node.type === 'link' || 79 | node.type === 'image' 80 | ) { 81 | links.push({ 82 | position: real(node.position), 83 | targetUri: node.url 84 | }); 85 | } 86 | 87 | if ( 88 | node.type === 'linkReference' 89 | ) { 90 | 91 | const definition = /** @type { Definition } */ ( 92 | definitionsById.get(node.identifier)[0] 93 | ); 94 | 95 | links.push({ 96 | position: real(node.position), 97 | targetUri: definition.url 98 | }); 99 | } 100 | 101 | if ( 102 | node.type === 'heading' 103 | ) { 104 | 105 | const slug = slugger.slug(toString(node)); 106 | 107 | anchors.push({ 108 | position: real(node.position), 109 | uri: '#' + slug 110 | }); 111 | } 112 | }); 113 | 114 | anchors.push({ 115 | uri: '', 116 | position: documentRange() 117 | }); 118 | 119 | return { 120 | ...tree, 121 | links, 122 | anchors, 123 | tags 124 | }; 125 | }; 126 | } 127 | -------------------------------------------------------------------------------- /lib/core/markdown/remark.js: -------------------------------------------------------------------------------- 1 | import { unified } from 'unified'; 2 | import remarkParse from 'remark-parse'; 3 | import remarkFrontmatter from 'remark-frontmatter'; 4 | import remarkGfm from 'remark-gfm'; 5 | 6 | import remarkTags from './tags.js'; 7 | 8 | import extractMetadata from './extract-metadata.js'; 9 | 10 | export default function remark() { 11 | 12 | return unified() 13 | .use(remarkParse) 14 | .use(remarkFrontmatter) 15 | .use(remarkTags) 16 | .use(remarkGfm) 17 | .use(extractMetadata) 18 | .freeze(); 19 | } 20 | -------------------------------------------------------------------------------- /lib/core/markdown/tags.js: -------------------------------------------------------------------------------- 1 | import { tags } from './tags/micromark.js'; 2 | import { tagsFromMarkdown } from './tags/mdast.js'; 3 | 4 | /** @typedef { {} } Options */ 5 | /** @typedef { import('../types.js').Root } Root */ 6 | 7 | /** 8 | * Plugin to add support for frontmatter. 9 | * 10 | * @type { import('unified').Plugin<[Options?]|void[], Root> } 11 | */ 12 | export default function remarkTags() { 13 | const data = this.data(); 14 | 15 | add('micromarkExtensions', tags()); 16 | add('fromMarkdownExtensions', tagsFromMarkdown()); 17 | 18 | /** 19 | * @param {'micromarkExtensions' | 'fromMarkdownExtensions'} field 20 | * @param {unknown} value 21 | */ 22 | function add(field, value) { 23 | const list = /** @type {unknown[]} */ ( 24 | data[field] ? data[field] : (data[field] = []) 25 | ); 26 | 27 | list.push(value); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/core/markdown/tags/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Literal } from 'mdast'; 2 | 3 | interface Tag extends Literal { 4 | type: 'tag'; 5 | value: string; 6 | } 7 | 8 | declare module 'mdast' { 9 | 10 | interface RootContentMap { 11 | tag: Tag 12 | } 13 | } 14 | 15 | declare module 'micromark-util-types' { 16 | 17 | interface TokenTypeMap { 18 | tag: 'tag' 19 | tagName: 'tagName' 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /lib/core/markdown/tags/mdast.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import('mdast').Literal } Literal 3 | * @typedef { import('mdast-util-from-markdown').Extension } FromMarkdownExtension 4 | * @typedef { import('mdast-util-from-markdown').Handle } FromMarkdownHandle 5 | * @typedef { import('mdast-util-to-markdown').Handle } ToMarkdownHandle 6 | * @typedef { import('mdast-util-to-markdown').Map } Map 7 | */ 8 | 9 | /** 10 | * Function that can be called to get an extension for 11 | * `mdast-util-from-markdown`. 12 | * 13 | * @returns { FromMarkdownExtension } 14 | */ 15 | export function tagsFromMarkdown() { 16 | return { 17 | enter: { 18 | tag: open, 19 | }, 20 | exit: { 21 | tag: close, 22 | tagName: value 23 | } 24 | }; 25 | } 26 | 27 | /** @type { FromMarkdownHandle } */ 28 | function open(token) { 29 | this.enter({ type: 'tag', value: '' }, token); 30 | this.buffer(); 31 | } 32 | 33 | /** @type { FromMarkdownHandle } */ 34 | function close(token) { 35 | const data = this.resume(); 36 | 37 | const node = /** @type {Literal} */ (this.stack[this.stack.length - 1]); 38 | node.value = data; 39 | 40 | return this.exit(token); 41 | } 42 | 43 | /** @type { FromMarkdownHandle } */ 44 | function value(token) { 45 | this.config.enter.data.call(this, token); 46 | this.config.exit.data.call(this, token); 47 | } 48 | -------------------------------------------------------------------------------- /lib/core/markdown/tags/micromark.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import('micromark-util-types').Extension } Extension 3 | * @typedef { import('micromark-util-types').ConstructRecord } ConstructRecord 4 | * @typedef { import('micromark-util-types').Construct } Construct 5 | * @typedef { import('micromark-util-types').Tokenizer } Tokenizer 6 | * @typedef { import('micromark-util-types').State } State 7 | * @typedef { import('micromark-util-types').Code } Code 8 | */ 9 | 10 | import { markdownLineEndingOrSpace } from 'micromark-util-character'; 11 | import { codes } from 'micromark-util-symbol'; 12 | 13 | /** 14 | * Add support for parsing tags in markdown. 15 | * 16 | * Function that can be called to get a syntax extension for micromark (passed 17 | * in `extensions`). 18 | * 19 | * @returns { Extension } 20 | * Syntax extension for micromark (passed in `extensions`). 21 | */ 22 | export function tags() { 23 | 24 | return { 25 | text: { 26 | 35: parse() // <#> 27 | } 28 | }; 29 | } 30 | 31 | const tagType = 'tag'; 32 | const tagNameType = 'tagName'; 33 | 34 | /** 35 | * @returns { Construct } 36 | */ 37 | function parse() { 38 | const tagName = { tokenize: tokenizeName, partial: true }; 39 | 40 | return { tokenize: tokenizeTag, concrete: true }; 41 | 42 | /** @type { Tokenizer } */ 43 | function tokenizeTag(effects, ok, nok) { 44 | return start; 45 | 46 | /** @type { State } */ 47 | function start(code) { 48 | 49 | effects.enter(tagType); 50 | effects.consume(code); 51 | 52 | return effects.attempt(tagName, exit, nok); 53 | } 54 | 55 | /** @type { State } */ 56 | function exit(code) { 57 | effects.exit(tagType); 58 | return ok(code); 59 | } 60 | } 61 | 62 | /** @type { Tokenizer } */ 63 | function tokenizeName(effects, ok, nok) { 64 | 65 | return start; 66 | 67 | /** @type { State } */ 68 | function start(code) { 69 | if (code === codes.eof || code === codes.comma || markdownLineEndingOrSpace(code)) { 70 | return nok(code); 71 | } 72 | 73 | effects.enter(tagNameType); 74 | return insideName(code); 75 | } 76 | 77 | /** 78 | * @param { Code } code 79 | * @return { State | undefined } 80 | */ 81 | function insideName(code) { 82 | if (code !== codes.eof && code !== codes.comma && !markdownLineEndingOrSpace(code)) { 83 | effects.consume(code); 84 | 85 | return insideName; 86 | } 87 | 88 | effects.exit(tagNameType); 89 | return ok(code); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/core/markmark.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | 3 | import References from './references.js'; 4 | import Completions from './completions.js'; 5 | import Workqueue from './workqueue.js'; 6 | import Processor from './processor.js'; 7 | import Watcher from './watcher.js'; 8 | import Indexer from './indexer.js'; 9 | import Linter from './linter.js'; 10 | 11 | /** 12 | * @typedef { import('./types.js').DocumentRange } DocumentRange 13 | * @typedef { import('./types.js').DocumentLocation } DocumentLocation 14 | * 15 | * @typedef { import('./types.js').Positioned } Positioned 16 | * @typedef { import('./types.js').Completion } Completion 17 | */ 18 | 19 | export default class Markmark extends EventEmitter { 20 | 21 | /** 22 | * @param { import('./logger.js').default } logger 23 | */ 24 | constructor(logger) { 25 | 26 | super(); 27 | 28 | this._logger = logger; 29 | 30 | this._workqueue = new Workqueue(this); 31 | this._processor = new Processor(logger); 32 | this._indexer = new Indexer(logger, this, this._processor, this._workqueue); 33 | 34 | this._references = new References(logger, this); 35 | this._linter = new Linter(logger, this, this._references); 36 | 37 | this._completions = new Completions(logger, this._indexer, this._references); 38 | } 39 | 40 | /** 41 | * Add root 42 | * 43 | * @param { string } uri 44 | */ 45 | addRoot(uri) { 46 | return this._indexer.addRoot(uri); 47 | } 48 | 49 | /** 50 | * Remove root 51 | * 52 | * @param { string } uri 53 | */ 54 | removeRoot(uri) { 55 | return this._indexer.removeRoot(uri); 56 | } 57 | 58 | /** 59 | * Add file 60 | * 61 | * @param {string} uri 62 | */ 63 | addFile(uri) { 64 | return this._indexer.add(uri); 65 | } 66 | 67 | /** 68 | * Notify file changed 69 | * 70 | * @param {string} uri 71 | */ 72 | updateFile(uri) { 73 | return this.addFile(uri); 74 | } 75 | 76 | /** 77 | * Remove file 78 | * 79 | * @param {string} uri 80 | */ 81 | removeFile(uri) { 82 | return this._indexer.remove(uri); 83 | } 84 | 85 | /** 86 | * Notify file opened 87 | * 88 | * @param { { uri: string, value: string } } fileProps 89 | */ 90 | fileOpen(fileProps) { 91 | return this._indexer.fileOpen(fileProps); 92 | } 93 | 94 | /** 95 | * Notify file content changed 96 | * 97 | * @param { { uri: string, value: string } } fileProps 98 | */ 99 | fileContentChanged(fileProps) { 100 | return this._indexer.fileContentChanged(fileProps); 101 | } 102 | 103 | /** 104 | * Notify file closed 105 | * 106 | * @param { string } uri 107 | */ 108 | fileClosed(uri) { 109 | return this._indexer.fileClosed(uri); 110 | } 111 | 112 | /** 113 | * Find definitions of reference 114 | * 115 | * @param { DocumentLocation } ref 116 | * 117 | * @return { DocumentRange[] } definitions 118 | */ 119 | findDefinitions(ref) { 120 | return this._references.findDefinitions(ref); 121 | } 122 | 123 | /** 124 | * Find references to referenced link _or_ current document 125 | * 126 | * @param { DocumentLocation } ref 127 | * 128 | * @return { DocumentRange[] } references 129 | */ 130 | findReferences(ref) { 131 | return this._references.findReferences(ref); 132 | } 133 | 134 | /** 135 | * Get completion at position 136 | * 137 | * @param { DocumentLocation } ref 138 | * 139 | * @return { Promise } completions 140 | */ 141 | getCompletions(ref) { 142 | return this._completions.get(ref); 143 | } 144 | 145 | /** 146 | * @return { Promise } 147 | */ 148 | close() { 149 | 150 | if (this._watcher) { 151 | return this._watcher.close(); 152 | } 153 | 154 | return Promise.resolve(); 155 | } 156 | 157 | /** 158 | * @param { { watch: boolean } } opts 159 | */ 160 | init(opts) { 161 | 162 | if (opts.watch) { 163 | this._watcher = new Watcher(this._logger, this); 164 | 165 | this.once('watcher:ready', () => { 166 | this.once('workqueue:empty', () => this.emit('ready')); 167 | }); 168 | } else { 169 | this.once('workqueue:empty', () => this.emit('ready')); 170 | } 171 | } 172 | 173 | } 174 | -------------------------------------------------------------------------------- /lib/core/processor.js: -------------------------------------------------------------------------------- 1 | import remark from './markdown/remark.js'; 2 | 3 | /** 4 | * @typedef { import('./types.js').Root } Root 5 | * @typedef { import('./types.js').TaggedRoot } TaggedRoot 6 | * 7 | * @typedef { import('./types.js').IndexItem } IndexItem 8 | */ 9 | 10 | export default class Processor { 11 | 12 | /** 13 | * @param { import('./logger.js').default } logger 14 | */ 15 | constructor(logger) { 16 | this._processor = remark(); 17 | this._logger = logger; 18 | } 19 | 20 | /** 21 | * @param {IndexItem} item 22 | * 23 | * @return { Promise } 24 | */ 25 | async process(item) { 26 | 27 | const tree = await this._processor.parse({ 28 | ...item.file, 29 | value: item.value 30 | }); 31 | 32 | return this._processor.run(tree, item.file); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /lib/core/references.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import('./types.js').Position } Position 3 | * @typedef { import('./types.js').DocumentLocation } DocumentLocation 4 | * @typedef { import('./types.js').DocumentRange } DocumentRange 5 | * 6 | * @typedef { import('./types.js').Positioned } Positioned 7 | * @typedef { import('./types.js').ParsedIndexItem } ParsedIndexItem 8 | * 9 | * @typedef { import('./types.js').LocalAnchor } LocalAnchor 10 | * @typedef { import('./types.js').LocalLink } LocalLink 11 | * @typedef { import('./types.js').LocalTag } LocalTag 12 | * 13 | * @typedef { { 14 | * name: string, 15 | * references: Set 16 | * } } Tag 17 | * 18 | * @typedef { Positioned & { 19 | * uri: string, 20 | * tag: Tag 21 | * } } TagRef 22 | 23 | * @typedef { { 24 | * uri: string, 25 | * anchors: LocalAnchor[], 26 | * links: LocalLink[], 27 | * tags: LocalTag[] 28 | * } } Document 29 | * 30 | * @typedef { LocalAnchor } BaseAnchor 31 | * @typedef { LocalLink & { uri: string } } BaseLink 32 | * @typedef { LocalTag & { uri: string } } BaseTag 33 | 34 | * @typedef { BaseAnchor & { references: Set } } Anchor 35 | * @typedef { BaseLink & { anchor?: Anchor } } Link 36 | */ 37 | 38 | import { URL } from 'node:url'; 39 | 40 | import { 41 | inRange 42 | } from './util.js'; 43 | 44 | 45 | /** 46 | * A references holder. 47 | */ 48 | export default class References { 49 | 50 | /** 51 | * @type { Map } 52 | */ 53 | documentsById = new Map(); 54 | 55 | /** 56 | * @type { Set } 57 | */ 58 | anchors = new Set(); 59 | 60 | /** 61 | * @type { Map> } 62 | */ 63 | anchorsByDocument = new Map(); 64 | 65 | /** 66 | * @type { Map } 67 | */ 68 | anchorsByUri = new Map(); 69 | 70 | /** 71 | * @type { Map } 72 | */ 73 | tags = new Map(); 74 | 75 | /** 76 | * @type { Map> } 77 | */ 78 | tagsByDocument = new Map(); 79 | 80 | /** 81 | * @type { Set } 82 | */ 83 | links = new Set(); 84 | 85 | /** 86 | * @type { Map> } 87 | */ 88 | linksByDocument = new Map(); 89 | 90 | /** 91 | * @type { Map> } 92 | */ 93 | linksByTarget = new Map(); 94 | 95 | /** 96 | * @param { import('./logger.js').default } logger 97 | * @param { import('node:events').EventEmitter } eventBus 98 | */ 99 | constructor(logger, eventBus) { 100 | this._logger = logger; 101 | this._eventBus = eventBus; 102 | 103 | eventBus.on('indexer:updated', (/** @type {ParsedIndexItem} */ item) => { 104 | 105 | try { 106 | const { 107 | uri, 108 | parseTree: { 109 | anchors, 110 | links, 111 | tags 112 | } 113 | } = item; 114 | 115 | this.addDocument({ 116 | uri, 117 | anchors, 118 | links, 119 | tags 120 | }); 121 | } catch (err) { 122 | this._logger.log('references :: failed to process ' + item.uri, err); 123 | } 124 | }); 125 | 126 | eventBus.on('indexer:removed', (item) => { 127 | this.removeDocument(item.uri); 128 | }); 129 | 130 | eventBus.on('references:changed', () => { 131 | 132 | this._logger.log('references :: changed', { 133 | links: this.links.size, 134 | anchors: this.anchors.size, 135 | tags: this.tags.size 136 | }); 137 | }); 138 | 139 | } 140 | 141 | /** 142 | * @internal 143 | */ 144 | _changed() { 145 | clearTimeout(this._changedTimer); 146 | 147 | this._changedTimer = setTimeout(() => { 148 | this._eventBus.emit('references:changed'); 149 | }, 300); 150 | 151 | } 152 | 153 | /** 154 | * @param { Document } doc 155 | */ 156 | addDocument(doc) { 157 | 158 | const { 159 | uri: documentUri, 160 | anchors, 161 | links, 162 | tags 163 | } = doc; 164 | 165 | this._logger.log('references :: addDocument', documentUri); 166 | 167 | this.removeDocument(documentUri); 168 | 169 | this.documentsById.set(documentUri, doc); 170 | 171 | for (const tag of tags) { 172 | 173 | this._addTag({ 174 | ...tag, 175 | uri: documentUri 176 | }); 177 | } 178 | 179 | for (const anchor of anchors) { 180 | 181 | const { 182 | uri 183 | } = anchor; 184 | 185 | this._addAnchor({ 186 | ...anchor, 187 | uri: resolve(uri, documentUri) 188 | }); 189 | } 190 | 191 | for (const link of links) { 192 | const { 193 | targetUri 194 | } = link; 195 | 196 | this._addLink({ 197 | ...link, 198 | uri: documentUri, 199 | targetUri: resolve(targetUri, documentUri) 200 | }); 201 | } 202 | 203 | this._changed(); 204 | } 205 | 206 | /** 207 | * @internal 208 | * 209 | * @param {BaseTag} baseTag 210 | */ 211 | _addTag(baseTag) { 212 | 213 | const { 214 | uri: documentUri, 215 | value: name, 216 | position 217 | } = baseTag; 218 | 219 | let tag = this.tags.get(name); 220 | 221 | if (!tag) { 222 | tag = { 223 | name, 224 | references: new Set() 225 | }; 226 | 227 | this.tags.set(name, tag); 228 | } 229 | 230 | const tagRef = { 231 | uri: documentUri, 232 | position, 233 | tag 234 | }; 235 | 236 | tag.references.add(tagRef); 237 | 238 | this._addRef(tagRef, this.tagsByDocument, documentUri); 239 | } 240 | 241 | /** 242 | * @internal 243 | * 244 | * @param {TagRef} tagRef 245 | */ 246 | _removeTag(tagRef) { 247 | this._removeRef(tagRef, this.tagsByDocument, tagRef.uri); 248 | 249 | const tag = tagRef.tag; 250 | 251 | tag.references.delete(tagRef); 252 | } 253 | 254 | /** 255 | * @internal 256 | * 257 | * @param {BaseLink} baseLink 258 | */ 259 | _addLink(baseLink) { 260 | 261 | /** @type {Link} */ 262 | const link = { 263 | ...baseLink, 264 | anchor: undefined 265 | }; 266 | 267 | this.links.add(link); 268 | 269 | this._addRef(link, this.linksByTarget, link.targetUri); 270 | this._addDocRef(link, this.linksByDocument, link.uri); 271 | 272 | const anchor = this.anchorsByUri.get(link.targetUri); 273 | 274 | if (anchor) { 275 | anchor.references.add(link); 276 | link.anchor = anchor; 277 | } 278 | } 279 | 280 | /** 281 | * @internal 282 | * 283 | * @param {Link} link 284 | */ 285 | _removeLink(link) { 286 | this.links.delete(link); 287 | 288 | this._removeRef(link, this.linksByTarget, link.targetUri); 289 | this._removeDocRef(link, this.linksByDocument, link.uri); 290 | 291 | const anchor = link.anchor; 292 | 293 | if (anchor) { 294 | anchor.references.delete(link); 295 | link.anchor = undefined; 296 | } 297 | } 298 | 299 | /** 300 | * @internal 301 | * 302 | * @param { BaseAnchor } baseAnchor 303 | */ 304 | _addAnchor(baseAnchor) { 305 | 306 | /** @type {Anchor} */ 307 | const anchor = { 308 | ...baseAnchor, 309 | references: new Set() 310 | }; 311 | 312 | this.anchors.add(anchor); 313 | this.anchorsByUri.set(anchor.uri, anchor); 314 | 315 | this._addDocRef(anchor, this.anchorsByDocument, anchor.uri); 316 | 317 | const links = this.linksByTarget.get(anchor.uri); 318 | 319 | if (links) { 320 | for (const link of links) { 321 | link.anchor = anchor; 322 | anchor.references.add(link); 323 | } 324 | } 325 | } 326 | 327 | /** 328 | * @internal 329 | * 330 | * @param { Anchor } anchor 331 | */ 332 | _removeAnchor(anchor) { 333 | 334 | this.anchors.delete(anchor); 335 | this.anchorsByUri.delete(anchor.uri); 336 | 337 | this._removeDocRef(anchor, this.anchorsByDocument, anchor.uri); 338 | 339 | for (const link of anchor.references) { 340 | link.anchor = undefined; 341 | } 342 | 343 | anchor.references.clear(); 344 | } 345 | 346 | /** 347 | * @internal 348 | * 349 | * @template T 350 | * @param { T } ref 351 | * @param { Map> } refsByDocument 352 | * @param { string } uri 353 | */ 354 | _addDocRef(ref, refsByDocument, uri) { 355 | 356 | const url = new URL(uri); 357 | url.search = ''; 358 | url.hash = ''; 359 | 360 | const documentUri = url.toString(); 361 | 362 | return this._addRef(ref, refsByDocument, documentUri); 363 | } 364 | 365 | /** 366 | * @internal 367 | * 368 | * @template T 369 | * @param { T } ref 370 | * @param { Map> } refsByUri 371 | * @param { string } uri 372 | */ 373 | _addRef(ref, refsByUri, uri) { 374 | 375 | let refs = refsByUri.get(uri); 376 | 377 | if (!refs) { 378 | refs = new Set(); 379 | refsByUri.set(uri, refs); 380 | } 381 | 382 | refs.add(ref); 383 | } 384 | 385 | /** 386 | * @internal 387 | * 388 | * @template T 389 | * @param { T } ref 390 | * @param { Map> } refsByDocument 391 | * @param { string } uri 392 | */ 393 | _removeDocRef(ref, refsByDocument, uri) { 394 | 395 | const url = new URL(uri); 396 | url.search = ''; 397 | url.hash = ''; 398 | 399 | const documentUri = url.toString(); 400 | 401 | return this._removeRef(ref, refsByDocument, documentUri); 402 | } 403 | 404 | /** 405 | * @internal 406 | * 407 | * @template T 408 | * @param { T } ref 409 | * @param { Map> } refsByUri 410 | * @param { string } uri 411 | */ 412 | _removeRef(ref, refsByUri, uri) { 413 | 414 | let refs = refsByUri.get(uri); 415 | 416 | if (!refs) { 417 | return; 418 | } 419 | 420 | refs.delete(ref); 421 | } 422 | 423 | /** 424 | * Find references to referenced link _or_ current document. 425 | * 426 | * @param { DocumentLocation } ref 427 | * 428 | * @return { DocumentRange[] } references 429 | */ 430 | findReferences(ref) { 431 | 432 | const linkRef = this._findRef(this.linksByDocument, ref); 433 | 434 | // resolve links to external resources 435 | if (linkRef && !linkRef.anchor) { 436 | return Array.from(this.linksByTarget.get(linkRef.targetUri) || []); 437 | } 438 | 439 | const anchor = ( 440 | linkRef?.anchor || 441 | this._findRef(this.tagsByDocument, ref)?.tag || 442 | this._findRef(this.anchorsByDocument, ref) 443 | ); 444 | 445 | if (!anchor) { 446 | return []; 447 | } 448 | 449 | return Array.from( 450 | /** @type {Set} */ (anchor.references) 451 | ); 452 | } 453 | 454 | /** 455 | * @param {DocumentLocation} ref 456 | * 457 | * @return {DocumentRange[]} references 458 | */ 459 | findDefinitions(ref) { 460 | const link = this._findRef(this.linksByDocument, ref); 461 | 462 | if (!link) { 463 | return []; 464 | } 465 | 466 | if (!link.anchor) { 467 | const self = { 468 | uri: link.targetUri, 469 | position: link.position 470 | }; 471 | 472 | return [ self ]; 473 | } 474 | 475 | return [ 476 | link.anchor 477 | ]; 478 | } 479 | 480 | /** 481 | * @param {string} uri 482 | */ 483 | removeDocument(uri) { 484 | 485 | if (!this.documentsById.has(uri)) { 486 | return; 487 | } 488 | 489 | const anchors = this.anchorsByDocument.get(uri); 490 | 491 | if (anchors) { 492 | for (const anchor of anchors) { 493 | this._removeAnchor(anchor); 494 | } 495 | } 496 | 497 | const links = this.linksByDocument.get(uri); 498 | 499 | if (links) { 500 | for (const link of links) { 501 | this._removeLink(link); 502 | } 503 | } 504 | 505 | const tagRefs = this.tagsByDocument.get(uri); 506 | 507 | if (tagRefs) { 508 | for (const tagRef of tagRefs) { 509 | this._removeTag(tagRef); 510 | } 511 | } 512 | this.documentsById.delete(uri); 513 | 514 | this._changed(); 515 | } 516 | 517 | 518 | /** 519 | * @internal 520 | * 521 | * @template { Positioned } T 522 | * 523 | * @param { Map> } refs 524 | * @param { DocumentLocation } ref 525 | * 526 | * @return { T | undefined } 527 | */ 528 | _findRef(refs, ref) { 529 | 530 | const { uri, position } = ref; 531 | 532 | const potentialRefs = refs.get(uri); 533 | 534 | if (!potentialRefs) { 535 | return; 536 | } 537 | 538 | return Array.from(potentialRefs).find(ref => inRange(position, ref.position)); 539 | } 540 | 541 | getAnchors() { 542 | return Array.from(this.anchors); 543 | } 544 | 545 | getLinks() { 546 | return Array.from(this.links); 547 | } 548 | 549 | getTags() { 550 | return Array.from(this.tags.values()); 551 | } 552 | } 553 | 554 | 555 | /** 556 | * @param {string} uri 557 | * @param {string} baseUri 558 | * 559 | * @return {string} 560 | */ 561 | function resolve(uri, baseUri) { 562 | const url = new URL(uri, baseUri); 563 | return url.toString(); 564 | } 565 | -------------------------------------------------------------------------------- /lib/core/types.d.ts: -------------------------------------------------------------------------------- 1 | import { VFile } from 'vfile'; 2 | 3 | import { Position, Point } from 'unist'; 4 | import { Node as UnistNode, Parent as UnistParent } from 'unist'; 5 | 6 | import { Root, Content, Heading, Definition } from 'mdast'; 7 | 8 | export type Node = (Content|Root) & UnistNode; 9 | export type Parent = UnistParent; 10 | 11 | export type Positioned = { 12 | position: Position 13 | }; 14 | 15 | export type DocumentRange = Positioned & { 16 | uri: string 17 | }; 18 | 19 | export type LocalLink = Positioned & { 20 | targetUri: string 21 | }; 22 | 23 | export type LocalAnchor = Positioned & { 24 | uri: string 25 | }; 26 | 27 | export type LocalTag = Positioned & { 28 | value: string 29 | }; 30 | 31 | export type DocumentLocation = { 32 | uri: string, 33 | position: Point 34 | }; 35 | 36 | export type File = VFile; 37 | 38 | export type TaggedRoot = Root & { 39 | anchors: LocalAnchor[], 40 | links: LocalLink[], 41 | tags: LocalTag[] 42 | }; 43 | 44 | export type Completion = { 45 | label: string, 46 | replace: { 47 | position: Position, 48 | newText: string 49 | }, 50 | detail?: string 51 | }; 52 | 53 | export type LinterResult = { 54 | message: string, 55 | severity: 'warn' | 'error' | 'info' | 'hint', 56 | position: Position 57 | }; 58 | 59 | export type LinterResults = { 60 | uri: string, 61 | results: LinterResult[] 62 | }; 63 | 64 | export type IndexItem = Record & { 65 | uri: string, 66 | value?: string, 67 | version?: number, 68 | localValue?: string, 69 | file: File 70 | } 71 | 72 | export type ParsedIndexItem = IndexItem & { 73 | parseTree: TaggedRoot 74 | } 75 | 76 | export { 77 | Root, 78 | Heading, 79 | Definition, 80 | Position, 81 | Point 82 | }; 83 | -------------------------------------------------------------------------------- /lib/core/util.js: -------------------------------------------------------------------------------- 1 | import { VFile } from 'vfile'; 2 | 3 | import assert from 'node:assert'; 4 | 5 | /** 6 | * @typedef { import('./types.js').File } File 7 | * @typedef { import('./types.js').IndexItem } IndexItem 8 | * @typedef { import('./types.js').Position } Position 9 | * @typedef { import('./types.js').Point } Point 10 | * @typedef { import('./types.js').Node } Node 11 | * @typedef { import('./types.js').Parent } Parent 12 | */ 13 | 14 | 15 | /** 16 | * @param { { uri: string, localValue?: string } } item 17 | * 18 | * @return { IndexItem } 19 | */ 20 | export function createIndexItem(item) { 21 | 22 | const { 23 | uri, 24 | localValue, 25 | ...rest 26 | } = item; 27 | 28 | const file = createFile({ 29 | path: new URL(uri), 30 | value: localValue 31 | }); 32 | 33 | file.data.uri = uri; 34 | 35 | return { 36 | ...rest, 37 | uri, 38 | get value() { 39 | return this.localValue || /** @type {string|undefined} */ (this.file.value); 40 | }, 41 | set value(value) { 42 | this.localValue = value; 43 | }, 44 | file, 45 | localValue 46 | }; 47 | 48 | } 49 | 50 | 51 | /** 52 | * @param { { path: URL, value: string | undefined } } props 53 | * 54 | * @return { File } 55 | */ 56 | export function createFile(props) { 57 | return /** @type File */ (new VFile(props)); 58 | } 59 | 60 | /** 61 | * @param {Node} node 62 | * 63 | * @return { node is Parent } 64 | */ 65 | export function isParent(node) { 66 | return 'children' in node && node.children !== undefined; 67 | } 68 | 69 | /** 70 | * @template T extends undefined ? never : T 71 | * 72 | * @param {T|undefined} o 73 | * 74 | * @return {T} 75 | */ 76 | export function real(o) { 77 | assert(o !== undefined, 'expected to be defined'); 78 | 79 | return /** @type {T} */ (o); 80 | } 81 | 82 | 83 | /** 84 | * @param { Position|Point|undefined } position 85 | * @param { Position|undefined } range 86 | * 87 | * @return { boolean } 88 | */ 89 | export function inRange(position, range) { 90 | 91 | if (position === undefined || range === undefined) { 92 | return false; 93 | } 94 | 95 | if (isDocumentRange(range)) { 96 | return true; 97 | } 98 | 99 | const start = 'start' in position ? position.start : position; 100 | const end = 'end' in position ? position.end : position; 101 | 102 | return ( 103 | ( 104 | range.start.line < start.line || ( 105 | range.start.line === start.line && 106 | range.start.column <= start.column 107 | ) 108 | ) && ( 109 | range.end.line > end.line || ( 110 | range.end.line === end.line && 111 | range.end.column >= end.column 112 | ) 113 | ) 114 | ); 115 | } 116 | 117 | /** 118 | * @param { Position } position 119 | * 120 | * @return { boolean } 121 | */ 122 | export function isDocumentRange(position) { 123 | 124 | const start = position.start; 125 | const end = position.end; 126 | 127 | return [ 128 | start.column, 129 | start.line, 130 | end.column, 131 | end.line 132 | ].every(n => n === 0); 133 | } 134 | 135 | /** 136 | * @return {Position} 137 | */ 138 | export function documentRange() { 139 | return { 140 | start: { 141 | line: 0, 142 | column: 0 143 | }, 144 | end: { 145 | line: 0, 146 | column: 0 147 | } 148 | }; 149 | } 150 | -------------------------------------------------------------------------------- /lib/core/watcher.js: -------------------------------------------------------------------------------- 1 | import { 2 | FSWatcher 3 | } from 'chokidar'; 4 | 5 | import { 6 | pathToFileURL, 7 | fileURLToPath 8 | } from 'node:url'; 9 | 10 | 11 | export default class Watcher { 12 | 13 | /** 14 | * @param { import('./logger.js').default } logger 15 | * @param { import('node:events').EventEmitter } eventBus 16 | */ 17 | constructor(logger, eventBus) { 18 | 19 | this._logger = logger; 20 | this._eventBus = eventBus; 21 | 22 | /** 23 | * @type { string[] } 24 | */ 25 | this._roots = []; 26 | 27 | /** 28 | * @type {Set} 29 | */ 30 | this._files = new Set(); 31 | 32 | this._logger.log('watcher :: start'); 33 | 34 | /** 35 | * @type { import('chokidar').FSWatcher } 36 | */ 37 | this._chokidar = new FSWatcher({ 38 | ignored: /\/(node_modules|\.git)\//i, 39 | atomic: 300 40 | }); 41 | 42 | this._chokidar.on('add', path => { 43 | 44 | if (!/\.md$/i.test(path)) { 45 | return; 46 | } 47 | 48 | this._addFile(path); 49 | }); 50 | 51 | this._chokidar.on('unlink', path => { 52 | this._removeFile(path); 53 | }); 54 | 55 | if (/\*|events/.test(process.env.DEBUG || '')) { 56 | this._chokidar.on('all', (event, arg0) => { 57 | this._logger.log(event, arg0); 58 | }); 59 | } 60 | 61 | this._chokidar.on('ready', () => { 62 | this._emit('ready'); 63 | }); 64 | 65 | eventBus.on('indexer:roots:add', (uri) => { 66 | this.addFolder(uri); 67 | }); 68 | 69 | eventBus.on('indexer:roots:remove', (uri) => { 70 | this.removeFolder(uri); 71 | }); 72 | 73 | } 74 | 75 | /** 76 | * @param {string} path 77 | */ 78 | _removeFile(path) { 79 | this._emit('remove', pathToFileURL(path).toString()); 80 | 81 | this._files.delete(path); 82 | 83 | this._changed(); 84 | } 85 | 86 | /** 87 | * @param { string } path 88 | */ 89 | _addFile(path) { 90 | this._files.add(path); 91 | 92 | this._emit('add', pathToFileURL(path).toString()); 93 | 94 | this._changed(); 95 | } 96 | 97 | /** 98 | * @param { string } event 99 | * 100 | * @param { ...unknown } args 101 | */ 102 | _emit(event, ...args) { 103 | this._eventBus.emit('watcher:' + event, ...args); 104 | } 105 | 106 | /** 107 | * @internal 108 | */ 109 | _changed() { 110 | clearTimeout(this._changedTimer); 111 | 112 | this._changedTimer = setTimeout(() => { 113 | this._emit('changed'); 114 | }, 300); 115 | 116 | } 117 | 118 | /** 119 | * @return {string[]} 120 | */ 121 | getFiles() { 122 | return Array.from(this._files); 123 | } 124 | 125 | /** 126 | * Add watched folder 127 | * 128 | * @param {string} uri 129 | */ 130 | addFolder(uri) { 131 | this._logger.log('watcher :: addFolder', uri); 132 | 133 | const path = fileURLToPath(uri); 134 | 135 | if (this._roots.some(root => path.startsWith(root))) { 136 | return; 137 | } 138 | 139 | this._roots.push(path); 140 | 141 | this._chokidar.add(path); 142 | } 143 | 144 | /** 145 | * Remove watched folder 146 | * 147 | * @param {string} uri 148 | */ 149 | removeFolder(uri) { 150 | this._logger.log('watcher :: removeFolder', uri); 151 | 152 | const path = fileURLToPath(uri); 153 | 154 | if (!this._roots.some(root => path.startsWith(root))) { 155 | return; 156 | } 157 | 158 | this._chokidar.unwatch(path); 159 | 160 | this._roots = this._roots.filter(p => p !== path); 161 | 162 | for (const file of this._files) { 163 | 164 | if (file.startsWith(path)) { 165 | this._removeFile(file); 166 | } 167 | } 168 | } 169 | 170 | close() { 171 | this._logger.log('watcher :: close'); 172 | 173 | return this._chokidar.close(); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /lib/core/workqueue.js: -------------------------------------------------------------------------------- 1 | export default class Workqueue { 2 | 3 | /** 4 | * @type { Set> } 5 | */ 6 | queue = new Set(); 7 | 8 | /** 9 | * @param { import('node:events').EventEmitter } eventBus 10 | */ 11 | constructor(eventBus) { 12 | this.eventBus = eventBus; 13 | } 14 | 15 | /** 16 | * Add work queue item. 17 | * 18 | * @template T 19 | * 20 | * @param { Promise } value 21 | * 22 | * @return { Promise } 23 | */ 24 | add(value) { 25 | 26 | this.queue.add(value); 27 | 28 | return value.finally(() => { 29 | this.queue.delete(value); 30 | 31 | if (this.queue.size === 0) { 32 | this.eventBus.emit('workqueue:empty'); 33 | } 34 | }); 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | export { createLanguageServer } from './language-server/language-server.js'; 2 | 3 | export { default as Markmark } from './core/markmark.js'; 4 | -------------------------------------------------------------------------------- /lib/language-server/language-server.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef { import('unist').Point } Point 3 | * @typedef { import('unist').Position } UnistPosition 4 | * @typedef { import('vfile-message').VFileMessage} VFileMessage 5 | * 6 | * @typedef { import('../core/types.js').LinterResults } LinterResults 7 | * 8 | * @typedef { import('vscode-languageserver').Connection } Connection 9 | */ 10 | 11 | import { URL } from 'node:url'; 12 | import { VFile } from 'vfile'; 13 | 14 | import { 15 | createConnection, 16 | CodeAction, 17 | CodeActionKind, 18 | Diagnostic, 19 | DiagnosticSeverity, 20 | Position, 21 | ProposedFeatures, 22 | Range, 23 | TextDocuments, 24 | TextDocumentSyncKind, 25 | TextEdit, 26 | Location, 27 | CompletionItem, 28 | CompletionItemKind, 29 | FileChangeType, 30 | DidChangeWatchedFilesNotification 31 | } from 'vscode-languageserver/node.js'; 32 | 33 | import { TextDocument } from 'vscode-languageserver-textdocument'; 34 | 35 | import { createLogger } from './logger.js'; 36 | 37 | import Markmark from '../core/markmark.js'; 38 | 39 | 40 | /** 41 | * Convert a unist point to a language server protocol position. 42 | * 43 | * @param {Point} point 44 | * @returns {Position} 45 | */ 46 | function unistPointToLspPosition(point) { 47 | return Position.create(point.line - 1, point.column - 1); 48 | } 49 | 50 | 51 | /** 52 | * Convert a position to a language server protocol position. 53 | * 54 | * @param {Position} position 55 | * @returns {Point} 56 | */ 57 | function lspPositionToUinstPoint(position) { 58 | return { 59 | line: position.line + 1, 60 | column: position.character + 1 61 | }; 62 | } 63 | 64 | /** 65 | * @param {Point|null|undefined} point 66 | * @returns {boolean} 67 | */ 68 | function isValidUnistPoint(point) { 69 | return Boolean( 70 | point && Number.isInteger(point.line) && Number.isInteger(point.column) && point.line > 0 && point.column > 0 71 | ); 72 | } 73 | 74 | /** 75 | * @param { string } severity 76 | * 77 | * @return { DiagnosticSeverity } 78 | */ 79 | function toLspSeverity(severity) { 80 | return ({ 81 | 'error': DiagnosticSeverity.Error, 82 | 'warn': DiagnosticSeverity.Warning, 83 | 'info': DiagnosticSeverity.Information, 84 | 'hint': DiagnosticSeverity.Hint 85 | })[severity] || DiagnosticSeverity.Information; 86 | } 87 | 88 | /** 89 | * Convert a unist position to a language server protocol range. 90 | * 91 | * If no position is given, a range is returned which represents the beginning 92 | * of the document. 93 | * 94 | * @param {UnistPosition|null|undefined} position 95 | * @returns {Range} 96 | */ 97 | function unistLocationToLspRange(position) { 98 | if (position) { 99 | const end = isValidUnistPoint(position.end) 100 | ? unistPointToLspPosition(position.end) 101 | : undefined; 102 | const start = isValidUnistPoint(position.start) 103 | ? unistPointToLspPosition(position.start) 104 | : end; 105 | 106 | if (start) { 107 | return Range.create(start, end || start); 108 | } 109 | } 110 | 111 | return Range.create(0, 0, 0, 0); 112 | } 113 | 114 | /** 115 | * Convert a vfile message to a language server protocol diagnostic. 116 | * 117 | * @param {VFileMessage & { position?: UnistPosition } } message 118 | * @returns {Diagnostic} 119 | */ 120 | // eslint-disable-next-line 121 | function vfileMessageToDiagnostic(message) { 122 | const diagnostic = Diagnostic.create( 123 | unistLocationToLspRange(message.position), 124 | String(message.stack || message.reason), 125 | message.fatal === true 126 | ? DiagnosticSeverity.Error 127 | : message.fatal === false 128 | ? DiagnosticSeverity.Warning 129 | : DiagnosticSeverity.Information, 130 | message.ruleId || undefined, 131 | message.source || undefined 132 | ); 133 | if (message.url) { 134 | diagnostic.codeDescription = { href: message.url }; 135 | } 136 | 137 | if (message.expected) { 138 | 139 | // Type-coverage:ignore-next-line 140 | diagnostic.data = { 141 | expected: message.expected 142 | }; 143 | } 144 | 145 | if (message.note) { 146 | diagnostic.message += '\n' + message.note; 147 | } 148 | 149 | return diagnostic; 150 | } 151 | 152 | /** 153 | * Convert language server protocol text document to a vfile. 154 | * 155 | * @param {TextDocument} document 156 | * @param {string} cwd 157 | * 158 | * @returns {VFile} 159 | */ 160 | // eslint-disable-next-line 161 | function lspDocumentToVFile(document, cwd) { 162 | return new VFile({ 163 | cwd, 164 | path: new URL(document.uri), 165 | value: document.getText() 166 | }); 167 | } 168 | 169 | /** 170 | * Create a language server for linked markdown editing. 171 | * 172 | * @return { Connection } 173 | */ 174 | export function createLanguageServer() { 175 | const connection = createConnection(ProposedFeatures.all); 176 | const documents = new TextDocuments(TextDocument); 177 | 178 | /** @type { Set } */ 179 | const diagnostics = new Set(); 180 | 181 | const logger = createLogger(connection); 182 | const markmark = new Markmark(logger); 183 | 184 | /** 185 | * Resolve references to symbol at location. 186 | */ 187 | connection.onReferences((event) => { 188 | 189 | logger.info('connection.onReferences', event); 190 | 191 | const uri = event.textDocument.uri; 192 | const position = lspPositionToUinstPoint(event.position); 193 | 194 | logger.info('connection.onReferences :: position', position); 195 | 196 | const refs = markmark.findReferences({ 197 | uri, 198 | position 199 | }); 200 | 201 | logger.info('connection.onReferences :: refs', refs); 202 | 203 | return refs.map( 204 | ref => Location.create(ref.uri, unistLocationToLspRange(ref.position)) 205 | ); 206 | }); 207 | 208 | markmark.on('linter:lint', /** @param { LinterResults[] } reports */ (reports) => { 209 | 210 | /** @type { Set } */ 211 | const oldDiagnostics = new Set(Array.from(diagnostics)); 212 | 213 | /** @type { Promise[] } */ 214 | const jobs = []; 215 | 216 | for (const report of reports) { 217 | 218 | const doc = documents.get(report.uri); 219 | 220 | // only report diagnostics for open files 221 | if (!doc) { 222 | continue; 223 | } 224 | 225 | logger.info('markmark :: linter:lint :: add diagnostics', doc.uri, report); 226 | 227 | jobs.push(connection.sendDiagnostics({ 228 | uri: report.uri, 229 | diagnostics: report.results.map(result => Diagnostic.create( 230 | unistLocationToLspRange(result.position), 231 | result.message, 232 | toLspSeverity(result.severity), 233 | undefined, 234 | 'markmark' 235 | )) 236 | }).catch(err => logger.error('FAILED TO SEND DIAGNISTOCS', err))); 237 | 238 | diagnostics.add(report.uri); 239 | oldDiagnostics.delete(report.uri); 240 | } 241 | 242 | for (const oldUri of oldDiagnostics) { 243 | 244 | logger.info('markmark :: linter:lint :: clear diagnostics', oldUri); 245 | 246 | diagnostics.delete(oldUri); 247 | 248 | jobs.push(connection.sendDiagnostics({ 249 | uri: oldUri, 250 | diagnostics: [] 251 | })); 252 | } 253 | 254 | Promise.all(jobs).catch(err => { 255 | logger.warn('markmark :: linter:lint :: failed to send diagnostics', err); 256 | }); 257 | }); 258 | 259 | connection.onDefinition((event) => { 260 | 261 | logger.info('connection.onDefinition', event); 262 | 263 | const uri = event.textDocument.uri; 264 | const position = lspPositionToUinstPoint(event.position); 265 | 266 | logger.info('connection.onDefinition :: position', position); 267 | 268 | const defs = markmark.findDefinitions({ 269 | uri, 270 | position 271 | }); 272 | 273 | logger.info('connection.onDefinition :: defs', defs); 274 | 275 | return defs.map( 276 | def => Location.create(def.uri, unistLocationToLspRange(def.position)) 277 | )[0]; 278 | }); 279 | 280 | connection.onCompletion(async (event, _completionItem, _workDoneProgress) => { 281 | 282 | const uri = event.textDocument.uri; 283 | const position = lspPositionToUinstPoint(event.position); 284 | 285 | logger.info('connection.onCompletion :: get', uri, position); 286 | 287 | try { 288 | const completions = await markmark.getCompletions({ 289 | uri, 290 | position 291 | }); 292 | 293 | return completions.map(completion => { 294 | 295 | const { 296 | label, 297 | replace: { 298 | position, 299 | newText 300 | }, 301 | detail 302 | } = completion; 303 | 304 | const completionItem = CompletionItem.create(label); 305 | 306 | completionItem.kind = CompletionItemKind.Reference; 307 | completionItem.textEdit = TextEdit.replace(unistLocationToLspRange(position), newText); 308 | completionItem.detail = detail; 309 | 310 | return completionItem; 311 | }); 312 | } catch (err) { 313 | 314 | // @ts-ignore 315 | logger.error(err.message, err.stack); 316 | 317 | return []; 318 | } 319 | }); 320 | 321 | connection.onInitialize((event, _cancelationToken, workDoneProgress, _resultProgress) => { 322 | logger.info('connection.onInitialize'); 323 | 324 | workDoneProgress.begin('Initializing Markmark language features'); 325 | 326 | if (event.workspaceFolders) { 327 | for (const workspace of event.workspaceFolders) { 328 | markmark.addRoot(workspace.uri); 329 | } 330 | } else if (event.rootUri) { 331 | markmark.addRoot(event.rootUri); 332 | } 333 | 334 | markmark.on('ready', () => { 335 | logger.info('connection.onInitialize'); 336 | 337 | workDoneProgress.done(); 338 | }); 339 | 340 | const hasWorkspaceFolderSupport = Boolean( 341 | event.capabilities.workspace && 342 | event.capabilities.workspace.workspaceFolders 343 | ); 344 | 345 | const hasFileWatchingSupport = Boolean( 346 | event.capabilities.workspace && 347 | event.capabilities.workspace.didChangeWatchedFiles 348 | ); 349 | 350 | logger.info('client support :: ', { 351 | fileWatching: hasFileWatchingSupport, 352 | workspaceFolders: hasWorkspaceFolderSupport 353 | }); 354 | 355 | if (hasWorkspaceFolderSupport) { 356 | connection.onInitialized(() => { 357 | 358 | connection.workspace.onDidChangeWorkspaceFolders((event) => { 359 | 360 | logger.info('connection.workspace.onDidChangeWorkspaceFolders', event); 361 | 362 | for (const workspace of event.removed) { 363 | markmark.removeRoot(workspace.uri); 364 | } 365 | 366 | for (const workspace of event.added) { 367 | markmark.addRoot(workspace.uri); 368 | } 369 | }); 370 | 371 | }); 372 | } 373 | 374 | if (hasFileWatchingSupport) { 375 | connection.client.register(DidChangeWatchedFilesNotification.type, { 376 | watchers: [ 377 | { globPattern: '**/*.md' } 378 | ] 379 | }); 380 | 381 | connection.onDidChangeWatchedFiles((event) => { 382 | 383 | for (const change of event.changes) { 384 | 385 | const { type, uri } = change; 386 | 387 | switch (type) { 388 | case FileChangeType.Changed: 389 | markmark.updateFile(uri); 390 | break; 391 | case FileChangeType.Created: 392 | markmark.addFile(uri); 393 | break; 394 | case FileChangeType.Deleted: 395 | markmark.removeFile(uri); 396 | break; 397 | } 398 | } 399 | }); 400 | } 401 | 402 | markmark.init({ 403 | watch: !hasFileWatchingSupport 404 | }); 405 | 406 | return { 407 | capabilities: { 408 | diagnosticProvider: { 409 | interFileDependencies: true, 410 | workspaceDiagnostics: false 411 | }, 412 | completionProvider: {}, 413 | textDocumentSync: TextDocumentSyncKind.Full, 414 | referencesProvider: true, 415 | codeActionProvider: { 416 | codeActionKinds: [ CodeActionKind.QuickFix ], 417 | resolveProvider: true 418 | }, 419 | definitionProvider: true, 420 | workspace: hasWorkspaceFolderSupport 421 | ? { 422 | workspaceFolders: { 423 | supported: true, 424 | changeNotifications: true 425 | } 426 | } 427 | : undefined 428 | } 429 | }; 430 | }); 431 | 432 | connection.onExit(() => { 433 | return markmark.close(); 434 | }); 435 | 436 | connection.onCodeAction((event) => { 437 | 438 | /** @type {CodeAction[]} */ 439 | const codeActions = []; 440 | 441 | const document = documents.get(event.textDocument.uri); 442 | 443 | // This might happen if a client calls this function without synchronizing 444 | // the document first. 445 | if (!document) { 446 | return; 447 | } 448 | 449 | for (const diagnostic of event.context.diagnostics) { 450 | 451 | const data = /** @type {{expected?: unknown[]}} */ (diagnostic.data); 452 | if (typeof data !== 'object' || !data) { 453 | continue; 454 | } 455 | 456 | const { expected } = data; 457 | 458 | if (!Array.isArray(expected)) { 459 | continue; 460 | } 461 | 462 | const { end, start } = diagnostic.range; 463 | const actual = document.getText(diagnostic.range); 464 | 465 | for (const replacement of expected) { 466 | if (typeof replacement !== 'string') { 467 | continue; 468 | } 469 | 470 | codeActions.push( 471 | CodeAction.create( 472 | replacement 473 | ? start.line === end.line && start.character === end.character 474 | ? 'Insert `' + replacement + '`' 475 | : 'Replace `' + actual + '` with `' + replacement + '`' 476 | : 'Remove `' + actual + '`', 477 | { 478 | changes: { 479 | [document.uri]: [ 480 | TextEdit.replace(diagnostic.range, replacement) 481 | ] 482 | } 483 | }, 484 | CodeActionKind.QuickFix 485 | ) 486 | ); 487 | } 488 | } 489 | 490 | return codeActions; 491 | }); 492 | 493 | documents.onDidOpen((event) => { 494 | 495 | const { 496 | document 497 | } = event; 498 | 499 | markmark.fileOpen({ 500 | uri: document.uri, 501 | value: document.getText() 502 | }); 503 | }); 504 | 505 | documents.onDidChangeContent((event) => { 506 | 507 | const { 508 | document 509 | } = event; 510 | 511 | markmark.fileContentChanged({ 512 | uri: document.uri, 513 | value: document.getText() 514 | }); 515 | 516 | }); 517 | 518 | documents.onDidClose((event) => { 519 | const { uri, version } = event.document; 520 | 521 | markmark.fileClosed(uri); 522 | 523 | connection.sendDiagnostics({ 524 | uri, 525 | version, 526 | diagnostics: [] 527 | }); 528 | }); 529 | 530 | documents.listen(connection); 531 | 532 | connection.listen(); 533 | 534 | return connection; 535 | } 536 | -------------------------------------------------------------------------------- /lib/language-server/logger.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param { import('vscode-languageserver').Connection } connection 3 | * 4 | * @return { import('../core/logger.js').default } 5 | */ 6 | export function createLogger(connection) { 7 | 8 | return { 9 | 10 | /** 11 | * @param { unknown[] } args 12 | */ 13 | log(...args) { 14 | return connection.console.log(toMessage(args)); 15 | }, 16 | 17 | /** 18 | * @param { unknown[] } args 19 | */ 20 | info(...args) { 21 | return connection.console.info(toMessage(args)); 22 | }, 23 | 24 | /** 25 | * @param { unknown[] } args 26 | */ 27 | warn(...args) { 28 | return connection.console.debug(toMessage(args)); 29 | }, 30 | 31 | /** 32 | * @param { unknown[] } args 33 | */ 34 | error(...args) { 35 | return connection.console.debug(toMessage(args)); 36 | } 37 | }; 38 | 39 | } 40 | 41 | 42 | // helpers /////////////// 43 | 44 | /** 45 | * @param { unknown[] } args 46 | * 47 | * @return { string } 48 | */ 49 | function toMessage(args) { 50 | return args.map(arg => { 51 | if (typeof arg === 'string') { 52 | return arg; 53 | } 54 | 55 | return JSON.stringify(arg, null, 2); 56 | }).join(' '); 57 | } 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markmark", 3 | "version": "0.5.1", 4 | "description": "Language tooling for markdown", 5 | "license": "MIT", 6 | "keywords": [ 7 | "language server", 8 | "lsp", 9 | "markdown" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/nikku/markmark" 14 | }, 15 | "author": "Nico Rehwaldt ", 16 | "type": "module", 17 | "bin": { 18 | "markmark-lsp": "./bin/lsp.js" 19 | }, 20 | "engines": { 21 | "node": ">= 16" 22 | }, 23 | "files": [ 24 | "bin", 25 | "lib" 26 | ], 27 | "dependencies": { 28 | "chokidar": "^4.0.3", 29 | "github-slugger": "^2.0.0", 30 | "mdast-util-to-string": "^4.0.0", 31 | "micromark-util-character": "^2.1.1", 32 | "micromark-util-symbol": "^2.0.1", 33 | "remark-frontmatter": "^5.0.0", 34 | "remark-gfm": "^4.0.0", 35 | "remark-parse": "^11.0.0", 36 | "to-vfile": "^8.0.0", 37 | "unified": "^11.0.5", 38 | "unist-util-index": "^4.0.0", 39 | "unist-util-visit": "^5.0.0", 40 | "vfile": "^6.0.3", 41 | "vfile-location": "^5.0.3", 42 | "vfile-message": "^4.0.2", 43 | "vscode-languageserver": "^9.0.1", 44 | "vscode-languageserver-textdocument": "^1.0.12" 45 | }, 46 | "devDependencies": { 47 | "@types/chai": "^4.3.20", 48 | "@types/mocha": "^10.0.10", 49 | "@types/node": "^20.17.11", 50 | "@types/unist": "^3.0.3", 51 | "chai": "^5.1.2", 52 | "eslint": "^9.17.0", 53 | "eslint-plugin-bpmn-io": "^2.0.2", 54 | "mocha": "^10.7.3", 55 | "typescript": "^5.7.2" 56 | }, 57 | "scripts": { 58 | "all": "npm run lint && npm test", 59 | "lint": "eslint . && tsc", 60 | "test": "mocha test/spec/**/*.js" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>bpmn-io/renovate-config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/completions/ANCHOR.md: -------------------------------------------------------------------------------- 1 | # Anchor 2 | 3 | # Deeplink 4 | -------------------------------------------------------------------------------- /test/fixtures/completions/BASE.md: -------------------------------------------------------------------------------- 1 | #fo # #fooop 2 | 3 | []() 4 | [foo]() 5 | [](#) 6 | [](../) 7 | [](./AN) 8 | [![asd](./asd.png)](./AN) 9 | 10 | ## Local 11 | 12 | asdf yes no 13 | -------------------------------------------------------------------------------- /test/fixtures/completions/HEADING.md: -------------------------------------------------------------------------------- 1 | # 2 | -------------------------------------------------------------------------------- /test/fixtures/completions/TAGGED.md: -------------------------------------------------------------------------------- 1 | # Tagged 2 | 3 | #other #bar # 4 | -------------------------------------------------------------------------------- /test/fixtures/linter/EXTERNAL.md: -------------------------------------------------------------------------------- 1 | Non-empty file! 2 | 3 | [External URL](https://foo.md) 4 | -------------------------------------------------------------------------------- /test/fixtures/linter/IMG.md: -------------------------------------------------------------------------------- 1 | ![IMG](./non-existing-image.png) 2 | 3 | [IMG link](./non-existing-image) 4 | -------------------------------------------------------------------------------- /test/fixtures/linter/README.md: -------------------------------------------------------------------------------- 1 | # foo 2 | 3 | [ASD](#foo) 4 | [EXTERNAL](./EXTERNAL.md) 5 | 6 | [NON_EXISTING](#bar) 7 | 8 | [EXTERNAL NON_EXISTING](./EXTERNAL.md#off) 9 | 10 | [OTHER NON_EXISTING](./OTHER.md) 11 | -------------------------------------------------------------------------------- /test/fixtures/linter/nested/README.md: -------------------------------------------------------------------------------- 1 | [asd](../README.md) 2 | [asd](../README.md#foo) 3 | -------------------------------------------------------------------------------- /test/fixtures/notes/.markmarkrc: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /test/fixtures/notes/IDEAS.md: -------------------------------------------------------------------------------- 1 | # Ideas 2 | 3 | #PUNCH_LINE 4 | 5 | 6 | ## Connect This and That 7 | 8 | To get super powers, cf. [notes](./NOTES.md#deeplink). 9 | 10 | 11 | --- 12 | 13 | Back to [top](#ideas). 14 | -------------------------------------------------------------------------------- /test/fixtures/notes/NOTES.md: -------------------------------------------------------------------------------- 1 | # Notes 2 | 3 | ## Deeplink 4 | 5 | -------------------------------------------------------------------------------- /test/fixtures/notes/ideas/PUNCH_LINE.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikku/markmark/1810142d2bd00407e03c173ced74f9e1839dc0ec/test/fixtures/notes/ideas/PUNCH_LINE.md -------------------------------------------------------------------------------- /test/fixtures/notes/ideas/nested/NESTED_IDEAS.md: -------------------------------------------------------------------------------- 1 | # Nested Ideas 2 | 3 | ## Some stuff 4 | 5 | ![](./image.png) 6 | -------------------------------------------------------------------------------- /test/fixtures/special/EXTERNAL.md: -------------------------------------------------------------------------------- 1 | [link](https://github.com) 2 | -------------------------------------------------------------------------------- /test/fixtures/special/IMG.md: -------------------------------------------------------------------------------- 1 | ![image](./img.png) 2 | 3 | [image link](./img.png) 4 | -------------------------------------------------------------------------------- /test/fixtures/special/img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikku/markmark/1810142d2bd00407e03c173ced74f9e1839dc0ec/test/fixtures/special/img.png -------------------------------------------------------------------------------- /test/spec/core/completions.spec.js: -------------------------------------------------------------------------------- 1 | import Indexer from '../../../lib/core/indexer.js'; 2 | import Processor from '../../../lib/core/processor.js'; 3 | import Workqueue from '../../../lib/core/workqueue.js'; 4 | import Completions from '../../../lib/core/completions.js'; 5 | import References from '../../../lib/core/references.js'; 6 | 7 | import { EventEmitter } from 'node:events'; 8 | 9 | import { expect } from 'chai'; 10 | 11 | import { fileUri } from './helper.js'; 12 | 13 | const NOTES_ROOT_URI = fileUri('test/fixtures/notes'); 14 | const IDEAS_URI = fileUri('test/fixtures/notes/IDEAS.md'); 15 | 16 | const COMPLETIONS_ROOT_URI = fileUri('test/fixtures/completions'); 17 | const BASE_URI = fileUri('test/fixtures/completions/BASE.md'); 18 | const HEADING_URI = fileUri('test/fixtures/completions/HEADING.md'); 19 | const ANCHOR_URI = fileUri('test/fixtures/completions/ANCHOR.md'); 20 | const TAGGED_URI = fileUri('test/fixtures/completions/TAGGED.md'); 21 | 22 | 23 | describe('core/completions', function() { 24 | 25 | /** 26 | * @type { Processor } 27 | */ 28 | let processor; 29 | 30 | /** 31 | * @type { Indexer } 32 | */ 33 | let indexer; 34 | 35 | /** 36 | * @type { Workqueue } 37 | */ 38 | let workqueue; 39 | 40 | /** 41 | * @type { EventEmitter } 42 | */ 43 | let eventBus; 44 | 45 | /** 46 | * @type { Completions } 47 | */ 48 | let completions; 49 | 50 | /** 51 | * @type { References } 52 | */ 53 | let references; 54 | 55 | 56 | beforeEach(function() { 57 | eventBus = new EventEmitter(); 58 | workqueue = new Workqueue(eventBus); 59 | processor = new Processor(console); 60 | indexer = new Indexer(console, eventBus, processor, workqueue); 61 | references = new References(console, eventBus); 62 | completions = new Completions(console, indexer, references); 63 | }); 64 | 65 | 66 | beforeEach(async function() { 67 | indexer.addRoot(COMPLETIONS_ROOT_URI); 68 | await indexer.add(ANCHOR_URI); 69 | await indexer.add(BASE_URI); 70 | await indexer.add(TAGGED_URI); 71 | await indexer.add(HEADING_URI); 72 | 73 | indexer.addRoot(NOTES_ROOT_URI); 74 | await indexer.add(IDEAS_URI); 75 | }); 76 | 77 | 78 | it('should complete tag', async function() { 79 | 80 | // when 81 | const items = await completions.get({ 82 | uri: BASE_URI, 83 | position: { 84 | line: 1, 85 | column: 3 86 | } 87 | }); 88 | 89 | // then 90 | expect(items).to.eql([ 91 | { 92 | label: '#fo', 93 | replace: { 94 | position: { 95 | start: { 96 | line: 1, 97 | column: 1, 98 | offset: 0 99 | }, 100 | end: { 101 | line: 1, 102 | column: 4, 103 | offset: 3 104 | } 105 | }, 106 | newText: '#fo' 107 | } 108 | }, 109 | { 110 | label: '#fooop', 111 | replace: { 112 | position: { 113 | start: { 114 | line: 1, 115 | column: 1, 116 | offset: 0 117 | }, 118 | end: { 119 | line: 1, 120 | column: 4, 121 | offset: 3 122 | } 123 | }, 124 | newText: '#fooop' 125 | } 126 | } 127 | ]); 128 | }); 129 | 130 | 131 | it('should complete text <#>', async function() { 132 | 133 | // #fo #| #fooop 134 | 135 | // when 136 | const items = await completions.get({ 137 | uri: BASE_URI, 138 | position: { 139 | line: 1, 140 | column: 6 141 | } 142 | }); 143 | 144 | const expectedCompletions = [ 145 | 'fo', 146 | 'fooop', 147 | 'other', 148 | 'bar' 149 | ].map(tag => ({ 150 | label: '#' + tag, 151 | replace: { 152 | position: { 153 | start: { 154 | line: 1, 155 | column: 5, 156 | offset: 4 157 | }, 158 | end: { 159 | line: 1, 160 | column: 6, 161 | offset: 5 162 | } 163 | }, 164 | newText: '#' + tag 165 | } 166 | })); 167 | 168 | // then 169 | expect(items).to.eql(expectedCompletions); 170 | }); 171 | 172 | 173 | it('should complete heading <#>', async function() { 174 | 175 | // #| 176 | 177 | // when 178 | const items = await completions.get({ 179 | uri: HEADING_URI, 180 | position: { 181 | line: 1, 182 | column: 2 183 | } 184 | }); 185 | 186 | const expectedCompletions = [ 187 | 'fo', 188 | 'fooop', 189 | 'other', 190 | 'bar' 191 | ].map(tag => ({ 192 | label: '#' + tag, 193 | replace: { 194 | position: { 195 | start: { 196 | line: 1, 197 | column: 1, 198 | offset: 0 199 | }, 200 | end: { 201 | line: 1, 202 | column: 2, 203 | offset: 1 204 | } 205 | }, 206 | newText: '#' + tag 207 | } 208 | })); 209 | 210 | // then 211 | expect(items).to.eql(expectedCompletions); 212 | }); 213 | 214 | 215 | it('should NOT complete link name', async function() { 216 | 217 | // [|]() 218 | 219 | // when 220 | const nameCompletions = await completions.get({ 221 | uri: BASE_URI, 222 | position: { 223 | line: 3, 224 | column: 2 225 | } 226 | }); 227 | 228 | // then 229 | expect(nameCompletions).to.eql([]); 230 | }); 231 | 232 | 233 | it('should NOT complete text within root', async function() { 234 | 235 | // asdf yes| no 236 | 237 | // when 238 | const refCompletions = await completions.get({ 239 | uri: BASE_URI, 240 | position: { 241 | line: 12, 242 | column: 10 243 | } 244 | }); 245 | 246 | // then 247 | expect(refCompletions).to.eql([]); 248 | }); 249 | 250 | 251 | it('should complete link ref', async function() { 252 | 253 | // [](|) 254 | 255 | // when 256 | const refCompletions = await completions.get({ 257 | uri: BASE_URI, 258 | position: { 259 | line: 3, 260 | column: 4 261 | } 262 | }); 263 | 264 | // then 265 | expect(refCompletions).to.eql([ 266 | { 267 | 'label': './ANCHOR.md#anchor', 268 | 'replace': { 269 | 'position': { 270 | 'start': { 271 | 'line': 3, 272 | 'column': 4, 273 | 'offset': 17 274 | }, 275 | 'end': { 276 | 'line': 3, 277 | 'column': 4, 278 | 'offset': 17 279 | } 280 | }, 281 | 'newText': './ANCHOR.md#anchor' 282 | } 283 | }, 284 | { 285 | 'label': './ANCHOR.md#deeplink', 286 | 'replace': { 287 | 'position': { 288 | 'start': { 289 | 'line': 3, 290 | 'column': 4, 291 | 'offset': 17 292 | }, 293 | 'end': { 294 | 'line': 3, 295 | 'column': 4, 296 | 'offset': 17 297 | } 298 | }, 299 | 'newText': './ANCHOR.md#deeplink' 300 | } 301 | }, 302 | { 303 | 'label': './ANCHOR.md', 304 | 'replace': { 305 | 'position': { 306 | 'start': { 307 | 'line': 3, 308 | 'column': 4, 309 | 'offset': 17 310 | }, 311 | 'end': { 312 | 'line': 3, 313 | 'column': 4, 314 | 'offset': 17 315 | } 316 | }, 317 | 'newText': './ANCHOR.md' 318 | } 319 | }, 320 | { 321 | 'label': '#local', 322 | 'replace': { 323 | 'position': { 324 | 'start': { 325 | 'line': 3, 326 | 'column': 4, 327 | 'offset': 17 328 | }, 329 | 'end': { 330 | 'line': 3, 331 | 'column': 4, 332 | 'offset': 17 333 | } 334 | }, 335 | 'newText': '#local' 336 | } 337 | }, 338 | { 339 | 'label': './TAGGED.md#tagged', 340 | 'replace': { 341 | 'position': { 342 | 'start': { 343 | 'line': 3, 344 | 'column': 4, 345 | 'offset': 17 346 | }, 347 | 'end': { 348 | 'line': 3, 349 | 'column': 4, 350 | 'offset': 17 351 | } 352 | }, 353 | 'newText': './TAGGED.md#tagged' 354 | } 355 | }, 356 | { 357 | 'label': './TAGGED.md', 358 | 'replace': { 359 | 'position': { 360 | 'start': { 361 | 'line': 3, 362 | 'column': 4, 363 | 'offset': 17 364 | }, 365 | 'end': { 366 | 'line': 3, 367 | 'column': 4, 368 | 'offset': 17 369 | } 370 | }, 371 | 'newText': './TAGGED.md' 372 | } 373 | }, 374 | { 375 | 'label': './HEADING.md', 376 | 'replace': { 377 | 'newText': './HEADING.md', 378 | 'position': { 379 | 'end': { 380 | 'column': 4, 381 | 'line': 3, 382 | 'offset': 17 383 | }, 384 | 'start': { 385 | 'column': 4, 386 | 'line': 3, 387 | 'offset': 17 388 | } 389 | } 390 | } 391 | } 392 | ]); 393 | }); 394 | 395 | 396 | it('should complete link ref with # prefix', async function() { 397 | 398 | // [](|) 399 | 400 | // when 401 | const refCompletions = await completions.get({ 402 | uri: BASE_URI, 403 | position: { 404 | line: 5, 405 | column: 5 406 | } 407 | }); 408 | 409 | const expectedCompletions = [ 410 | './ANCHOR.md#anchor', 411 | './ANCHOR.md#deeplink', 412 | '#local', 413 | './TAGGED.md#tagged' 414 | ].map(ref => ({ 415 | 'label': ref, 416 | 'replace': { 417 | 'position': { 418 | 'start': { 419 | 'line': 5, 420 | 'column': 4, 421 | 'offset': 30 422 | }, 423 | 'end': { 424 | 'line': 5, 425 | 'column': 5, 426 | 'offset': 31 427 | } 428 | }, 429 | 'newText': ref 430 | } 431 | })); 432 | 433 | // then 434 | expect(refCompletions).to.eql(expectedCompletions); 435 | }); 436 | 437 | 438 | it('should complete link with name ref', async function() { 439 | 440 | // [foo](|) 441 | 442 | // when 443 | const refCompletions = await completions.get({ 444 | uri: BASE_URI, 445 | position: { 446 | line: 4, 447 | column: 7 448 | } 449 | }); 450 | 451 | const expectedCompletions = [ 452 | './ANCHOR.md#anchor', 453 | './ANCHOR.md#deeplink', 454 | './ANCHOR.md', 455 | '#local', 456 | './TAGGED.md#tagged', 457 | './TAGGED.md', 458 | './HEADING.md' 459 | ].map(ref => ({ 460 | 'label': ref, 461 | 'replace': { 462 | 'position': { 463 | 'start': { 464 | 'line': 4, 465 | 'column': 7, 466 | 'offset': 25 467 | }, 468 | 'end': { 469 | 'line': 4, 470 | 'column': 7, 471 | 'offset': 25 472 | } 473 | }, 474 | 'newText': ref 475 | } 476 | })); 477 | 478 | // then 479 | expect(refCompletions).to.eql(expectedCompletions); 480 | }); 481 | 482 | 483 | it('should complete link ref with prefix', async function() { 484 | 485 | // [](./A|N) 486 | 487 | // when 488 | const refCompletions = await completions.get({ 489 | uri: BASE_URI, 490 | position: { 491 | line: 7, 492 | column: 7 493 | } 494 | }); 495 | 496 | // then 497 | expect(refCompletions).to.eql([ 498 | { 499 | 'label': './ANCHOR.md#anchor', 500 | 'replace': { 501 | 'position': { 502 | 'start': { 503 | 'line': 7, 504 | 'column': 4, 505 | 'offset': 44 506 | }, 507 | 'end': { 508 | 'line': 7, 509 | 'column': 8, 510 | 'offset': 48 511 | } 512 | }, 513 | 'newText': './ANCHOR.md#anchor' 514 | } 515 | }, 516 | { 517 | 'label': './ANCHOR.md#deeplink', 518 | 'replace': { 519 | 'position': { 520 | 'start': { 521 | 'line': 7, 522 | 'column': 4, 523 | 'offset': 44 524 | }, 525 | 'end': { 526 | 'line': 7, 527 | 'column': 8, 528 | 'offset': 48 529 | } 530 | }, 531 | 'newText': './ANCHOR.md#deeplink' 532 | } 533 | }, 534 | { 535 | 'label': './ANCHOR.md', 536 | 'replace': { 537 | 'position': { 538 | 'start': { 539 | 'line': 7, 540 | 'column': 4, 541 | 'offset': 44 542 | }, 543 | 'end': { 544 | 'line': 7, 545 | 'column': 8, 546 | 'offset': 48 547 | } 548 | }, 549 | 'newText': './ANCHOR.md' 550 | } 551 | } 552 | ]); 553 | }); 554 | 555 | 556 | it('should complete link (with image) ref', async function() { 557 | 558 | // [![asd](./asd.png)](./A|N) 559 | 560 | // when 561 | const refCompletions = await completions.get({ 562 | uri: BASE_URI, 563 | position: { 564 | line: 8, 565 | column: 24 566 | } 567 | }); 568 | 569 | const expectedCompletions = [ 570 | './ANCHOR.md#anchor', 571 | './ANCHOR.md#deeplink', 572 | './ANCHOR.md' 573 | ].map(ref => ({ 574 | 'label': ref, 575 | 'replace': { 576 | 'position': { 577 | 'start': { 578 | 'line': 8, 579 | 'column': 21, 580 | 'offset': 70 581 | }, 582 | 'end': { 583 | 'line': 8, 584 | 'column': 25, 585 | 'offset': 74 586 | } 587 | }, 588 | 'newText': ref 589 | } 590 | })); 591 | 592 | // then 593 | expect(refCompletions).to.eql(expectedCompletions); 594 | }); 595 | 596 | }); 597 | 598 | 599 | // eslint-disable-next-line 600 | function on(event, eventBus) { 601 | return new Promise((resolve) => { 602 | eventBus.once(event, resolve); 603 | }); 604 | } 605 | -------------------------------------------------------------------------------- /test/spec/core/helper.js: -------------------------------------------------------------------------------- 1 | import { pathToFileURL } from 'node:url'; 2 | 3 | /** 4 | * @param { string } path 5 | * @return { string } uri 6 | */ 7 | export function fileUri(path) { 8 | return pathToFileURL(path).toString(); 9 | } 10 | -------------------------------------------------------------------------------- /test/spec/core/indexer.spec.js: -------------------------------------------------------------------------------- 1 | import Indexer from '../../../lib/core/indexer.js'; 2 | import Processor from '../../../lib/core/processor.js'; 3 | import Workqueue from '../../../lib/core/workqueue.js'; 4 | 5 | import { EventEmitter } from 'node:events'; 6 | 7 | import { expect } from 'chai'; 8 | import { fileUri } from './helper.js'; 9 | 10 | 11 | describe('core/indexer', function() { 12 | 13 | let processor, indexer, workqueue, eventBus; 14 | 15 | beforeEach(function() { 16 | eventBus = new EventEmitter(); 17 | workqueue = new Workqueue(eventBus); 18 | processor = new Processor(console); 19 | indexer = new Indexer(console, eventBus, processor, workqueue); 20 | }); 21 | 22 | 23 | it('should index directory', async function() { 24 | 25 | // when 26 | await addFiles([ 27 | 'test/fixtures/notes/ideas/PUNCH_LINE.md', 28 | 'test/fixtures/notes/ideas/nested/NESTED_IDEAS.md', 29 | 'test/fixtures/notes/IDEAS.md', 30 | 'test/fixtures/notes/NOTES.md' 31 | ]); 32 | 33 | // then 34 | const items = indexer.getItems(); 35 | 36 | expect(items).to.have.length(4); 37 | 38 | for (const item of items) { 39 | expect(item.parseTree).to.exist; 40 | expect(item.parseTree.anchors).to.exist; 41 | expect(item.parseTree.links).to.exist; 42 | expect(item.parseTree.tags).to.exist; 43 | } 44 | }); 45 | 46 | 47 | it('should emit ', async function() { 48 | 49 | // when 50 | const items = []; 51 | 52 | eventBus.on('indexer:updated', (item) => { 53 | items.push(item); 54 | }); 55 | 56 | await addFiles([ 57 | 'test/fixtures/notes/ideas/PUNCH_LINE.md', 58 | 'test/fixtures/notes/ideas/nested/NESTED_IDEAS.md', 59 | 'test/fixtures/notes/IDEAS.md', 60 | 'test/fixtures/notes/NOTES.md' 61 | ]); 62 | 63 | // then 64 | expect(items).to.have.length(4); 65 | 66 | for (const item of items) { 67 | expect(item.parseTree).to.exist; 68 | expect(item.parseTree.anchors).to.exist; 69 | expect(item.parseTree.links).to.exist; 70 | expect(item.parseTree.tags).to.exist; 71 | } 72 | }); 73 | 74 | 75 | it('should remove item', async function() { 76 | 77 | // given 78 | const removedItems = []; 79 | 80 | eventBus.on('indexer:removed', (item) => { 81 | removedItems.push(item); 82 | }); 83 | 84 | await addFiles([ 85 | 'test/fixtures/notes/ideas/PUNCH_LINE.md', 86 | 'test/fixtures/notes/ideas/nested/NESTED_IDEAS.md', 87 | 'test/fixtures/notes/IDEAS.md', 88 | 'test/fixtures/notes/NOTES.md' 89 | ]); 90 | 91 | // when 92 | const [ firstItem ] = indexer.getItems(); 93 | 94 | // removing locally 95 | indexer.remove(firstItem.uri, true); 96 | 97 | // then 98 | expect(removedItems).to.be.empty; 99 | 100 | // actually removing 101 | indexer.remove(firstItem.uri); 102 | 103 | // then 104 | expect(removedItems).to.eql([ firstItem ]); 105 | }); 106 | 107 | 108 | it('should eagerly fetch index item', async function() { 109 | 110 | // given 111 | // file-backed version added 112 | const uri = await addFile('test/fixtures/notes/IDEAS.md'); 113 | 114 | // when 115 | const item = await indexer.get(uri); 116 | 117 | // then 118 | expect(item.parseTree).to.exist; 119 | expect(item.parseTree.anchors).to.have.length(3); 120 | expect(item.parseTree.links).to.have.length(2); 121 | expect(item.parseTree.tags).to.have.length(1); 122 | 123 | // but when 124 | // local override added 125 | indexer.fileOpen({ 126 | uri, 127 | value: '# hello world!' 128 | }); 129 | 130 | // when 131 | const openedItem = await indexer.get(uri); 132 | 133 | // then 134 | expect(openedItem.parseTree).to.exist; 135 | expect(openedItem.parseTree.anchors).to.have.length(2); 136 | expect(openedItem.parseTree.links).to.have.length(0); 137 | 138 | // but when 139 | // local override closed 140 | indexer.fileClosed(uri); 141 | 142 | // when 143 | const closedItem = await indexer.get(uri); 144 | 145 | // then 146 | expect(closedItem.parseTree).to.exist; 147 | expect(closedItem.parseTree.anchors).to.have.length(3); 148 | expect(closedItem.parseTree.links).to.have.length(2); 149 | }); 150 | 151 | 152 | it('should handle non-existing file', async function() { 153 | 154 | // given 155 | // file-backed version added 156 | const uri = await addFile('test/fixtures/NON_EXISTING.md'); 157 | 158 | // when 159 | const item = await indexer.get(uri); 160 | 161 | // then 162 | expect(item.parseTree).to.exist; 163 | expect(item.parseTree.anchors).to.have.length(1); 164 | expect(item.parseTree.links).to.have.length(0); 165 | expect(item.parseTree.tags).to.have.length(0); 166 | }); 167 | 168 | 169 | it('should keep local file after file indexing', async function() { 170 | 171 | // given 172 | const uri = fileUri('test/fixtures/notes/IDEAS.md'); 173 | 174 | await indexer.fileOpen({ 175 | uri, 176 | value: 'FOO' 177 | }); 178 | 179 | // when 180 | const item = await indexer.add(uri); 181 | 182 | // then 183 | expect(item.value).to.eql('FOO'); 184 | 185 | expect( 186 | indexer.getItems() 187 | ).to.have.length(1); 188 | }); 189 | 190 | 191 | it('should keep local file after removing globally indexed', async function() { 192 | 193 | // given 194 | const removedItems = []; 195 | 196 | eventBus.on('indexer:removed', (item) => { 197 | removedItems.push(item); 198 | }); 199 | 200 | const uri = fileUri('test/fixtures/notes/IDEAS.md'); 201 | 202 | await indexer.fileOpen({ 203 | uri, 204 | value: 'FOO' 205 | }); 206 | 207 | const item = await indexer.add(uri); 208 | 209 | // when 210 | // removing global item 211 | await indexer.remove(uri); 212 | 213 | // then 214 | // item still locally opened 215 | expect(removedItems).to.be.empty; 216 | 217 | // removing local item 218 | await indexer.fileClosed(uri); 219 | 220 | // then 221 | expect(removedItems).to.eql([ item ]); 222 | }); 223 | 224 | 225 | function addFiles(paths) { 226 | 227 | for (const path of paths) { 228 | addFile(path); 229 | } 230 | 231 | return on('workqueue:empty', eventBus); 232 | } 233 | 234 | function addFile(path) { 235 | const uri = fileUri(path); 236 | 237 | indexer.add(uri); 238 | 239 | return uri; 240 | } 241 | 242 | }); 243 | 244 | 245 | function on(event, eventBus) { 246 | return new Promise((resolve) => { 247 | eventBus.once(event, resolve); 248 | }); 249 | } 250 | -------------------------------------------------------------------------------- /test/spec/core/linter.results.README.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "position": { 4 | "start": { 5 | "line": 6, 6 | "column": 1, 7 | "offset": 46 8 | }, 9 | "end": { 10 | "line": 6, 11 | "column": 21, 12 | "offset": 66 13 | } 14 | }, 15 | "message": "Target is unresolved", 16 | "severity": "warn" 17 | }, 18 | { 19 | "position": { 20 | "start": { 21 | "line": 8, 22 | "column": 1, 23 | "offset": 68 24 | }, 25 | "end": { 26 | "line": 8, 27 | "column": 43, 28 | "offset": 110 29 | } 30 | }, 31 | "message": "Target is unresolved", 32 | "severity": "warn" 33 | }, 34 | { 35 | "position": { 36 | "start": { 37 | "line": 10, 38 | "column": 1, 39 | "offset": 112 40 | }, 41 | "end": { 42 | "line": 10, 43 | "column": 33, 44 | "offset": 144 45 | } 46 | }, 47 | "message": "Target is unresolved", 48 | "severity": "warn" 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /test/spec/core/linter.spec.js: -------------------------------------------------------------------------------- 1 | import Indexer from '../../../lib/core/indexer.js'; 2 | import Processor from '../../../lib/core/processor.js'; 3 | import Workqueue from '../../../lib/core/workqueue.js'; 4 | import References from '../../../lib/core/references.js'; 5 | import Linter from '../../../lib/core/linter.js'; 6 | 7 | import { EventEmitter } from 'node:events'; 8 | 9 | import { expect } from 'chai'; 10 | import { fileUri } from './helper.js'; 11 | 12 | import { createRequire } from 'module'; 13 | 14 | const require = createRequire(import.meta.url); 15 | 16 | const results_README = require('./linter.results.README.json'); 17 | 18 | 19 | describe('core/linter', function() { 20 | 21 | let processor, indexer, 22 | workqueue, eventBus, references; 23 | 24 | beforeEach(function() { 25 | eventBus = new EventEmitter(); 26 | workqueue = new Workqueue(eventBus); 27 | processor = new Processor(console); 28 | indexer = new Indexer(console, eventBus, processor, workqueue); 29 | references = new References(console, eventBus); 30 | 31 | new Linter(console, eventBus, references); 32 | }); 33 | 34 | 35 | it('should lint directory', async function() { 36 | 37 | // given 38 | const uris = await addFiles([ 39 | 'test/fixtures/linter/README.md', 40 | 'test/fixtures/linter/EXTERNAL.md', 41 | 'test/fixtures/linter/IMG.md', 42 | 'test/fixtures/linter/nested/README.md' 43 | ]); 44 | 45 | // when 46 | const reports = await on('linter:lint', eventBus); 47 | 48 | // then 49 | expect(reports).to.exist; 50 | 51 | // only README is invalid 52 | expect(reports).to.have.length(1); 53 | 54 | const [ firstReport ] = reports; 55 | 56 | expect(firstReport).to.eql({ 57 | uri: uris[0], 58 | results: results_README 59 | }); 60 | }); 61 | 62 | 63 | async function addFiles(paths) { 64 | 65 | const uris = paths.map(addFile); 66 | 67 | await on('workqueue:empty', eventBus); 68 | 69 | return uris; 70 | } 71 | 72 | function addFile(path) { 73 | const uri = fileUri(path); 74 | 75 | indexer.add(uri); 76 | 77 | return uri; 78 | } 79 | 80 | }); 81 | 82 | 83 | function on(event, eventBus) { 84 | return new Promise((resolve) => { 85 | eventBus.once(event, resolve); 86 | }); 87 | } 88 | -------------------------------------------------------------------------------- /test/spec/core/markdown.parseTree.LINKS.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "root", 3 | "children": [ 4 | { 5 | "type": "heading", 6 | "depth": 1, 7 | "children": [ 8 | { 9 | "type": "text", 10 | "value": "heading", 11 | "position": { 12 | "start": { 13 | "line": 2, 14 | "column": 3, 15 | "offset": 3 16 | }, 17 | "end": { 18 | "line": 2, 19 | "column": 10, 20 | "offset": 10 21 | } 22 | } 23 | } 24 | ], 25 | "position": { 26 | "start": { 27 | "line": 2, 28 | "column": 1, 29 | "offset": 1 30 | }, 31 | "end": { 32 | "line": 2, 33 | "column": 10, 34 | "offset": 10 35 | } 36 | } 37 | }, 38 | { 39 | "type": "paragraph", 40 | "children": [ 41 | { 42 | "type": "tag", 43 | "value": "some-tag", 44 | "position": { 45 | "start": { 46 | "line": 4, 47 | "column": 1, 48 | "offset": 12 49 | }, 50 | "end": { 51 | "line": 4, 52 | "column": 10, 53 | "offset": 21 54 | } 55 | } 56 | }, 57 | { 58 | "type": "text", 59 | "value": ", ", 60 | "position": { 61 | "start": { 62 | "line": 4, 63 | "column": 10, 64 | "offset": 21 65 | }, 66 | "end": { 67 | "line": 4, 68 | "column": 12, 69 | "offset": 23 70 | } 71 | } 72 | }, 73 | { 74 | "type": "tag", 75 | "value": "other_tag", 76 | "position": { 77 | "start": { 78 | "line": 4, 79 | "column": 12, 80 | "offset": 23 81 | }, 82 | "end": { 83 | "line": 4, 84 | "column": 22, 85 | "offset": 33 86 | } 87 | } 88 | }, 89 | { 90 | "type": "text", 91 | "value": "\n", 92 | "position": { 93 | "start": { 94 | "line": 4, 95 | "column": 22, 96 | "offset": 33 97 | }, 98 | "end": { 99 | "line": 5, 100 | "column": 1, 101 | "offset": 34 102 | } 103 | } 104 | }, 105 | { 106 | "type": "tag", 107 | "value": "tag", 108 | "position": { 109 | "start": { 110 | "line": 5, 111 | "column": 1, 112 | "offset": 34 113 | }, 114 | "end": { 115 | "line": 5, 116 | "column": 5, 117 | "offset": 38 118 | } 119 | } 120 | }, 121 | { 122 | "type": "text", 123 | "value": " no-tag\n", 124 | "position": { 125 | "start": { 126 | "line": 5, 127 | "column": 5, 128 | "offset": 38 129 | }, 130 | "end": { 131 | "line": 6, 132 | "column": 1, 133 | "offset": 46 134 | } 135 | } 136 | }, 137 | { 138 | "type": "link", 139 | "title": null, 140 | "url": "./rel#link", 141 | "children": [], 142 | "position": { 143 | "start": { 144 | "line": 6, 145 | "column": 1, 146 | "offset": 46 147 | }, 148 | "end": { 149 | "line": 6, 150 | "column": 15, 151 | "offset": 60 152 | } 153 | } 154 | }, 155 | { 156 | "type": "text", 157 | "value": "\n", 158 | "position": { 159 | "start": { 160 | "line": 6, 161 | "column": 15, 162 | "offset": 60 163 | }, 164 | "end": { 165 | "line": 7, 166 | "column": 1, 167 | "offset": 61 168 | } 169 | } 170 | }, 171 | { 172 | "type": "image", 173 | "title": null, 174 | "url": "./rel.png#image", 175 | "alt": "", 176 | "position": { 177 | "start": { 178 | "line": 7, 179 | "column": 1, 180 | "offset": 61 181 | }, 182 | "end": { 183 | "line": 7, 184 | "column": 21, 185 | "offset": 81 186 | } 187 | } 188 | } 189 | ], 190 | "position": { 191 | "start": { 192 | "line": 4, 193 | "column": 1, 194 | "offset": 12 195 | }, 196 | "end": { 197 | "line": 7, 198 | "column": 21, 199 | "offset": 81 200 | } 201 | } 202 | } 203 | ], 204 | "position": { 205 | "start": { 206 | "line": 1, 207 | "column": 1, 208 | "offset": 0 209 | }, 210 | "end": { 211 | "line": 8, 212 | "column": 1, 213 | "offset": 82 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /test/spec/core/markdown.spec.js: -------------------------------------------------------------------------------- 1 | import _remark from '../../../lib/core/markdown/remark.js'; 2 | 3 | import { toVFile } from 'to-vfile'; 4 | 5 | import { expect } from 'chai'; 6 | 7 | import { createRequire } from 'module'; 8 | 9 | const require = createRequire(import.meta.url); 10 | 11 | const parseTree_LINKS = require('./markdown.parseTree.LINKS.json'); 12 | 13 | 14 | describe('core/markdown', function() { 15 | 16 | const remark = _remark(); 17 | 18 | 19 | describe('should transform markdown', function() { 20 | 21 | it('basic', async function() { 22 | 23 | // given 24 | const markdown = ` 25 | # Ideas 26 | 27 | #foo 28 | 29 | [](./NOTES.md) 30 | [[PUNCH_LINE]] 31 | 32 | ## Connect This and That 33 | 34 | To get super powers. 35 | 36 | 37 | [](./NOTES.md#deeplink) 38 | 39 | [local link](#ideas) 40 | 41 | ![img](./image.png) 42 | [image link](./img.svg) 43 | 44 | [external-uri](https://foobar.com) 45 | `; 46 | 47 | const file = toVFile({ value: markdown }); 48 | 49 | // when 50 | const tree = remark.parse(file); 51 | 52 | const transformedTree = await remark.run(tree, file); 53 | 54 | // then 55 | expect(transformedTree.links).to.have.length(6); 56 | expect(transformedTree.anchors).to.have.length(3); 57 | expect(transformedTree.tags).to.have.length(1); 58 | }); 59 | 60 | 61 | it('external links', async function() { 62 | 63 | // given 64 | const markdown = ` 65 | [external-uri](https://foobar.com) 66 | `; 67 | 68 | const file = toVFile({ value: markdown }); 69 | 70 | // when 71 | const tree = remark.parse(file); 72 | 73 | const transformedTree = await remark.run(tree, file); 74 | 75 | // then 76 | expect(transformedTree.links).to.have.length(1); 77 | expect(transformedTree.anchors).to.have.length(1); 78 | expect(transformedTree.tags).to.have.length(0); 79 | }); 80 | 81 | 82 | it('image links', async function() { 83 | 84 | // given 85 | const markdown = ` 86 | ![img](./image.png) 87 | [image link](./img.svg) 88 | `; 89 | 90 | const file = toVFile({ value: markdown }); 91 | 92 | // when 93 | const tree = remark.parse(file); 94 | 95 | const transformedTree = await remark.run(tree, file); 96 | 97 | // then 98 | expect(transformedTree.links).to.have.length(2); 99 | expect(transformedTree.anchors).to.have.length(1); 100 | expect(transformedTree.tags).to.have.length(0); 101 | }); 102 | 103 | }); 104 | 105 | 106 | it('should recognize tags', async function() { 107 | 108 | // given 109 | const markdown = ` 110 | # heading 111 | 112 | #some-tag, #other_tag 113 | #tag no-tag 114 | [](./rel#link) 115 | ![](./rel.png#image) 116 | `; 117 | 118 | const file = toVFile({ value: markdown }); 119 | 120 | // when 121 | const tree = remark.parse(file); 122 | 123 | // then 124 | expect(tree).to.eql(parseTree_LINKS); 125 | }); 126 | 127 | }); 128 | -------------------------------------------------------------------------------- /test/spec/core/markmark.spec.js: -------------------------------------------------------------------------------- 1 | import Markmark from '../../../lib/core/markmark.js'; 2 | 3 | import { fileUri } from './helper.js'; 4 | 5 | 6 | describe('core/markmark', function() { 7 | 8 | let markmark; 9 | 10 | beforeEach(function() { 11 | markmark = new Markmark(console); 12 | }); 13 | 14 | afterEach(function() { 15 | return markmark.close(); 16 | }); 17 | 18 | 19 | describe('should index', function() { 20 | 21 | it('internal watcher', async function() { 22 | 23 | // given 24 | markmark.init({ watch: true }); 25 | 26 | // when 27 | markmark.addRoot(fileUri('test/fixtures/notes')); 28 | 29 | // then 30 | await on('ready', markmark); 31 | await on('references:changed', markmark); 32 | }); 33 | 34 | 35 | it('external file change handler', async function() { 36 | 37 | // given 38 | markmark.init({ watch: false }); 39 | 40 | // when 41 | markmark.addRoot(fileUri('test/fixtures/notes')); 42 | markmark.addFile(fileUri('test/fixtures/notes/IDEAS.md')); 43 | markmark.addFile(fileUri('test/fixtures/notes/NOTES.md')); 44 | markmark.updateFile(fileUri('test/fixtures/notes/IDEAS.md')); 45 | markmark.removeFile(fileUri('test/fixtures/notes/NON_EXISTING.md')); 46 | markmark.removeFile(fileUri('test/fixtures/notes/NOTES.md')); 47 | 48 | // then 49 | await on('ready', markmark); 50 | await on('references:changed', markmark); 51 | }); 52 | 53 | }); 54 | 55 | }); 56 | 57 | 58 | function on(event, eventBus) { 59 | return new Promise((resolve) => { 60 | eventBus.once(event, resolve); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /test/spec/core/references.parseTree.EXTERNAL.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "root", 3 | "children": [ 4 | { 5 | "type": "paragraph", 6 | "children": [ 7 | { 8 | "type": "link", 9 | "title": null, 10 | "url": "https://github.com", 11 | "children": [ 12 | { 13 | "type": "text", 14 | "value": "link", 15 | "position": { 16 | "start": { 17 | "line": 1, 18 | "column": 2, 19 | "offset": 1 20 | }, 21 | "end": { 22 | "line": 1, 23 | "column": 6, 24 | "offset": 5 25 | } 26 | } 27 | } 28 | ], 29 | "position": { 30 | "start": { 31 | "line": 1, 32 | "column": 1, 33 | "offset": 0 34 | }, 35 | "end": { 36 | "line": 1, 37 | "column": 27, 38 | "offset": 26 39 | } 40 | } 41 | } 42 | ], 43 | "position": { 44 | "start": { 45 | "line": 1, 46 | "column": 1, 47 | "offset": 0 48 | }, 49 | "end": { 50 | "line": 1, 51 | "column": 27, 52 | "offset": 26 53 | } 54 | } 55 | } 56 | ], 57 | "position": { 58 | "start": { 59 | "line": 1, 60 | "column": 1, 61 | "offset": 0 62 | }, 63 | "end": { 64 | "line": 2, 65 | "column": 1, 66 | "offset": 27 67 | } 68 | }, 69 | "links": [ 70 | { 71 | "targetUri": "https://github.com", 72 | "position": { 73 | "start": { 74 | "line": 1, 75 | "column": 1, 76 | "offset": 0 77 | }, 78 | "end": { 79 | "line": 1, 80 | "column": 27, 81 | "offset": 26 82 | } 83 | } 84 | } 85 | ], 86 | "anchors": [ 87 | { 88 | "uri": "", 89 | "position": { 90 | "start": { 91 | "line": 0, 92 | "column": 0 93 | }, 94 | "end": { 95 | "line": 0, 96 | "column": 0 97 | } 98 | } 99 | } 100 | ], 101 | "tags": [] 102 | } 103 | -------------------------------------------------------------------------------- /test/spec/core/references.parseTree.IDEAS.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "root", 3 | "children": [ 4 | { 5 | "type": "heading", 6 | "depth": 1, 7 | "children": [ 8 | { 9 | "type": "text", 10 | "value": "Ideas", 11 | "position": { 12 | "start": { 13 | "line": 1, 14 | "column": 3, 15 | "offset": 2 16 | }, 17 | "end": { 18 | "line": 1, 19 | "column": 8, 20 | "offset": 7 21 | } 22 | } 23 | } 24 | ], 25 | "position": { 26 | "start": { 27 | "line": 1, 28 | "column": 1, 29 | "offset": 0 30 | }, 31 | "end": { 32 | "line": 1, 33 | "column": 8, 34 | "offset": 7 35 | } 36 | } 37 | }, 38 | { 39 | "type": "paragraph", 40 | "children": [ 41 | { 42 | "type": "link", 43 | "title": null, 44 | "url": "./NOTES.md", 45 | "children": [], 46 | "position": { 47 | "start": { 48 | "line": 3, 49 | "column": 1, 50 | "offset": 9 51 | }, 52 | "end": { 53 | "line": 3, 54 | "column": 15, 55 | "offset": 23 56 | } 57 | } 58 | }, 59 | { 60 | "type": "text", 61 | "value": "\n", 62 | "position": { 63 | "start": { 64 | "line": 3, 65 | "column": 15, 66 | "offset": 23 67 | }, 68 | "end": { 69 | "line": 4, 70 | "column": 1, 71 | "offset": 24 72 | } 73 | } 74 | }, 75 | { 76 | "type": "tag", 77 | "value": "PUNCH_LINE", 78 | "position": { 79 | "start": { 80 | "line": 4, 81 | "column": 1, 82 | "offset": 24 83 | }, 84 | "end": { 85 | "line": 4, 86 | "column": 12, 87 | "offset": 35 88 | } 89 | } 90 | } 91 | ], 92 | "position": { 93 | "start": { 94 | "line": 3, 95 | "column": 1, 96 | "offset": 9 97 | }, 98 | "end": { 99 | "line": 4, 100 | "column": 12, 101 | "offset": 35 102 | } 103 | } 104 | }, 105 | { 106 | "type": "heading", 107 | "depth": 2, 108 | "children": [ 109 | { 110 | "type": "text", 111 | "value": "Connect This and That", 112 | "position": { 113 | "start": { 114 | "line": 6, 115 | "column": 4, 116 | "offset": 40 117 | }, 118 | "end": { 119 | "line": 6, 120 | "column": 25, 121 | "offset": 61 122 | } 123 | } 124 | } 125 | ], 126 | "position": { 127 | "start": { 128 | "line": 6, 129 | "column": 1, 130 | "offset": 37 131 | }, 132 | "end": { 133 | "line": 6, 134 | "column": 25, 135 | "offset": 61 136 | } 137 | } 138 | }, 139 | { 140 | "type": "paragraph", 141 | "children": [ 142 | { 143 | "type": "text", 144 | "value": "To get super powers.", 145 | "position": { 146 | "start": { 147 | "line": 8, 148 | "column": 1, 149 | "offset": 63 150 | }, 151 | "end": { 152 | "line": 8, 153 | "column": 21, 154 | "offset": 83 155 | } 156 | } 157 | } 158 | ], 159 | "position": { 160 | "start": { 161 | "line": 8, 162 | "column": 1, 163 | "offset": 63 164 | }, 165 | "end": { 166 | "line": 8, 167 | "column": 21, 168 | "offset": 83 169 | } 170 | } 171 | }, 172 | { 173 | "type": "paragraph", 174 | "children": [ 175 | { 176 | "type": "link", 177 | "title": null, 178 | "url": "./NOTES.md#deeplink", 179 | "children": [], 180 | "position": { 181 | "start": { 182 | "line": 11, 183 | "column": 1, 184 | "offset": 86 185 | }, 186 | "end": { 187 | "line": 11, 188 | "column": 24, 189 | "offset": 109 190 | } 191 | } 192 | } 193 | ], 194 | "position": { 195 | "start": { 196 | "line": 11, 197 | "column": 1, 198 | "offset": 86 199 | }, 200 | "end": { 201 | "line": 11, 202 | "column": 24, 203 | "offset": 109 204 | } 205 | } 206 | }, 207 | { 208 | "type": "paragraph", 209 | "children": [ 210 | { 211 | "type": "link", 212 | "title": null, 213 | "url": "#ideas", 214 | "children": [ 215 | { 216 | "type": "text", 217 | "value": "local link", 218 | "position": { 219 | "start": { 220 | "line": 13, 221 | "column": 2, 222 | "offset": 112 223 | }, 224 | "end": { 225 | "line": 13, 226 | "column": 12, 227 | "offset": 122 228 | } 229 | } 230 | } 231 | ], 232 | "position": { 233 | "start": { 234 | "line": 13, 235 | "column": 1, 236 | "offset": 111 237 | }, 238 | "end": { 239 | "line": 13, 240 | "column": 21, 241 | "offset": 131 242 | } 243 | } 244 | } 245 | ], 246 | "position": { 247 | "start": { 248 | "line": 13, 249 | "column": 1, 250 | "offset": 111 251 | }, 252 | "end": { 253 | "line": 13, 254 | "column": 21, 255 | "offset": 131 256 | } 257 | } 258 | } 259 | ], 260 | "position": { 261 | "start": { 262 | "line": 1, 263 | "column": 1, 264 | "offset": 0 265 | }, 266 | "end": { 267 | "line": 14, 268 | "column": 1, 269 | "offset": 132 270 | } 271 | }, 272 | "links": [ 273 | { 274 | "targetUri": "./NOTES.md", 275 | "position": { 276 | "start": { 277 | "line": 3, 278 | "column": 1, 279 | "offset": 9 280 | }, 281 | "end": { 282 | "line": 3, 283 | "column": 15, 284 | "offset": 23 285 | } 286 | } 287 | }, 288 | { 289 | "targetUri": "./NOTES.md#deeplink", 290 | "position": { 291 | "start": { 292 | "line": 11, 293 | "column": 1, 294 | "offset": 86 295 | }, 296 | "end": { 297 | "line": 11, 298 | "column": 24, 299 | "offset": 109 300 | } 301 | } 302 | }, 303 | { 304 | "targetUri": "#ideas", 305 | "position": { 306 | "start": { 307 | "line": 13, 308 | "column": 1, 309 | "offset": 111 310 | }, 311 | "end": { 312 | "line": 13, 313 | "column": 21, 314 | "offset": 131 315 | } 316 | } 317 | } 318 | ], 319 | "anchors": [ 320 | { 321 | "uri": "#ideas", 322 | "position": { 323 | "start": { 324 | "line": 1, 325 | "column": 1, 326 | "offset": 0 327 | }, 328 | "end": { 329 | "line": 1, 330 | "column": 8, 331 | "offset": 7 332 | } 333 | } 334 | }, 335 | { 336 | "uri": "#connect-this-and-that", 337 | "position": { 338 | "start": { 339 | "line": 6, 340 | "column": 1, 341 | "offset": 37 342 | }, 343 | "end": { 344 | "line": 6, 345 | "column": 25, 346 | "offset": 61 347 | } 348 | } 349 | }, 350 | { 351 | "uri": "", 352 | "position": { 353 | "start": { 354 | "line": 0, 355 | "column": 0 356 | }, 357 | "end": { 358 | "line": 0, 359 | "column": 0 360 | } 361 | } 362 | } 363 | ], 364 | "tags": [ 365 | { 366 | "position": { 367 | "start": { 368 | "line": 4, 369 | "column": 1, 370 | "offset": 24 371 | }, 372 | "end": { 373 | "line": 4, 374 | "column": 12, 375 | "offset": 35 376 | } 377 | }, 378 | "value": "PUNCH_LINE" 379 | } 380 | ] 381 | } 382 | -------------------------------------------------------------------------------- /test/spec/core/references.parseTree.IMG.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "root", 3 | "children": [ 4 | { 5 | "type": "paragraph", 6 | "children": [ 7 | { 8 | "type": "image", 9 | "title": null, 10 | "url": "./img.png", 11 | "alt": "image", 12 | "position": { 13 | "start": { 14 | "line": 1, 15 | "column": 1, 16 | "offset": 0 17 | }, 18 | "end": { 19 | "line": 1, 20 | "column": 20, 21 | "offset": 19 22 | } 23 | } 24 | } 25 | ], 26 | "position": { 27 | "start": { 28 | "line": 1, 29 | "column": 1, 30 | "offset": 0 31 | }, 32 | "end": { 33 | "line": 1, 34 | "column": 20, 35 | "offset": 19 36 | } 37 | } 38 | }, 39 | { 40 | "type": "paragraph", 41 | "children": [ 42 | { 43 | "type": "link", 44 | "title": null, 45 | "url": "./img.png", 46 | "children": [ 47 | { 48 | "type": "text", 49 | "value": "image link", 50 | "position": { 51 | "start": { 52 | "line": 3, 53 | "column": 2, 54 | "offset": 22 55 | }, 56 | "end": { 57 | "line": 3, 58 | "column": 12, 59 | "offset": 32 60 | } 61 | } 62 | } 63 | ], 64 | "position": { 65 | "start": { 66 | "line": 3, 67 | "column": 1, 68 | "offset": 21 69 | }, 70 | "end": { 71 | "line": 3, 72 | "column": 24, 73 | "offset": 44 74 | } 75 | } 76 | } 77 | ], 78 | "position": { 79 | "start": { 80 | "line": 3, 81 | "column": 1, 82 | "offset": 21 83 | }, 84 | "end": { 85 | "line": 3, 86 | "column": 24, 87 | "offset": 44 88 | } 89 | } 90 | } 91 | ], 92 | "position": { 93 | "start": { 94 | "line": 1, 95 | "column": 1, 96 | "offset": 0 97 | }, 98 | "end": { 99 | "line": 4, 100 | "column": 1, 101 | "offset": 45 102 | } 103 | }, 104 | "links": [ 105 | { 106 | "targetUri": "./img.png", 107 | "position": { 108 | "start": { 109 | "line": 1, 110 | "column": 1, 111 | "offset": 0 112 | }, 113 | "end": { 114 | "line": 1, 115 | "column": 20, 116 | "offset": 19 117 | } 118 | } 119 | }, 120 | { 121 | "targetUri": "./img.png", 122 | "position": { 123 | "start": { 124 | "line": 3, 125 | "column": 1, 126 | "offset": 21 127 | }, 128 | "end": { 129 | "line": 3, 130 | "column": 24, 131 | "offset": 44 132 | } 133 | } 134 | } 135 | ], 136 | "anchors": [ 137 | { 138 | "uri": "", 139 | "position": { 140 | "start": { 141 | "line": 0, 142 | "column": 0 143 | }, 144 | "end": { 145 | "line": 0, 146 | "column": 0 147 | } 148 | } 149 | } 150 | ], 151 | "tags": [] 152 | } 153 | -------------------------------------------------------------------------------- /test/spec/core/references.parseTree.NOTES.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "root", 3 | "children": [ 4 | { 5 | "type": "heading", 6 | "depth": 1, 7 | "children": [ 8 | { 9 | "type": "text", 10 | "value": "Notes", 11 | "position": { 12 | "start": { 13 | "line": 1, 14 | "column": 3, 15 | "offset": 2 16 | }, 17 | "end": { 18 | "line": 1, 19 | "column": 8, 20 | "offset": 7 21 | } 22 | } 23 | } 24 | ], 25 | "position": { 26 | "start": { 27 | "line": 1, 28 | "column": 1, 29 | "offset": 0 30 | }, 31 | "end": { 32 | "line": 1, 33 | "column": 8, 34 | "offset": 7 35 | } 36 | } 37 | }, 38 | { 39 | "type": "heading", 40 | "depth": 2, 41 | "children": [ 42 | { 43 | "type": "text", 44 | "value": "Deeplink", 45 | "position": { 46 | "start": { 47 | "line": 3, 48 | "column": 4, 49 | "offset": 12 50 | }, 51 | "end": { 52 | "line": 3, 53 | "column": 12, 54 | "offset": 20 55 | } 56 | } 57 | } 58 | ], 59 | "position": { 60 | "start": { 61 | "line": 3, 62 | "column": 1, 63 | "offset": 9 64 | }, 65 | "end": { 66 | "line": 3, 67 | "column": 12, 68 | "offset": 20 69 | } 70 | } 71 | } 72 | ], 73 | "position": { 74 | "start": { 75 | "line": 1, 76 | "column": 1, 77 | "offset": 0 78 | }, 79 | "end": { 80 | "line": 5, 81 | "column": 1, 82 | "offset": 22 83 | } 84 | }, 85 | "links": [], 86 | "anchors": [ 87 | { 88 | "uri": "#notes", 89 | "position": { 90 | "start": { 91 | "line": 1, 92 | "column": 1, 93 | "offset": 0 94 | }, 95 | "end": { 96 | "line": 1, 97 | "column": 8, 98 | "offset": 7 99 | } 100 | } 101 | }, 102 | { 103 | "uri": "#deeplink", 104 | "position": { 105 | "start": { 106 | "line": 3, 107 | "column": 1, 108 | "offset": 9 109 | }, 110 | "end": { 111 | "line": 3, 112 | "column": 12, 113 | "offset": 20 114 | } 115 | } 116 | }, 117 | { 118 | "uri": "", 119 | "position": { 120 | "start": { 121 | "line": 0, 122 | "column": 0 123 | }, 124 | "end": { 125 | "line": 0, 126 | "column": 0 127 | } 128 | } 129 | } 130 | ], 131 | "tags": [] 132 | } 133 | -------------------------------------------------------------------------------- /test/spec/core/references.spec.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'node:events'; 2 | 3 | import { 4 | createIndexItem 5 | } from '../../../lib/core/util.js'; 6 | 7 | import References from '../../../lib/core/references.js'; 8 | 9 | import { expect } from 'chai'; 10 | 11 | import { createRequire } from 'module'; 12 | 13 | const require = createRequire(import.meta.url); 14 | 15 | const parseTree_IDEAS = require('./references.parseTree.IDEAS.json'); 16 | const parseTree_NOTES = require('./references.parseTree.NOTES.json'); 17 | const parseTree_EXTERNAL = require('./references.parseTree.EXTERNAL.json'); 18 | const parseTree_IMG = require('./references.parseTree.IMG.json'); 19 | 20 | 21 | describe('core/references', function() { 22 | 23 | let eventBus, references; 24 | 25 | beforeEach(function() { 26 | eventBus = new EventEmitter(); 27 | references = new References(console, eventBus); 28 | }); 29 | 30 | 31 | describe('update / removal', function() { 32 | 33 | describe('should update', function() { 34 | 35 | it('basic', function() { 36 | 37 | // when 38 | triggerIndexed({ 39 | uri: 'file:///tmp/IDEAS.md', 40 | parseTree: parseTree_IDEAS 41 | }); 42 | 43 | // then 44 | expectRefs(references.getAnchors(), [ 45 | { 46 | uri: 'file:///tmp/IDEAS.md#ideas' 47 | }, 48 | { 49 | uri: 'file:///tmp/IDEAS.md#connect-this-and-that' 50 | }, 51 | { 52 | uri: 'file:///tmp/IDEAS.md', 53 | position: { 54 | start: { 55 | line: 0, 56 | column: 0 57 | }, 58 | end: { 59 | line: 0, 60 | column: 0 61 | } 62 | } 63 | } 64 | ]); 65 | 66 | expectRefs(references.getLinks(), [ 67 | { 68 | uri: 'file:///tmp/IDEAS.md', 69 | targetUri: 'file:///tmp/NOTES.md' 70 | }, 71 | { 72 | uri: 'file:///tmp/IDEAS.md', 73 | targetUri: 'file:///tmp/NOTES.md#deeplink' 74 | }, 75 | { 76 | uri: 'file:///tmp/IDEAS.md', 77 | targetUri: 'file:///tmp/IDEAS.md#ideas' 78 | } 79 | ]); 80 | }); 81 | 82 | 83 | it('external links', function() { 84 | 85 | // when 86 | triggerIndexed({ 87 | uri: 'file:///tmp/EXTERNAL.md', 88 | parseTree: parseTree_EXTERNAL 89 | }); 90 | 91 | // then 92 | expectRefs(references.getAnchors(), [ 93 | { 94 | uri: 'file:///tmp/EXTERNAL.md' 95 | } 96 | ]); 97 | 98 | expectRefs(references.getLinks(), [ 99 | { 100 | uri: 'file:///tmp/EXTERNAL.md', 101 | targetUri: 'https://github.com/' 102 | } 103 | ]); 104 | 105 | }); 106 | 107 | 108 | it('images', function() { 109 | 110 | // when 111 | triggerIndexed({ 112 | uri: 'file:///tmp/IMG.md', 113 | parseTree: parseTree_IMG 114 | }); 115 | 116 | // then 117 | expectRefs(references.getAnchors(), [ 118 | { 119 | uri: 'file:///tmp/IMG.md' 120 | } 121 | ]); 122 | 123 | expectRefs(references.getLinks(), [ 124 | { 125 | uri: 'file:///tmp/IMG.md', 126 | targetUri: 'file:///tmp/img.png' 127 | }, 128 | { 129 | uri: 'file:///tmp/IMG.md', 130 | targetUri: 'file:///tmp/img.png' 131 | } 132 | ]); 133 | }); 134 | 135 | }); 136 | 137 | 138 | it('should remove', function() { 139 | 140 | // given 141 | const indexItem = triggerIndexed({ 142 | uri: 'file:///tmp/IDEAS.md', 143 | parseTree: parseTree_IDEAS 144 | }); 145 | 146 | // when 147 | eventBus.emit('indexer:removed', indexItem); 148 | 149 | // then 150 | expectRefs(references.getAnchors(), []); 151 | expectRefs(references.getLinks(), []); 152 | }); 153 | 154 | }); 155 | 156 | 157 | describe('querying', function() { 158 | 159 | beforeEach(function() { 160 | 161 | triggerIndexed({ 162 | uri: 'file:///tmp/IDEAS.md', 163 | parseTree: parseTree_IDEAS 164 | }); 165 | 166 | triggerIndexed({ 167 | uri: 'file:///tmp/NOTES.md', 168 | parseTree: parseTree_NOTES 169 | }); 170 | 171 | triggerIndexed({ 172 | uri: 'file:///tmp/IMG.md', 173 | parseTree: parseTree_IMG 174 | }); 175 | 176 | triggerIndexed({ 177 | uri: 'file:///tmp/EXTERNAL.md', 178 | parseTree: parseTree_EXTERNAL 179 | }); 180 | 181 | }); 182 | 183 | 184 | describe('should find references', function() { 185 | 186 | it('to anchor', function() { 187 | 188 | // when 189 | const refs = references.findReferences({ 190 | uri: 'file:///tmp/NOTES.md', 191 | position: { 192 | start: { 193 | line: 3, 194 | column: 1 195 | }, 196 | end: { 197 | line: 3, 198 | column: 12 199 | } 200 | } 201 | }); 202 | 203 | // then 204 | expectRefs(refs, [ 205 | { 206 | uri: 'file:///tmp/IDEAS.md', 207 | targetUri: 'file:///tmp/NOTES.md#deeplink' 208 | } 209 | ]); 210 | }); 211 | 212 | 213 | it('to tag', function() { 214 | 215 | // when 216 | const refs = references.findReferences({ 217 | uri: 'file:///tmp/IDEAS.md', 218 | position: { 219 | start: { 220 | line: 4, 221 | column: 6 222 | }, 223 | end: { 224 | line: 4, 225 | column: 6 226 | } 227 | } 228 | }); 229 | 230 | // then 231 | expectRefs(refs, [ 232 | { 233 | uri: 'file:///tmp/IDEAS.md' 234 | } 235 | ]); 236 | }); 237 | 238 | 239 | it('to document', function() { 240 | 241 | // when 242 | const refs = references.findReferences({ 243 | uri: 'file:///tmp/NOTES.md', 244 | position: { 245 | start: { 246 | line: 2, 247 | column: 1 248 | }, 249 | end: { 250 | line: 2, 251 | column: 1 252 | } 253 | } 254 | }); 255 | 256 | // then 257 | expectRefs(refs, [ 258 | { 259 | uri: 'file:///tmp/IDEAS.md', 260 | targetUri: 'file:///tmp/NOTES.md' 261 | } 262 | ]); 263 | }); 264 | 265 | 266 | it('to image', function() { 267 | 268 | // when 269 | const refs = references.findReferences({ 270 | uri: 'file:///tmp/IMG.md', 271 | position: { 272 | start: { 273 | line: 3, 274 | column: 1 275 | }, 276 | end: { 277 | line: 3, 278 | column: 12 279 | } 280 | } 281 | }); 282 | 283 | // then 284 | expectRefs(refs, [ 285 | { 286 | uri: 'file:///tmp/IMG.md', 287 | targetUri: 'file:///tmp/img.png' 288 | }, { 289 | uri: 'file:///tmp/IMG.md', 290 | targetUri: 'file:///tmp/img.png' 291 | } 292 | ]); 293 | }); 294 | 295 | 296 | it('to external resource', function() { 297 | 298 | // when 299 | const refs = references.findReferences({ 300 | uri: 'file:///tmp/EXTERNAL.md', 301 | position: { 302 | start: { 303 | line: 1, 304 | column: 8 305 | }, 306 | end: { 307 | line: 1, 308 | column: 8 309 | } 310 | } 311 | }); 312 | 313 | // then 314 | expectRefs(refs, [ 315 | { 316 | uri: 'file:///tmp/EXTERNAL.md', 317 | targetUri: 'https://github.com/' 318 | } 319 | ]); 320 | }); 321 | 322 | 323 | it('of link', function() { 324 | 325 | // when 326 | const refs = references.findReferences({ 327 | uri: 'file:///tmp/IDEAS.md', 328 | position: { 329 | start: { 330 | line: 3, 331 | column: 5 332 | }, 333 | end: { 334 | line: 3, 335 | column: 5 336 | } 337 | } 338 | }); 339 | 340 | // then 341 | expectRefs(refs, [ 342 | { 343 | uri: 'file:///tmp/IDEAS.md', 344 | targetUri: 'file:///tmp/NOTES.md' 345 | } 346 | ]); 347 | }); 348 | 349 | }); 350 | 351 | 352 | describe('should find definition', function() { 353 | 354 | it('to document link', function() { 355 | 356 | // when 357 | const refs = references.findDefinitions({ 358 | uri: 'file:///tmp/IDEAS.md', 359 | position: { 360 | start: { 361 | line: 3, 362 | column: 5 363 | }, 364 | end: { 365 | line: 3, 366 | column: 5 367 | } 368 | } 369 | }); 370 | 371 | // then 372 | expectRefs(refs, [ 373 | { 374 | uri: 'file:///tmp/NOTES.md' 375 | } 376 | ]); 377 | }); 378 | 379 | 380 | it('to deep link', function() { 381 | 382 | // when 383 | const refs = references.findDefinitions({ 384 | uri: 'file:///tmp/IDEAS.md', 385 | position: { 386 | start: { 387 | line: 11, 388 | column: 1 389 | }, 390 | end: { 391 | line: 11, 392 | column: 24 393 | } 394 | } 395 | }); 396 | 397 | // then 398 | expectRefs(refs, [ 399 | { 400 | uri: 'file:///tmp/NOTES.md#deeplink' 401 | } 402 | ]); 403 | }); 404 | 405 | 406 | it('to image', function() { 407 | 408 | // when 409 | const refs = references.findDefinitions({ 410 | uri: 'file:///tmp/IMG.md', 411 | position: { 412 | start: { 413 | line: 1, 414 | column: 8 415 | }, 416 | end: { 417 | line: 1, 418 | column: 8 419 | } 420 | } 421 | }); 422 | 423 | // then 424 | expectRefs(refs, [ 425 | { 426 | uri: 'file:///tmp/img.png' 427 | } 428 | ]); 429 | }); 430 | 431 | 432 | it('to external URI', function() { 433 | 434 | // when 435 | const refs = references.findDefinitions({ 436 | uri: 'file:///tmp/EXTERNAL.md', 437 | position: { 438 | start: { 439 | line: 1, 440 | column: 8 441 | }, 442 | end: { 443 | line: 1, 444 | column: 8 445 | } 446 | } 447 | }); 448 | 449 | // then 450 | expectRefs(refs, [ 451 | { 452 | uri: 'https://github.com/' 453 | } 454 | ]); 455 | }); 456 | }); 457 | 458 | }); 459 | 460 | 461 | function triggerIndexed(args) { 462 | const indexItem = createIndexItem(args); 463 | 464 | eventBus.emit('indexer:updated', indexItem); 465 | 466 | return indexItem; 467 | } 468 | 469 | }); 470 | 471 | 472 | // helpers ///////////////// 473 | 474 | function expectRefs(refs, expectedRefs) { 475 | 476 | expect(refs).to.have.length(expectedRefs.length); 477 | 478 | const actualRefs = expectedRefs.map((ref, idx) => { 479 | 480 | const actualRef = refs[idx]; 481 | 482 | if (!actualRef) { 483 | return null; 484 | } 485 | 486 | return Object.keys(ref).reduce((obj, key) => { 487 | obj[key] = actualRef[key]; 488 | 489 | return obj; 490 | }, {}); 491 | }); 492 | 493 | expect(actualRefs).to.eql(expectedRefs); 494 | } 495 | -------------------------------------------------------------------------------- /test/spec/core/watcher.spec.js: -------------------------------------------------------------------------------- 1 | import Watcher from '../../../lib/core/watcher.js'; 2 | 3 | import { EventEmitter } from 'node:events'; 4 | 5 | import { expect } from 'chai'; 6 | import { fileUri } from './helper.js'; 7 | 8 | 9 | describe('core/watcher', function() { 10 | 11 | let watcher, eventBus; 12 | 13 | beforeEach(function() { 14 | eventBus = new EventEmitter(); 15 | 16 | watcher = new Watcher(console, eventBus); 17 | }); 18 | 19 | afterEach(function() { 20 | return watcher.close(); 21 | }); 22 | 23 | 24 | it('should watch directory', async function() { 25 | 26 | // when 27 | watcher.addFolder(fileUri('test/fixtures/notes')); 28 | watcher.addFolder(fileUri('test/fixtures/notes')); 29 | 30 | await on('watcher:ready', eventBus); 31 | 32 | // then 33 | expect(watcher.getFiles()).to.have.length(4); 34 | }); 35 | 36 | 37 | it('should unwatch directory', async function() { 38 | 39 | // given 40 | watcher.addFolder(fileUri('test/fixtures/notes')); 41 | 42 | await on('watcher:ready', eventBus); 43 | 44 | // when 45 | watcher.removeFolder(fileUri('test/fixtures/notes')); 46 | 47 | await on('watcher:changed', eventBus); 48 | 49 | // then 50 | expect(watcher.getFiles()).to.be.empty; 51 | }); 52 | 53 | }); 54 | 55 | 56 | function on(event, eventBus) { 57 | return new Promise((resolve) => { 58 | eventBus.once(event, resolve); 59 | }); 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*.js", 4 | "**/*.d.ts" 5 | ], 6 | "compilerOptions": { 7 | "allowJs": true, 8 | "allowSyntheticDefaultImports": true, 9 | "resolveJsonModule": true, 10 | "checkJs": true, 11 | "declaration": true, 12 | "lib": ["ES2022"], 13 | "moduleResolution": "nodenext", 14 | "module": "NodeNext", 15 | "noEmit": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "noImplicitAny": false 19 | } 20 | } 21 | --------------------------------------------------------------------------------