1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Markdown を HTML に変換するツールとして知られる Marked.js は、その軽量性、高速性、拡張性により、多くの JavaScript プロジェクトで使用されています。

本記事では、Marked.js の基本から高度な使い方までを解説します。

基本的な使い方

インストール

npm install marked

もしくは CDN を利用します。

<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

初歩的なコード

以下は、シンプルなMarkdownをHTMLに変換する例です。

<div id="content"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
  document.getElementById("content").innerHTML = marked.parse(
    "# 見出し\n\n**太字のテキスト**"
  );
</script>

セキュリティの注意点

Marked.js は、出力された HTML をサニタイズしません。そのため、未検証のデータを処理する場合は、XSS 攻撃を防ぐために必ずフィルタリングを行いましょう。

DOMPurifyを使用した例

DOMPurify.sanitize(
  marked.parse(`<img src="x" onerror="alert('危険!')">`)
);

Marked.js をカスタマイズ

前提知識

Marked.js をカスタマイズする上で必要な前提知識を説明します。

  • Marked.js の役割
    Markdown で記述された文章は Markdown パーサ(HTML 形式に変換するプログラム)を通じて、ブラウザ上で表示可能な HTML 形式に変換します。Marked.js もパーサのひとつです。

  • Markdown パーサの仕様
    Markdown パーサの仕様はさまざま存在します。CommonMark と CommonMark を拡張した GitHub Flavored Markdown (GFM) が有名です。

  • ブロックレベルとインラインレベル
    Markdown は、2 つの主要な要素タイプで構成されています、

    1. ブロックレベル: 見出しや段落、リストなど文章全体の構造を表します。
    2. インラインレベル: テキスト内で使う装飾(例: リンク、太字、画像)を表します。

    これらの要素は階層構造をとります。ブロック要素の中にブロックレベル・インラインレベルを、インラインレベルの中に他のインラインレベルをもつことができます。(インラインレベルの中にブロックレベルをもつことはありません。)

    const inlineHtml = marked.parseInline("**太字** _イタリック_");
    console.log(inlineHtml); // '<strong>太字</strong> <em>イタリック</em>'
    

    インラインレベルのみを HTML に変換したい場合は marked.parseInline を使用します。

  • Marked が Markdown から HTML に変換する流れ

    1. marked.parse は文字列を受け取ると、
    2. Lexer クラスが tokenizer クラスを利用してトークンという単位に分割し、その後、トークンをネストされたツリー構造に変換し、
    3. walkTokens 関数は、ツリー内のすべてのトークンを探索し、トークン内容の最終調整を行い、
    4. Parser クラスが Renderer クラスを利用してトークンツリーを HTML に変換します。

オプションの設定

以下は主要なオプションの一部です。

オプション デフォルト 説明
gfm true GitHub Flavored Markdown を使用
breaks false 単一の改行(\n)を <br> に変換
tokenizer new Tokenizer() Markdown で記述された文章を token に変換
renderer new Renderer() token を HTMLに変換
walkTokens null 各トークンの最終調整
marked.use({ gfm: true, breaks: true });
console.log(marked.parse("Hello\nWorld"));

Renderer

Parser がトークンツリー全体を管理し、Renderer が(Parser から渡された)各 token を実際に HTML へ変換する役割を持ちます。

既存の Renderer をカスタマイズする際は token がどのようなプロパティを持つか確認する必要があります。例えば、Heading の場合は typerawdepthtexttokens を持ちます。詳しくは markedjs- Tokens を参照してください。

// Override function
const renderer = {
  heading(token) {
    const text = this.parser.parseInline(token.tokens);
    const escapedText = text.toLowerCase().replace(/[^\w]+/g, "-");

    return `
            <h${token.depth}>
              <a name="${escapedText}" class="anchor" href="#${escapedText}">
                <span class="header-link"></span>
              </a>
              ${text}
            </h${token.depth}>`;
  },
  // hr(token) {
  //   ...
  // },
  // ...
};

marked.use({ renderer });
  • token には ブロックレベルインラインレベル があります。heading の場合は、子要素(tokens)はインラインレベルなため、子要素は parseInline メソッドを使用しています。
  • 複数回オーバーライドすると、最後に割り当てられたバージョンが優先されます。

