├── .autod.conf.js ├── .eslintrc ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .travis.yml ├── History.md ├── README.md ├── appveyor.yml ├── bin.js ├── index.d.ts ├── index.js ├── package.json └── test ├── bin.test.js ├── fixtures ├── docs1 │ ├── dir │ │ ├── README.md │ │ └── placeholder-image.png │ ├── other.md │ └── test.md └── vuepress │ ├── docs │ ├── .vuepress │ │ └── public │ │ │ └── 5856440.jpg │ └── readme.md │ └── other.md └── index.test.js /.autod.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | write: true, 5 | prefix: '^', 6 | test: [ 7 | 'test', 8 | ], 9 | dep: [ 10 | ], 11 | devdep: [ 12 | 'egg-ci', 13 | 'egg-bin', 14 | 'autod', 15 | 'eslint', 16 | 'eslint-config-egg', 17 | 'webstorm-disable-index', 18 | ], 19 | exclude: [ 20 | './test/fixtures', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-egg", 3 | "parserOptions": { 4 | "ecmaVersion": 9, 5 | "sourceType": "module" 6 | }, 7 | "rules": { 8 | "valid-jsdoc": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | schedule: 12 | - cron: '0 2 * * *' 13 | 14 | jobs: 15 | build: 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | node-version: [8, 10, 11] 22 | os: [ubuntu-latest, windows-latest, macos-latest] 23 | 24 | steps: 25 | - name: Checkout Git Source 26 | uses: actions/checkout@v2 27 | 28 | - name: Use Node.js ${{ matrix.node-version }} 29 | uses: actions/setup-node@v1 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Install Dependencies 34 | run: npm i -g npminstall && npminstall 35 | 36 | - name: Continuous Integration 37 | run: npm run ci 38 | 39 | - name: Code Coverage 40 | uses: codecov/codecov-action@v1 41 | with: 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logs/ 2 | npm-debug.log 3 | node_modules/ 4 | coverage/ 5 | .idea/ 6 | run/ 7 | .DS_Store 8 | *.swp 9 | .vscode/ 10 | yarn.lock 11 | *.log 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | 2 | language: node_js 3 | node_js: 4 | - '8' 5 | - '10' 6 | - '11' 7 | before_install: 8 | - npm i npminstall -g 9 | install: 10 | - npminstall 11 | script: 12 | - npm run ci 13 | after_script: 14 | - npminstall codecov && codecov 15 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 2 | 1.0.2 / 2021-02-19 3 | ================== 4 | 5 | **fixes** 6 | * [[`edb2e79`](http://github.com/whxaxes/check-md/commit/edb2e7937f7edeea3e0a40ca723b95cda9f9b312)] - fix: Image Titles and markdown imsize support (#13) (Jacob <>) 7 | 8 | 1.0.1 / 2020-06-28 9 | ================== 10 | 11 | **fixes** 12 | * [[`8b97536`](http://github.com/whxaxes/check-md/commit/8b97536a5d6442eaf5147b27f09d7d684bafa3d6)] - fix: allow ignoring the footnotes (#8) (Sacha <>) 13 | * [[`11d7071`](http://github.com/whxaxes/check-md/commit/11d70711a581f845f3959e017e9695a0fe558b1d)] - fix: ci (wanghx <>) 14 | 15 | 1.0.0 / 2019-07-30 16 | ================== 17 | 18 | **features** 19 | * [[`f8d3d62`](http://github.com/whxaxes/check-md/commit/f8d3d6241924fe739f5ad861b93814610c814523)] - feat: allow to custom slugify function (#1) (ULIVZ <<472590061@qq.com>>) 20 | 21 | **fixes** 22 | * [[`fdd835f`](http://github.com/whxaxes/check-md/commit/fdd835f745954903723a2c37384b9cff6a6b71bc)] - fix: ci (wanghx <>) 23 | 24 | **others** 25 | * [[`e780778`](http://github.com/whxaxes/check-md/commit/e780778f8af768e01b2ea5b75b6c25bdfe5ebcb4)] - test: update test case (wanghx <>) 26 | * [[`b08e365`](http://github.com/whxaxes/check-md/commit/b08e365fbc35ead6240850114d4dbb532ec6c639)] - docs: update docs (wanghx <>) 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # check-md 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![build status][travis-image]][travis-url] 5 | [![Test coverage][codecov-image]][codecov-url] 6 | [![David deps][david-image]][david-url] 7 | [![Known Vulnerabilities][snyk-image]][snyk-url] 8 | [![NPM download][download-image]][download-url] 9 | 10 | [npm-image]: https://img.shields.io/npm/v/check-md.svg?style=flat-square 11 | [npm-url]: https://npmjs.org/package/check-md 12 | [travis-image]: https://img.shields.io/travis/whxaxes/check-md.svg?style=flat-square 13 | [travis-url]: https://travis-ci.org/whxaxes/check-md 14 | [codecov-image]: https://codecov.io/gh/whxaxes/check-md/branch/master/graph/badge.svg 15 | [codecov-url]: https://codecov.io/gh/whxaxes/check-md 16 | [david-image]: https://img.shields.io/david/whxaxes/check-md.svg?style=flat-square 17 | [david-url]: https://david-dm.org/whxaxes/check-md 18 | [snyk-image]: https://snyk.io/test/npm/check-md/badge.svg?style=flat-square 19 | [snyk-url]: https://snyk.io/test/npm/check-md 20 | [download-image]: https://img.shields.io/npm/dm/check-md.svg?style=flat-square 21 | [download-url]: https://npmjs.org/package/check-md 22 | 23 | A simple cli for checking dead links of markdown. 24 | 25 | ## Usage 26 | 27 | ```bash 28 | $ npm i check-md --save 29 | $ npx check-md 30 | ``` 31 | 32 | ## Options 33 | 34 | ``` 35 | Usage: check-md [options] 36 | 37 | Options: 38 | -v, --version output the version number 39 | -f, --fix Check and try to fix 40 | -c, --cwd [path] Working directory of check-md, default to process.cwd() 41 | -r, --root [path] Checking url root, default to ./ 42 | -p, --preset [name] Preset config(eg vuepress, default) 43 | -P, --pattern [pattern] Glob patterns, default to **/*.md 44 | -i, --ignore [pattern] Ignore patterns, will merge to pattern, default to **/node_modules 45 | --exit-level [level] Process exit level, default to error, other choice is warn and none, it will not exit if setting to none 46 | --default-index [index] Default index in directory, default to README.md,readme.md 47 | -h, --help output usage information 48 | ``` 49 | 50 | configure in `package.json` 51 | 52 | ```json 53 | { 54 | "check-md": { 55 | "cwd": "./", 56 | "defaultIndex": [ "index.md" ], 57 | "exitLevel": "warn", 58 | } 59 | } 60 | ``` 61 | 62 | ## Example 63 | 64 | Running cli in directory of [test.md](https://github.com/whxaxes/check-md/blob/master/test/fixtures/docs1/test.md) 65 | 66 | The result is 67 | 68 | ``` 69 | Checking markdown... 70 | 71 | 1 warning was found 72 | 73 | Should use .md instead of .html: [test6](./other.html#ctx-get-name) (/Users/Workspace/check-md/test/fixtures/docs1/test.md:15:1) 74 | 75 | 76 | 5 dead links was found 77 | 78 | Url link is empty: [test1]() (/Users/Workspace/check-md/test/fixtures/docs1/test.md:5:5) 79 | Hash is not found: [test8](/other#cccc) (/Users/Workspace/check-md/test/fixtures/docs1/test.md:19:1) 80 | File is not found: [test9](/123.md#cccc) (/Users/Workspace/check-md/test/fixtures/docs1/test.md:21:1) 81 | Hash should slugify: [test12](./other.md#ctx.get(name) (/Users/Workspace/check-md/test/fixtures/docs1/test.md:27:1) 82 | File is not found: [test16](./123.md#ctx.get(name) (/Users/Workspace/check-md/test/fixtures/docs1/test.md:39:1) 83 | 84 | Executes with --fix to fix automatically 85 | 86 | Checking failed 87 | ``` -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - nodejs_version: '8' 4 | - nodejs_version: '10' 5 | - nodejs_version: '11' 6 | 7 | install: 8 | - ps: Install-Product node $env:nodejs_version 9 | - npm i npminstall && node_modules\.bin\npminstall 10 | 11 | test_script: 12 | - node --version 13 | - npm --version 14 | - npm run test 15 | 16 | build: off 17 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const { Command } = require('commander'); 6 | const { checkAndThrow, presetConfig } = require('./'); 7 | const packInfo = require('./package.json'); 8 | const program = new Command() 9 | .version(packInfo.version, '-v, --version') 10 | .usage('[options]') 11 | .option('-f, --fix', 'Check and try to fix') 12 | .option('-c, --cwd [path]', 'Working directory of check-md, default to process.cwd()') 13 | .option('-r, --root [path]', `Checking url root, default to ${presetConfig.default.root.join(',')}`) 14 | .option('-p, --preset [name]', `Preset config(eg ${Object.keys(presetConfig).join(', ')})`) 15 | .option('-P, --pattern [pattern]', `Glob patterns, default to ${presetConfig.default.pattern}`) 16 | .option('-i, --ignore [pattern]', `Ignore patterns, will merge to pattern, default to ${presetConfig.default.ignore.join(',')}`) 17 | .option('--ignore-footnotes', `Ignore footnotes, default to ${presetConfig.default.ignoreFootnotes}`) 18 | .option('--exit-level [level]', `Process exit level, default to ${presetConfig.default.exitLevel}, other choice is warn and none, it will not exit if setting to none`) 19 | .option('--default-index [index]', `Default index in directory, default to ${presetConfig.default.defaultIndex.join(',')}`); 20 | 21 | program.parse(process.argv); 22 | 23 | const options = { 24 | fix: !!program.fix, 25 | cwd: program.cwd, 26 | preset: program.preset, 27 | exitLevel: program.exitLevel, 28 | pattern: program.pattern ? program.pattern.split(',') : undefined, 29 | ignore: program.ignore ? program.ignore.split(',') : undefined, 30 | ignoreFootnotes: program.ignoreFootnotes, 31 | defaultIndex: program.defaultIndex ? program.defaultIndex.split(',') : undefined, 32 | }; 33 | 34 | Object.keys(options).forEach(k => { 35 | if (options[k] === undefined) delete options[k]; 36 | }); 37 | 38 | const packageInfoPath = path.resolve(process.cwd(), 'package.json'); 39 | const packageInfo = fs.existsSync(packageInfoPath) ? JSON.parse(fs.readFileSync(packageInfoPath, { encoding: 'utf-8' })) : {}; 40 | if (packageInfo['check-md']) { 41 | // read from package.json 42 | Object.assign(options, packageInfo['check-md']); 43 | } 44 | 45 | checkAndThrow(options); 46 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | // Generate by [js2dts](https://github.com/whxaxes/js2dts#readme) 2 | 3 | export interface CheckOption { 4 | cwd: string; 5 | fix?: boolean; 6 | exitLevel?: "none" | "info" | "warn" | "error"; 7 | root?: string[]; 8 | defaultIndex?: string[]; 9 | preset?: string; 10 | pattern?: string | string[]; 11 | ignore?: string | string[]; 12 | } 13 | export interface ReportListItem { 14 | errMsg: string; 15 | matchUrl: string; 16 | fullText: string; 17 | fileUrl: string; 18 | line: number; 19 | col: number; 20 | } 21 | export interface ReportResult { 22 | msg: string; 23 | list: ReportListItem[]; 24 | type: "none" | "info" | "warn" | "error"; 25 | } 26 | export interface T100 { 27 | warning: ReportResult; 28 | deadlink: ReportResult; 29 | } 30 | /** 31 | * check markdown 32 | * @param {CheckOption} options 33 | */ 34 | declare function check_1(options: CheckOption): Promise; 35 | export const check: typeof check_1; 36 | /** 37 | * check and throw 38 | * @param {CheckOption} options 39 | */ 40 | declare function checkAndThrow_1(options: CheckOption): Promise; 41 | export const checkAndThrow: typeof checkAndThrow_1; 42 | export interface T101 { 43 | root: string[]; 44 | cwd: string; 45 | } 46 | export interface T102 { 47 | defaultIndex: string[]; 48 | root: string[]; 49 | pattern: string; 50 | ignore: string[]; 51 | cwd: string; 52 | exitLevel: string; 53 | } 54 | export interface T103 { 55 | vuepress: T101; 56 | default: T102; 57 | } 58 | export const presetConfig: T103; 59 | /** 60 | * set content with cache 61 | * @param {String} fileUrl 62 | * @param {String} content 63 | */ 64 | declare function setContent_1(fileUrl: string, content: string): void; 65 | export const setContent: typeof setContent_1; 66 | export interface CacheObj { 67 | content: string; 68 | dirty: boolean; 69 | fileUrl: string; 70 | headings?: string[]; 71 | } 72 | /** 73 | * get content with cache 74 | * @param {String} fileUrl 75 | * @return {CacheObj} 76 | */ 77 | declare function getContent_1(fileUrl: string): CacheObj; 78 | export const getContent: typeof getContent_1; 79 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const glob = require('globby'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const chalk = require('chalk'); 5 | const url = require('url'); 6 | const diacritics = require('diacritics'); 7 | const assert = require('assert'); 8 | const headingRE = /(?:\r?\n|^)#+([^\n]+)/g; 9 | const imgTitleRE = /^(.*?) ".*?"$/; 10 | const imgSizeRE = /^(.*?) =[\dx]+$/; 11 | const matchUrlStr = c => `([^${c}]*)`; 12 | const matchAnchorStr = `((?:\\!)?\\[[^\\]\\r\\n]+\\])(?:(?:\\: *${matchUrlStr('\\r\\n')})|(?:\\(${matchUrlStr('\\)')}\\)))`; 13 | const matchAnchorRE = new RegExp(`(?:\\r?\\n|\`\`\`|${matchAnchorStr})`); 14 | // eslint-disable-next-line no-control-regex 15 | const rControl = /[\u0000-\u001f]/g; 16 | const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'<>,.?/]+/g; 17 | const LOG_LEVELS = { 18 | none: 0, 19 | info: 1, 20 | warn: 2, 21 | error: 3, 22 | }; 23 | 24 | /** @type {Map} */ 25 | const contentCache = new Map(); 26 | const dirtyContentList = []; 27 | 28 | const presetConfig = { 29 | vuepress: { 30 | root: [ './', './.vuepress/public' ], 31 | slugify: defaultSlugify, 32 | cwd: path.resolve(process.cwd(), './docs'), 33 | }, 34 | default: { 35 | defaultIndex: [ 'README.md', 'readme.md' ], 36 | root: [ './' ], 37 | pattern: '**/*.md', 38 | ignore: [ '**/node_modules' ], 39 | ignoreFootnotes: false, 40 | cwd: process.cwd(), 41 | exitLevel: 'error', 42 | slugify: defaultSlugify, 43 | }, 44 | }; 45 | 46 | /** 47 | * @typedef {Object} CacheObj 48 | * @property {String} CacheObj.content 49 | * @property {Boolean} CacheObj.dirty 50 | * @property {String} CacheObj.fileUrl 51 | * @property {Array} [CacheObj.headings] 52 | */ 53 | 54 | /** 55 | * @typedef {Object} CheckOption 56 | * @property {String} CheckOption.cwd 57 | * @property {Boolean} [CheckOption.fix] 58 | * @property {keyof LOG_LEVELS} [CheckOption.exitLevel] 59 | * @property {Array} [CheckOption.root] 60 | * @property {Array} [CheckOption.defaultIndex] 61 | * @property {String} [CheckOption.preset] 62 | * @property {String | Array} [CheckOption.pattern] 63 | * @property {String | Array} [CheckOption.ignore] 64 | * @property {Boolean} [CheckOption.ignoreFootnotes] 65 | */ 66 | 67 | /** 68 | * @typedef {Object} ReportListItem 69 | * @property {String} ReportResult.errMsg 70 | * @property {String} ReportResult.matchUrl 71 | * @property {String} ReportResult.fullText 72 | * @property {String} ReportResult.fileUrl 73 | * @property {Number} ReportResult.line 74 | * @property {Number} ReportResult.col 75 | */ 76 | 77 | /** 78 | * @typedef {Object} ReportResult 79 | * @property {String} ReportResult.msg 80 | * @property {Array} ReportResult.list 81 | * @property {keyof LOG_LEVELS} ReportResult.type 82 | */ 83 | 84 | /** 85 | * check md's heading 86 | * @param {String} fileUrl - fileUrl 87 | * @param {String} heading - heading 88 | * @param {Function} slugify - slugify 89 | * @return {Boolean} - check result 90 | */ 91 | function hasHeading(fileUrl, heading, slugify) { 92 | const cacheObj = getContent(fileUrl); 93 | if (!cacheObj.headings) { 94 | cacheObj.headings = []; 95 | cacheObj.content.replace(headingRE, (_, hash) => { 96 | cacheObj.headings.push(slugify(hash.trim())); 97 | }); 98 | } 99 | 100 | heading = heading.toLowerCase(); 101 | return cacheObj.headings.includes(heading); 102 | } 103 | 104 | // slugify 105 | function defaultSlugify(str, lower = true) { 106 | str = diacritics.remove(str) 107 | // Remove control characters 108 | .replace(rControl, '') 109 | // Replace special characters 110 | .replace(rSpecial, '-') 111 | // Remove continous separators 112 | .replace(/\-{2,}/g, '-') 113 | // Remove prefixing and trailing separtors 114 | .replace(/^\-+|\-+$/g, '') 115 | // ensure it doesn't start with a number (#121) 116 | .replace(/^(\d)/, '_$1'); 117 | 118 | if (lower) { 119 | return str.toLowerCase();// lowercase 120 | } 121 | 122 | return str; 123 | } 124 | 125 | /** 126 | * get content with cache 127 | * @param {String} fileUrl - fileUrl 128 | * @return {CacheObj} - CacheObj 129 | */ 130 | function getContent(fileUrl) { 131 | if (contentCache.has(fileUrl)) { 132 | return contentCache.get(fileUrl); 133 | } 134 | 135 | const content = fs.readFileSync(fileUrl, { encoding: 'utf-8' }); 136 | const cacheObj = { fileUrl, content, dirty: false }; 137 | contentCache.set(fileUrl, cacheObj); 138 | return cacheObj; 139 | } 140 | 141 | /** 142 | * set content with cache 143 | * @param {String} fileUrl - fileUrl 144 | * @param {String} content - content 145 | */ 146 | function setContent(fileUrl, content) { 147 | const contentResult = getContent(fileUrl); 148 | if (contentResult.content === content) { 149 | return; 150 | } 151 | 152 | contentResult.content = content; 153 | if (!contentResult.dirty) { 154 | contentResult.dirty = true; 155 | dirtyContentList.push(contentResult); 156 | } 157 | } 158 | 159 | // flush set content 160 | function flushSetContent() { 161 | dirtyContentList.forEach(item => { 162 | fs.writeFileSync(item.fileUrl, item.content); 163 | item.dirty = false; 164 | }); 165 | dirtyContentList.length = 0; 166 | } 167 | 168 | /** 169 | * @param {Object} options - options 170 | * @param {ReportResult['type']} options.type - options.type 171 | * @param {(p: ReportResult) => ReportResult['msg']} options.msgFn - options.msgFn 172 | * @return {ReportResult} - result in ReportResult Format 173 | */ 174 | function createReportResult({ type, msgFn }) { 175 | return { 176 | type, 177 | list: [], 178 | get msg() { return msgFn(this); }, 179 | }; 180 | } 181 | 182 | // check file exist 183 | const existCache = new Map(); 184 | function fileExist(fileUrl) { 185 | if (existCache.has(fileUrl)) { 186 | return existCache.get(fileUrl); 187 | } 188 | const isExist = fs.existsSync(fileUrl); 189 | existCache.set(fileUrl, isExist); 190 | return isExist; 191 | } 192 | 193 | // get file stat 194 | function getFileStat(fileUrl) { 195 | if (!fileExist(fileUrl)) { 196 | return; 197 | } 198 | return fs.statSync(fileUrl); 199 | } 200 | 201 | /** 202 | * init option 203 | * @param {CheckOption} options - options 204 | */ 205 | function initOption(options) { 206 | if (options.__init__) return options; 207 | 208 | if (options.preset && presetConfig[options.preset]) { 209 | options = Object.assign({}, presetConfig.default, presetConfig[options.preset], options); 210 | } else { 211 | options = Object.assign({}, presetConfig.default, options); 212 | } 213 | 214 | options.__init__ = true; 215 | return options; 216 | } 217 | 218 | /** 219 | * check markdown 220 | * @param {CheckOption} options - options 221 | */ 222 | async function check(options) { 223 | options = initOption(options); 224 | const { cwd, defaultIndex, root, fix, pattern, ignore, ignoreFootnotes } = options; 225 | assert(Array.isArray(root), 'options.root must be array'); 226 | const globPattern = (Array.isArray(pattern) ? pattern : [ pattern ]).concat( 227 | (Array.isArray(ignore) ? ignore : [ ignore ]).map(p => `!${p}`) 228 | ); 229 | 230 | const files = await glob(globPattern, { cwd }); 231 | const result = { 232 | warning: createReportResult({ 233 | msgFn(r) { return `${r.list.length} warning was found`; }, 234 | type: 'warn', 235 | }), 236 | deadlink: createReportResult({ 237 | msgFn(r) { return `${r.list.length} dead links was found`; }, 238 | type: 'error', 239 | }), 240 | }; 241 | 242 | // normalize url 243 | const normalizeUrl = (fileUrl, ext) => { 244 | ext = ext || path.extname(fileUrl); 245 | if (ext === '.html') { 246 | // convert html to md 247 | return `${fileUrl.substring(0, fileUrl.length - 4)}md`; 248 | } else if (!ext) { 249 | const stat = getFileStat(fileUrl); 250 | if (fileUrl.endsWith('/') || (stat && stat.isDirectory())) { 251 | // directory, try to find file with defaultIndex 252 | return defaultIndex.map(index => `${fileUrl}/${index}`).find(f => fileExist(f)); 253 | } 254 | 255 | return `${fileUrl}.md`; 256 | } 257 | return fileUrl; 258 | }; 259 | 260 | // each files 261 | files.forEach(file => { 262 | const fileUrl = path.resolve(cwd, file); 263 | const dirname = path.dirname(fileUrl); 264 | let { content } = getContent(fileUrl); 265 | let matches; 266 | let line = 1; 267 | let newContent = ''; 268 | let lineIndex = 0; 269 | let scanIndex = 0; 270 | let collectContent = ''; 271 | let inBlock = false; 272 | 273 | while ((matches = content.match(matchAnchorRE))) { 274 | const char = matches[0]; 275 | let matchUrl = (matches[2] || matches[3] || '').trim(); 276 | const isVariable = !!matches[2]; 277 | const beforeContent = content.substring(0, matches.index); 278 | let newChar = char; 279 | collectContent += beforeContent + char; 280 | 281 | if (char === '\n' || char === '\r\n') { 282 | // new line 283 | line++; 284 | lineIndex = scanIndex + matches.index + char.length; 285 | } else if (char === '```') { 286 | // code block 287 | inBlock = !inBlock; 288 | } else if (!inBlock) { 289 | // Support image alt attribute 290 | const imgTitleMatch = matchUrl.match(imgTitleRE); 291 | if (imgTitleMatch) { 292 | matchUrl = imgTitleMatch[1]; 293 | } 294 | // Support image alt attribute 295 | const imgSizeMatch = matchUrl.match(imgSizeRE); 296 | if (imgSizeMatch) { 297 | matchUrl = imgSizeMatch[1]; 298 | } 299 | const col = collectContent.length - char.length - lineIndex + 1; 300 | const baseReportObj = { matchUrl, fullText: char, fileUrl, line, col }; 301 | const urlObj = url.parse(matchUrl); 302 | if (urlObj.protocol) { 303 | // do nothing with remote url 304 | } else if (ignoreFootnotes && char.startsWith('[^')) { 305 | // do nothing with footnote 306 | } else if (!matchUrl) { 307 | // empty url 308 | result.deadlink.list.push({ ...baseReportObj, errMsg: 'Url link is empty' }); 309 | } else { 310 | // only handle local url 311 | const pathname = urlObj.pathname || ''; 312 | let ext = path.extname(pathname); 313 | let matchAbUrl; 314 | 315 | if (pathname) { 316 | if (pathname.charAt(0) === '/') { 317 | // find exist file 318 | matchAbUrl = root.map(r => normalizeUrl(path.join(cwd, r, pathname.substring(1)), ext)) 319 | .find(f => fileExist(f)); 320 | } else { 321 | matchAbUrl = path.resolve(dirname, pathname); 322 | } 323 | } else { 324 | matchAbUrl = fileUrl; 325 | ext = path.extname(matchAbUrl); 326 | } 327 | 328 | matchAbUrl = matchAbUrl && normalizeUrl(matchAbUrl, ext); 329 | if (ext === '.html') { 330 | // warning 331 | if (fix) { 332 | // replace .html to .md 333 | urlObj.pathname = `${urlObj.pathname.substring(0, urlObj.pathname.length - 4)}md`; 334 | } else { 335 | result.warning.list.push({ ...baseReportObj, errMsg: 'Should use .md instead of .html' }); 336 | } 337 | } 338 | 339 | if (!matchAbUrl || !fileExist(matchAbUrl)) { 340 | // file is not found 341 | result.deadlink.list.push({ ...baseReportObj, errMsg: 'File is not found' }); 342 | } else if (urlObj.hash) { 343 | let hash = decodeURIComponent(urlObj.hash.substring(1)); 344 | 345 | const slugify = options.slugify || defaultSlugify; 346 | // check slugify 347 | const slugHash = slugify(hash, false); 348 | 349 | if (slugHash !== hash) { 350 | if (fix) { 351 | urlObj.hash = slugHash; 352 | } else { 353 | result.deadlink.list.push({ ...baseReportObj, errMsg: 'Hash should slugify' }); 354 | } 355 | 356 | hash = slugHash; 357 | } 358 | 359 | if (!hasHeading(matchAbUrl, hash, slugify)) { 360 | // hash is not found 361 | result.deadlink.list.push({ ...baseReportObj, errMsg: 'Hash is not found' }); 362 | } 363 | } 364 | 365 | if (fix) { 366 | const newUrl = url.format(urlObj); 367 | if (newUrl !== matchUrl) { 368 | newChar = `${matches[1]}${isVariable ? `: ${newUrl}` : `(${newUrl})`}`; 369 | } 370 | } 371 | } 372 | } 373 | 374 | scanIndex += matches.index + char.length; 375 | content = content.substring(matches.index + char.length); 376 | newContent += beforeContent + newChar; 377 | } 378 | 379 | newContent += content; 380 | if (fix) setContent(fileUrl, newContent); 381 | }); 382 | 383 | flushSetContent(); 384 | return result; 385 | } 386 | 387 | 388 | /** 389 | * check and throw 390 | * @param {CheckOption} options - options 391 | */ 392 | async function checkAndThrow(options) { 393 | options = initOption(options); 394 | 395 | console.info('Checking markdown...'); 396 | const result = await check(options); 397 | 398 | const errorLevels = []; 399 | Object.keys(result).forEach(k => { 400 | /** @type {ReportResult} */ 401 | const item = result[k]; 402 | if (!item.list.length) { 403 | return; 404 | } 405 | 406 | const level = LOG_LEVELS[item.type]; 407 | errorLevels.push(level); 408 | if (level > LOG_LEVELS.none) { 409 | console[item.type](convertErrMsg(item)); 410 | } 411 | }); 412 | 413 | // tips for fix 414 | if (errorLevels.length) { 415 | console.info(chalk.gray('Executes with --fix to fix automatically\n')); 416 | } 417 | 418 | // should not exit if exitLevel is none 419 | const exitLevel = LOG_LEVELS[options.exitLevel.toLowerCase()]; 420 | if (exitLevel !== LOG_LEVELS.none && errorLevels.find(level => level >= exitLevel)) { 421 | console.error(chalk.red('Checking failed\n')); 422 | process.exit(1); 423 | } else { 424 | console.info(chalk.green('Checking passed\n')); 425 | } 426 | } 427 | 428 | /** 429 | * @param {ReportResult} obj - obj 430 | */ 431 | function convertErrMsg(obj) { 432 | return `\n${obj.type === 'error' ? chalk.red(obj.msg) : (obj.type === 'warn' ? chalk.yellow(obj.msg) : obj.msg)}\n\n` + 433 | obj.list 434 | .map(item => ` ${chalk.red(item.errMsg)}: ${item.fullText} ${chalk.gray(`(${item.fileUrl}:${item.line}:${item.col})`)}`) 435 | .join('\n') + 436 | '\n'; 437 | } 438 | 439 | // export list 440 | exports.check = check; 441 | exports.checkAndThrow = checkAndThrow; 442 | exports.presetConfig = presetConfig; 443 | exports.setContent = setContent; 444 | exports.getContent = getContent; 445 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "check-md", 3 | "version": "1.0.2", 4 | "description": "Checks dead links of markdown", 5 | "bin": { 6 | "check-md": "./bin.js" 7 | }, 8 | "dependencies": { 9 | "@sindresorhus/slugify": "^0.8.0", 10 | "chalk": "^2.4.2", 11 | "commander": "^2.19.0", 12 | "diacritics": "^1.3.0", 13 | "globby": "^9.1.0", 14 | "yargs-parser": "^20.2.1" 15 | }, 16 | "devDependencies": { 17 | "autod": "^3.0.1", 18 | "coffee": "^5.2.1", 19 | "egg-bin": "^4.3.7", 20 | "egg-ci": "^1.8.0", 21 | "egg-mock": "^3.21.0", 22 | "eslint": "^4.18.1", 23 | "eslint-config-egg": "^7.0.0", 24 | "js2dts": "^0.3.0" 25 | }, 26 | "engines": { 27 | "node": ">=8.0.0" 28 | }, 29 | "scripts": { 30 | "autod": "autod", 31 | "lint": "eslint .", 32 | "test": "npm run lint -- --fix && egg-bin pkgfiles && npm run test-local", 33 | "test-local": "egg-bin test", 34 | "gen-dts": "j2d ./index.js", 35 | "pub": "npm run gen-dts && npm run pkgfiles && npm publish", 36 | "cov": "egg-bin cov", 37 | "ci": "npm run lint && egg-bin pkgfiles --check && npm run cov", 38 | "pkgfiles": "egg-bin pkgfiles" 39 | }, 40 | "ci": { 41 | "version": "8, 10, 11" 42 | }, 43 | "eslintIgnore": [ 44 | "coverage", 45 | "dist" 46 | ], 47 | "repository": { 48 | "type": "git", 49 | "url": "https://github.com/whxaxes/check-md.git" 50 | }, 51 | "files": [ 52 | "index.js", 53 | "bin.js", 54 | "index.d.ts" 55 | ], 56 | "publishConfig": { 57 | "registry": "https://registry.npmjs.org/" 58 | }, 59 | "main": "index.js", 60 | "types": "index.d.ts", 61 | "author": "whxaxes ", 62 | "license": "MIT" 63 | } 64 | -------------------------------------------------------------------------------- /test/bin.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const coffee = require('coffee'); 3 | 4 | describe('test/bin.test.js', () => { 5 | const binPath = path.resolve(__dirname, '../bin.js'); 6 | it('should works', async () => { 7 | await coffee.fork(binPath, [ '-c', path.resolve(__dirname, './fixtures/docs1') ]) 8 | .expect('stderr', /Checking failed/) 9 | .expect('stderr', /\d+ dead links was found/) 10 | .expect('stderr', /\d+ warning was found/) 11 | .expect('code', 1) 12 | // .debug() 13 | .end(); 14 | }); 15 | 16 | it('should works with preset', async () => { 17 | await coffee.fork(binPath, [ '-p', 'vuepress' ], { cwd: path.resolve(__dirname, './fixtures/vuepress') }) 18 | .expect('stderr', /Checking failed/) 19 | .expect('stderr', /readme\.md/) 20 | .notExpect('stderr', /other\.md/) 21 | .notExpect('stderr', /\!\[avatar\]\(\/5856440\.jpg\)/) 22 | .expect('code', 1) 23 | // .debug() 24 | .end(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/fixtures/docs1/dir/README.md: -------------------------------------------------------------------------------- 1 | # Test 2 | 3 | -------------------------------------------------------------------------------- /test/fixtures/docs1/dir/placeholder-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whxaxes/check-md/5646387a1c20501423f668ecd5d955f12d99adde/test/fixtures/docs1/dir/placeholder-image.png -------------------------------------------------------------------------------- /test/fixtures/docs1/other.md: -------------------------------------------------------------------------------- 1 | ## ctx.get(name) 2 | 3 | test 4 | 5 | 6 | ## 测试中文 7 | 8 | ##testOther 9 | -------------------------------------------------------------------------------- /test/fixtures/docs1/test.md: -------------------------------------------------------------------------------- 1 | # ABC 2 | 3 | test test 4 | 5 | abc [test1]() 6 | 7 | [test2](./other) 8 | 9 | [test3](./other#testother) 10 | 11 | absbsbs 测试 sss[test4](./other#测试中文) absbsbs 测试 sss 12 | asd 13 | aaaaa[test5](./other.md#ctx-get-name) asdasd 14 | 15 | [test6](./other.html#ctx-get-name) 16 | 17 | [test7](/other#ctx-get-name) 18 | 19 | [test8](/other#cccc) 20 | 21 | 11111111111测[test9](/123.md#cccc)asdasd 22 | 23 | [test10]: ./dir/ 24 | 25 | [test11]: /dir/ 26 | 27 | aaasss222222 [test12](./other.md#ctx.get(name)) 28 | aaa 29 | ``` 30 | [test13](./123.md#ctx.get(name)) 31 | ``` 32 | 33 | ```bash 34 | [test14](./123.md#ctx.get(name)) 35 | ``` 36 | 37 | asdasd11123 ```[test15](./123.md#ctx.get(name))``` 38 | 39 | [test16](./123.md#ctx.get(name)) 40 | 41 | Footnotes[^test17] test ref 42 | 43 | [^test17]: Footnote description 44 | 45 | ![test18](./dir/placeholder-image.png "Test title attribute") 46 | 47 | ![test19](./dir/not-exist.png "Test title attribute") 48 | 49 | ![test20](./dir/not-exist.png =200x) 50 | 51 | ![test21](./dir/not-exist.png =100x100) 52 | -------------------------------------------------------------------------------- /test/fixtures/vuepress/docs/.vuepress/public/5856440.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/whxaxes/check-md/5646387a1c20501423f668ecd5d955f12d99adde/test/fixtures/vuepress/docs/.vuepress/public/5856440.jpg -------------------------------------------------------------------------------- /test/fixtures/vuepress/docs/readme.md: -------------------------------------------------------------------------------- 1 | # My docs site 2 | 3 | Here is my docs site 4 | 5 | [asdasd](./asdasd.md) 6 | 7 | ![avatar](/5856440.jpg) 8 | -------------------------------------------------------------------------------- /test/fixtures/vuepress/other.md: -------------------------------------------------------------------------------- 1 | # Other 2 | 3 | Should not check me 4 | 5 | [asdasd](./asdasd.md) 6 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const checkMd = require('../'); 3 | const fs = require('fs'); 4 | const mm = require('egg-mock'); 5 | const assert = require('assert'); 6 | 7 | describe('test/index.test.js', () => { 8 | afterEach(mm.restore); 9 | 10 | it('should works without error', async () => { 11 | const result = await checkMd.check({ cwd: path.resolve(__dirname, './fixtures/docs1') }); 12 | assert(result.deadlink.list.length === 9); 13 | assert(result.warning.list.length === 1); 14 | assert(result.deadlink.list[0].fullText.includes('[test1]')); 15 | assert(result.deadlink.list[0].line === 5); 16 | assert(result.deadlink.list[0].col === 5); 17 | assert(result.deadlink.list[1].fullText.includes('[test8]')); 18 | assert(result.deadlink.list[1].line === 19); 19 | assert(result.deadlink.list[1].col === 1); 20 | assert(result.deadlink.list[2].fullText.includes('[test9]')); 21 | assert(result.deadlink.list[2].line === 21); 22 | assert(result.deadlink.list[2].col === 13); 23 | assert(result.deadlink.list[3].fullText.includes('[test12]')); 24 | assert(result.deadlink.list[4].fullText.includes('[test16]')); 25 | assert(result.deadlink.list[3].errMsg.includes('slugify')); 26 | assert(result.deadlink.list[5].fullText.includes('[^test17]')); 27 | assert(result.deadlink.list[5].line === 43); 28 | assert(result.deadlink.list[5].col === 1); 29 | assert(result.deadlink.list[6].fullText.includes('![test19]')); 30 | assert(result.warning.list[0].fullText.includes('[test6]')); 31 | 32 | const resultWithIgnoreFootnotes = await checkMd.check({ 33 | cwd: path.resolve(__dirname, './fixtures/docs1'), 34 | ignoreFootnotes: true, 35 | }); 36 | assert(resultWithIgnoreFootnotes.deadlink.list.length === 8); 37 | }); 38 | 39 | it('should fix without error', async () => { 40 | const setContents = {}; 41 | mm(fs, 'writeFileSync', (fileUrl, content) => { 42 | setContents[fileUrl] = content; 43 | }); 44 | await checkMd.check({ cwd: path.resolve(__dirname, './fixtures/docs1'), fix: true }); 45 | const fileList = Object.keys(setContents); 46 | assert(fileList.length === 1); 47 | assert(setContents[fileList[0]].includes('[test6](./other.md#ctx-get-name)')); 48 | assert(setContents[fileList[0]].includes('[test12](./other.md#ctx-get-name)')); 49 | }); 50 | }); 51 | --------------------------------------------------------------------------------