近期在自己的專案中加入了對 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 檔案就行了。