Renderer のカスタマイズには markedjs- Renderer のコードが参考になります。

Tokenizer

Lexer が入力された文字列全体を管理し、Tokenizer が実際に文字列を Token へ変換する役割を持ちます。

// Override function
const tokenizer = {
  codespan(src) {
    const match = src.match(/^\$+([^\$\n]+?)\$+/);
    if (match) {
      return {
        type: 'codespan',
        raw: match[0],
        text: match[1].trim()
      };
    }

    // return false to use original codespan tokenizer
    return false;
  },
  // blockquote(src) {
  //   ...
  // },
  // ...
};

marked.use({ tokenizer });
  • 基本的に、引数は src だけでいいですが、
    • reflink メソッドは srclinksを引数にとります。
    • emStrong メソッドは srcmaskedSrcprevChar を引数にとります。
  • 返り値の Token の型は整合性を保つために、markedjs- Tokens のコードと同じにする必要があります。
  • 複数回オーバーライドすると、最後に割り当てられたバージョンが優先されます。

詳しくは markedjs- Tokenizer を参照してください。

walkTokens

Tokenize と Render の間で、トークン内容の最終調整したい場合は、walkTokens を利用します。

// Override function
const walkTokens = (token) => {
  if (token.type === 'heading') {
    token.depth += 1;
  }
};

marked.use({ walkTokens });
  • token の type から最終調整する token を判断しています。type については以下を参照してください。

Extensions

marked.use({extensions: []}) を使用すると、独自の Markdown 記法を追加することができます。

Extension を行うには、以下のプロパティを使用する必要があります。

  • name:
    • トークンを識別するために使用される文字列。
  • level
    • この拡張のトークナイザーを実行するタイミングを指定する文字列。
    • 'block' または 'inline' のどちらかを指定する必要があります。
  • start(string src)
    • トークンの開始位置を示すインデックスを返す関数。
  • tokenizer(string src, array tokens)
    • テキストを読み込み、トークンを返す関数
    • tokens には、これまでに lexer によって生成されたトークンの配列が含まれています。
  • renderer(object token)
    • トークンを読み込み、HTML 文字列を返す関数。

オプションで childTokens プロパティも使用できます。

const youTubeExtension = {
    name: 'youtube',
    level: 'block',

    start(text) {
        const match = /^::youtube\[([^\]]+)\]$/.exec(text);
        return match ? match.index : undefined;
    },
    tokenizer(text) {
        const match = /^::youtube\[([^\]]+)\]$/.exec(text);
        if (match) {
            const lineCount = match[0].split('\n').length;

            return {
                type: 'youtube',
                text: match[1],
                length: lineCount - 2,
                raw: match[0],
                tokens: []
            };
        }
    },
    renderer(token) {
        return renderYouTube(token.text);
    }
}

function renderYouTube(url) {
    let videoId = null;
    if (url.startsWith('https://www.youtube.com/watch?v=')) {
        videoId = url.split('&')[0].split('/watch?v=')[1];
    } else if (url.startsWith('https://youtu.be/')) {
        videoId = url.slice(17).split('?')[0];
    } else if (url.startsWith('https://www.youtube.com/shorts/') && url.length > 32) {
        videoId = url.slice(31).split('?')[0];
    } else if (url.startsWith('https://m.youtube.com/shorts/') && url.length > 31) {
        videoId = url.slice(29).split('?')[0];
    }
    // videoId は escape 処理をした方がよい
    if (videoId && videoId.length < 15) return `<iframe width="100%" height="100%" src="https://www.youtube.com/embed/${videoId}" frameborder="0" allowfullscreen></iframe>`;
    return '';
}

const extensions = [youTubeExtension]
marked.use({ extensions });

document.getElementById("content").innerHTML = marked.parse(
    "# Heading\n\n::youtube[https://www.youtube.com/watch?v=w6zgUcQnhIs]"
);

詳しくは Using Pro - Marked Documentation を参照してください。

おわりに

本記事で紹介したように、Marked.js はカスタマイズ性に優れており、自由に Markdown 記法を拡張できます。

今回紹介しなかった機能に HooksWorkers があります。Marked.js のカスタマイズ性をさらに高めるための強力な機能です。ぜひ公式ドキュメントを参照してください。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?