在前端頁面中使用Markdown並且優化a標籤

跡憶客發表於2021-12-03

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

相關文章