近期在自己的項目中加入了對 Markdown 語法 的支持,主要用到的是markedjs這個項目。該項目托管在github上,地址為:https://github.com/markedjs/marked/
項目的安裝
下載項目之后,在根目錄下執行如下 npm 命令 進行安裝
$ npm install
安裝完成之后最終項目的目錄結構如下
我們看一下根目錄下的 package.json 文件,部分內容如下。 json有自己的語法格式,可以參考 Json 教程
"scripts": { "test": "jasmine --config=jasmine.json", "test:all": "npm test && npm run test:lint", "test:unit": "npm test -- test/unit/**/*-spec.js", "test:specs": "npm test -- test/specs/**/*-spec.js", "test:lint": "eslint bin/marked .", "test:redos": "node test/vuln-regex.js", "test:update": "node test/update-specs.js", "rules": "node test/rules.js", "bench": "npm run rollup && node test/bench.js", "lint": "eslint --fix bin/marked .", "build:reset": "git checkout upstream/master lib/marked.js lib/marked.esm.js marked.min.js", "build": "npm run rollup && npm run minify", "build:docs": "node build-docs.js", "rollup": "npm run rollup:umd && npm run rollup:esm", "rollup:umd": "rollup -c rollup.config.js", "rollup:esm": "rollup -c rollup.config.esm.js", "minify": "uglifyjs lib/marked.js -cm --comments /Copyright/ -o marked.min.js", "minifyMessage": "uglifyjs ext/onmpwmessage.js -cm --comments /Copyright/ -o ext/onmpwmessage.min.js", "preversion": "npm run build && (git diff --quiet || git commit -am build)" }
執行如下命令
$ npm run build
命令執行完成會生成marked.min.js文件
最后我們將 marked.min.js
文件拷貝到我們的項目中,然后就可以使用了
使用markedjs 解析編譯Markdown內容
在頁面中引入 marked.min.js 文件
<script type="text/javascript" src="/js/marked.min.js"></script>
接下來就是對內容的解析了,首先要初始化marked
對象
marked.setOptions({ renderer: new marked.Renderer(), gfm: true, tables: true, breaks: false, pedantic: false, sanitize: false, smartLists: true, smartypants: false, highlight: function (code,lang) { //使用 highlight 插件解析文檔中代碼部分 return hljs.highlightAuto(code,[lang]).value; } });
然后調用marked
函數進行解析
let originText = "[跡憶客](https://www.jiyik.com)"; let newText = marked(originText); console.log(newText);
實際情況中我們可以通過ajax
從后台獲取markdown
的內容,然后通過marked
解析成 html,將解析后的 html 內容放到頁面中相應的地方即可。
說一下我的markdown的應用
本人的項目中不是在前端對Markdown進行轉換,而是在編輯器中按照Markdown語法編輯好內容之后,通過markedjs將內容轉換成html,存入到數據庫中,在前台取出來的直接就是解析后的內容了,可以直接顯示在頁面上。
對markedJs的優化
下面到了本次重點內容了,markedJs相對來說比較成熟,個人感覺功能還是比較全面的。然而美中不足的是,可能受markdown默認語法的影響,對 a
標簽 的解析只有是當前頁面打開,沒有新窗口打開的語法。也就是說對於下面的語法
[跡憶客](https://www.jiyik.com "這里是title")
最終只能轉換成
<a href="https://www.jiyik.com" title="這里是title">跡憶客</a>
如果我想要新窗口打開的a標簽,是沒有對應的語法可以使用的。總不能因為一個a標簽就將markedJs拋棄不用吧,面對這種情況,即然項目是開源的,那就試着看一下自己能不能加上這一屬性。
我總共用了三種方法來增加target這一屬性
直接暴力添加
最開始我是這么考慮的,在項目中一般都是在文章內容里才會用到markdown的語法。一般情況下文章內容中的跳轉都會使用新窗口打開。所以說,直接在解析后的a
標簽中加上屬性target="_blank"
。
按照這一思路,我就直接去看源碼。此種方式有個最簡單的方式就是全項目搜索<a
。找到構造 a 標簽的地方,在后面直接加上 target="_blank"
就可以了。
在項目中的 src/Renderer.js 文件中的140行左右
let out = '<a href="' + escape(href) + '"';
直接添加target屬性
let out = '<a href="' + escape(href) + '" target="_blank"';
然后在根目錄下執行命令
$ npm run build
將生成的 marked.min.js
應用到項目中。之后再新添加的a標簽都帶着 target="_blank"
屬性。
雖然添加上了,但是仔細想想這種方式和沒優化之前並沒有什么區別,只是一個新窗口,一個不新窗口。沒辦法進行控制是最痛苦的。要是能通過某種方式對這個屬性進行控制,那就完美了。
使用!控制屬性是否添加
要想能控制target屬性,就要在[]()
中使用某種符號進行標記。img
標簽對應的markdown的語法為![]()
。借鑒img
標簽的語法,我把嘆號放到中括號里面[!]
來實現對target屬性的控制。
要實現的效果如下
[跡憶客](https://www.jiyik.com) // 解析后為 <a href="https://www.jiyik.com">跡憶客</a> [!跡憶客](https://www.jiyik.com) // 解析后為 <a href="https://www.jiyik.com" target="_blank">跡憶客</a>
要實現這種效果,就不像上面一樣了,直接全項目搜索 <a
是沒什么用的。這里我使用了WebStorm打開marked項目,然后利用上面的調試工具,追蹤它的代碼。
首先要在webstorm中配置markedJs,使其能夠運行。首先新建 node.js 腳本運行
新建成功之后,可以在代碼中打上斷點,運用webstorm的調試功能來追蹤其代碼。
當然這里不能在項目的入口文件就打斷點,這樣在追蹤的過程中是很痛苦的,因為如果代碼層級很深的話,容易走着走着就迷路了。
先讀源碼,在認為和解析a標簽相關的地方打上斷點。在讀了源碼之后,我是在 src/Tokenizer.js文件中的 link()
方法里打上的斷點(在 474 行)
經過追蹤,最終跟到了src/Tokenizer.js中的outputLink()
方法中,其實現如下:
function outputLink(cap, link, raw) { const href = link.href; const title = link.title ? escape(link.title) : null; const text = cap[1].replace(/\\([\[\]])/g, '$1'); if (cap[0].charAt(0) !== '!') { return { type: 'link', raw, href, title, text, }; } else { return { type: 'image', raw, href, title, text: escape(text) }; } }
代碼中的 text 保存的就是 [跡憶客]
中的文本(跡憶客)。如果我們加上嘆號,[!跡憶客]
,那text的值為“!跡憶客”。這樣我們就可以對text的文本做一個判斷,如果第一個字母是嘆號!
,則就要將target的值設置為"_blank"。否則的話target就為空。然后在返回的對象中加上target屬性。修改后的代碼如下
function outputLink(cap, link, raw) { const href = link.href; const title = link.title ? escape(link.title) : null; const text = cap[1].replace(/\\([\[\]])/g, '$1'); if (cap[0].charAt(0) !== '!') { let a_text = text; let target = ""; if(a_text.charAt(0) === '!') { target = "_blank"; a_text = a_text.substring(1); // 這里將文本中的!去掉 } return { type: 'link', raw, href, title, text:a_text, target }; } else { return { type: 'image', raw, href, title, text: escape(text) }; } }
然后繼續追蹤代碼,來到了我們第一種方法中暴力添加的地方 link()
方法。這里我們不再使用暴力了,因為我們現在有選擇了,需要給link方法增加一個參數 target
。
link(href, title, text, target) { href = cleanUrl(this.options.sanitize, this.options.baseUrl, href); if (href === null) { return text; } let out = ''; if(target !== "") { out = '<a href="' + escape(href) + '" target="' + escape(target) + '"'; }else{ out = '<a href="' + escape(href) + '"'; } if (title) { out += ' title="' + title + '"'; } out += '>' + text + '</a>'; return out; }
然后我們繼續找到調用link
方法的地方—— src/Parser.js文件的第219行
在link方法調用的地方將 target參數傳過去
case 'link': { out += renderer.link(token.href, token.title, this.parseInline(token.tokens, renderer),token.target); break; }
到這里我們所有的代碼就修改完成了,接下來就是編譯項目,生成 marked.min.js 文件,在我的項目中使用了。
使用了一段時間,沒有發現什么問題。但是總感覺這種方式不夠徹底,當然不是說對於語法上不夠徹底,而是對於代碼上不夠徹底。在匹配出text之后,還要對text的首字母進行判斷,然后在截取字符串。效率上應該是有些不足(雖然實際情況沒什么影響,但是畢竟要本着精益求精的態度不是嗎,請允許我裝一下)。還是應該繼續優化代碼,接下來就來到了終極的方法
究極大招,修改規則
即然不想從文本那里動手,那就要改變其匹配的規則。同樣繼續使用webstorm斷點調試。可以發現對所有的標簽匹配的規則如下
const inline = { escape: /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/, autolink: /^<(scheme:[^\s\x00-\x1f<>]*|email)>/, url: noopTest, tag: '^comment' + '|^</[a-zA-Z][\\w:-]*\\s*>' // self-closing tag + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. <?php ?> + '|^<![a-zA-Z]+\\s[\\s\\S]*?>' // declaration, e.g. <!DOCTYPE html> + '|^<!\\[CDATA\\[[\\s\\S]*?\\]\\]>', // CDATA section link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/, reflink: /^!?\[(label)\]\[(?!\s*\])((?:\\[\[\]]?|[^\[\]\\])+)\]/, nolink: /^!?\[(?!\s*\])((?:\[[^\[\]]*\]|\\[\[\]]|[^\[\]])*)\](?:\[\])?/, reflinkSearch: 'reflink|nolink(?!\\()', emStrong: { lDelim: /^(?:\*+(?:([punct_])|[^\s*]))|^_+(?:([punct*])|([^\s_]))/, // (1) and (2) can only be a Right Delimiter. (3) and (4) can only be Left. (5) and (6) can be either Left or Right. // () Skip other delimiter (1) #*** (2) a***#, a*** (3) #***a, ***a (4) ***# (5) #***# (6) a***a rDelimAst: /\_\_[^_]*?\*[^_]*?\_\_|[punct_](\*+)(?=[\s]|$)|[^punct*_\s](\*+)(?=[punct_\s]|$)|[punct_\s](\*+)(?=[^punct*_\s])|[\s](\*+)(?=[punct_])|[punct_](\*+)(?=[punct_])|[^punct*_\s](\*+)(?=[^punct*_\s])/, rDelimUnd: /\*\*[^*]*?\_[^*]*?\*\*|[punct*](\_+)(?=[\s]|$)|[^punct*_\s](\_+)(?=[punct*\s]|$)|[punct*\s](\_+)(?=[^punct*_\s])|[\s](\_+)(?=[punct*])|[punct*](\_+)(?=[punct*])/ // ^- Not allowed for _ }, code: /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/, br: /^( {2,}|\\)\n(?!\s*$)/, del: noopTest, text: /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\<!\[`*_]|\b_|$)|[^ ](?= {2,}\n)))/, punctuation: /^([\spunctuation])/ };
這里我們只關心link規則
link: /^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/
原來你一開始就沒有把我們可愛的target考慮進去,target一定不是親生的。
即然你不要,那我們就自己動手將其加進去吧,修改規則如下
link: /^!?\[(target)(label)\]\(\s*(href)(?:\s+(title))?\s*\)/,
這還不夠,像里面的 target、label、href和title這都是一個標記,來說明此處應該是什么。用這種正則去匹配也匹配不出什么東西來啊。下面肯定還藏着有東西呢。於是繼續尋找,最終發現下面的代碼
inline._label = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; inline._href = /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/; inline._title = /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/; inline.link = edit(inline.link) .replace('label', inline._label) .replace('href', inline._href) .replace('title', inline._title) .getRegex();
啊哈哈,這就對上了。這是為了防止一個這么長的正則不好閱讀,所以才使用標記來進行說明,然后由程序自己來替換使用。還挺人性化的嗎,這里給點個贊。
這就好辦了,上面我們即然加上了target的標記,那這里我們也加個正則來匹配我們的嘆號!
inline._target = /!?/; inline.link = edit(inline.link) .replace('target',inline._target) .replace('label', inline._label) .replace('href', inline._href) .replace('title', inline._title) .getRegex();
因為我要捕獲匹配的結果,所以在上面target標記外面加了小括號(target)
。 這里是屬於正則表達式的知識點了。所以說正則表達式還是很重要的,如果不了解正則那我們也就沒有大招了。到了第二種方式也就停止了。看到這是不是有種想學習正則表達式的沖動了。點擊學習正則表達式。
接下來我們要對在第二種方式中修改的 outputlink()
方法再次進行修改
function outputLink(cap, link, raw) { const href = link.href; const title = link.title ? escape(link.title) : null; const text = cap[2].replace(/\\([\[\]])/g, '$1'); const target = (cap[1].length == 1 && cap[1] === '!')?"_blank":""; if (cap[0].charAt(0) !== '!') { return { type: 'link', raw, href, title, text, target }; } else { return { type: 'image', raw, href, title, text: escape(text) }; } }
看起來,是不是變簡單了呢。不過只是修改這里還是不行的,因為我們前面在正則中多加了一個捕獲組,所以對於之前的text、href和title它們的分組索引都要加 1 才對。
要在哪里修改呢,這里繼續往下尋找,又找到了一個link
方法,但是這個link
方法和之前加參數的 link 方法不同。該link
方法是 src/Tokenizer.js 文件中定義的。
link(src) { const cap = this.rules.inline.link.exec(src); if (cap) { const trimmedUrl = cap[3].trim(); // 原先為 cap[2].trim() if (!this.options.pedantic && /^</.test(trimmedUrl)) { // commonmark requires matching angle brackets if (!(/>$/.test(trimmedUrl))) { return; } // ending angle bracket cannot be escaped const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { return; } } else { // find closing parenthesis // 原先為 const lastParenIndex = findClosingBracket(cap[2], '()') const lastParenIndex = findClosingBracket(cap[3], '()'); if (lastParenIndex > -1) { const start = cap[0].indexOf('!') === 0 ? 5 : 4; const linkLen = start + cap[1].length + lastParenIndex; // 原先為 cap[2] = cap[2].substring(0, lastParenIndex); cap[3] = cap[3].substring(0, lastParenIndex); cap[0] = cap[0].substring(0, linkLen).trim(); cap[4] = ''; // 原先為 cap[3] = ''; } } let href = cap[3]; // 原先為 let href = cap[2]; let title = ''; if (this.options.pedantic) { // split pedantic href and title const link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href); if (link) { href = link[1]; title = link[3]; } } else { // 原先為 title = cap[3] ? cap[3].slice(1, -1) : ''; title = cap[4] ? cap[4].slice(1, -1) : ''; } href = href.trim(); if (/^</.test(href)) { if (this.options.pedantic && !(/>$/.test(trimmedUrl))) { // pedantic allows starting angle bracket without ending angle bracket href = href.slice(1); } else { href = href.slice(1, -1); } } return outputLink(cap, { href: href ? href.replace(this.rules.inline._escapes, '$1') : href, title: title ? title.replace(this.rules.inline._escapes, '$1') : title }, cap[0]); } }
第二種方式中修改的其他地方的代碼就不要再繼續動了,保持在第二種方式中的修改即可。
到此終極大招放完了。使用命令編譯生成 marked.min.js 文件就行了。