ことれいのもり

ParsedownのHTMLにCSSクラスをつける拡張クラスを作ってみた

はじめに

私が運営しているこのサイトは、記事内容をデータベースにMarkdown形式で保存しています。

そして、ページを表示するときにPHPのライブラリ「Parsedown」を使ってHTMLコードに変換しています。

しかし、Parsedownで変換されたHTMLにはタグにCSSクラス名がついていません。

CSSを適用させたい場合は、変換後のHTMLに対してクラスを追加する処理が必要になります。

今回はそのための拡張クラスを作ってみました。

前提

言語

拡張クラスの考え方

まず、ParsedownでMarkdown形式をHTMLに変換する例を見てみましょう。


$Parsedown = new Parsedown();

$markdown = '## タイトル\n\nここに記事内容が書かれます。';
$html = $Parsedown->text($markdown);

echo $html;


<h2>タイトル</h2>
<p>ここに記事内容が書かれます。</p>


ここで、h2タグに対してCSSを適用したい場合、クラスを追加する必要があります。

Parsedown自体にはクラスを追加する機能がないため、変換後のHTMLを正規表現で加工します。

例えば、以下のような関数を使って、全てのh2タグにクラスを追加できます。



  /**
   * h2タグにクラスをつける
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function addClassToH2($html)
  {
    $pattern = '/(<h2)([^>]*>)/';
    $replacement = '$1 class="underline-with-background"$2';
    return preg_replace($pattern, $replacement, $html);
  }


これを使うと、次のように変換されます。


<h2 class="underline-with-background">タイトル</h2>


これでCSSで自由に見た目を調整できるようになります。

拡張クラスの全体像

実際に私が使っている拡張クラスでは、以下のような処理を行なっています。

  • h2やh3タグにクラスを付与
  • 各見出し(h2~h6)と、それに続く本文をdivで囲む
  • コードブロック(pre~/pre)を別のdivで囲んで装飾しやすくする
  • 通常の段落もpタグで明示的に囲む


このようにすることで、Parsedownによって生成されたHTMLコードが見やすくなったりCSSの反映がしやすくなります。

以下に、実際の拡張クラスのコードを載せておきます。


GitHub Gistにもコードを載せていますので、コピペしたい方などはこちらをご覧ください。 →GitHub Gistで見る


<?php

class CustomParsedown extends Parsedown
{
  public function text($text)
  {
    // 親のメソッドでマークダウンをHTMLに変換
    $html = parent::text($text); 

    $html = $this->wrapParagraphs($html);
    $html = $this->wrapHeadingWithDiv($html);
    $html = $this->wrapCodeWithDiv($html);
    $html = $this->addClassToH2($html);
    $html = $this->addClassToH3($html);
    $html = $this->addClassToPre($html);

    return $html;
  }

  /**
   * <h2>~<h6>タグから次のh系タグまでのコンテンツを囲む
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function wrapHeadingWithDiv($html)
  {
    $pattern = '/(<h[2-6][^>]*>.*?<\/h[2-6]>)(.*?)(?=<h[2-6]|$)/s';  // h2~h6タグが基準
    $replacement = '<div class="article__content">$1$2</div>';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * <pre>~</pre>のコードブロック間を<div>で囲む
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function wrapCodeWithDiv($html)
  {
    $pattern = '/(<pre[^>]*>.*?<\/pre>)/s';
    $replacement = '<div class="code-block">$1</div>';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * h2タグにクラスをつける
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function addClassToH2($html)
  {
    $pattern = '/(<h2)([^>]*>)/';
    $replacement = '$1 class="underline-with-background"$2';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * h3タグにクラスをつける
   *
   * @param string $html HTMLに変換された文字列
   * @return void
   */
  private function addClassToH3($html)
  {
    $pattern = '/(<h3)([^>]*>)/';
    $replacement = '$1 class="orange-circle"$2';
    return preg_replace($pattern, $replacement, $html);
  }

  private function addClassToPre($html)
  {
    $pattern = '/(<pre)([^>]*>)/';
    $replacement = '$1 class="line-numbers" data-copy="true"$2';
    return preg_replace($pattern, $replacement, $html);
  }

  /**
   * 段落内を<p>タグで囲む
   *
   * @param string $html
   * @return void
   */
  private function wrapParagraphs($html)
  {
    // 文の中身をマッチさせる
    $html = preg_replace_callback('/([^\n\r]+(\n|\r\n)?)/', function ($matches) use (&$isInsidePre) {
      $line = $matches[0];

      // 空行の場合は何も返さない
      if ($line === '') {
        return '';
      }

      // <pre>タグ内でのみ<p>タグはつけない
      $preHandled = $this->handlePreTags($line, $isInsidePre);
      if ($preHandled !== null) {
        return $preHandled;
      }

      // 他のHTMLタグがある場合はそのまま返す
      if ($this->hasOtherTags($line)) {
        return $line . "\n";
      }

      // 既に<p></p>で囲まれているときはそのまま返す
      if ($this->isWrappedInPTags($line)) {
        return $line . "\n";
      }

      // <p>タグが一切ついていないときは両方追加
      if (!$this->hasPTags($line)) {
        return '<p>' . trim($matches[1]) . '</p>' . "\n";
      }

      // <p>のみないときは追加
      if ($this->hasClosingPTag($line)) {
        return '<p>' . trim($matches[1]);
      }

      // </p>のみないときは追加
      if (!$this->hasClosingPTag($line)){
        return trim($matches[1]) . '</p>' . "\n";
      }

      return $line;
    }, $html);

    return $html;
  }

  /**
   * 先頭に<p>タグ以外のタグがあるか判定
   *
   * @param string $line 
   * @return boolean
   */
  private function hasOtherTags($line) {
    // 先頭に<p>
    return substr($line, 0, 1) === '<' && substr($line, 1, 1) !== 'p';
  }

  /**
   * <pre>タグ内部の処理
   *
   * @param string $line
   * @param boolean $isInsidePre  <pre>タグ内部かどうか
   * @return boolean
   */
  private function handlePreTags($line, &$isInsidePre)
  {
    // <pre>タグの開始を検知
    if (strpos($line, '<pre') !== false) {
      $isInsidePre = true;
      return $line;
    }

    // <pre>タグの終了を検知
    if (strpos($line, '</pre>') !== false) {
      $isInsidePre = false;
      return $line;
    }

    if ($isInsidePre) {
      return $line;
    }

    return null;
  }

  /**
   * <p>タグで囲まれているか判定
   *
   * @param string $line
   * @return boolean
   */
  private function isWrappedInPTags($line)
  {
    return preg_match('/^<p>.*<\/p>$/s', $line);
  }

  /**
   * <p>と</p>の両方があるか判定
   *
   * @param string $line
   * @return boolean
   */
  private function hasPTags($line) {
    return strpos($line, '<p>') !== false || strpos($line, '</p>') !== false;
  }

  /**
   * </p>で終わっているかを判定
   *
   * @param string $line
   * @return boolean
   */
  private function hasClosingPTag($line) {
    return substr($line, -4) === '</p>';
  }
}

おわりに

Markdown形式からHTMLを生成してそのまま表示するだけだと、CSSが適用されていないものになります。

今回紹介したように、Parsedownの変換後のHTMLに対して加工を行なうことで、見やすい文章が生成されます!

「Markdown + HTML加工」で悩んでいる方の参考になれば幸いです。