Markdownで記事を綺麗に表示する — コードハイライトと目次【AIと一緒に作るDjangoブログ入門 #6.1】

Markdownで記事を綺麗に表示する — コードハイライトと目次【AIと一緒に作るDjangoブログ入門 #6.1】

第5章まで素のテキストだった記事本文を、Markdownで整形して表示できるようにする章。Python-Markdownでの変換、Qiita風のファイル名付きコードブロック、Pygmentsシンタックスハイライト、bleachによるサニタイズ、そしてサイドバーの目次(TOC)までを実装します。「書いたものが綺麗に見える」土台を、記事だけ見て作れる形で解説します。

はじめに

前章(第5章)で記事の表示・検索・カテゴリ機能が完成しました。ただ記事本文は linebreaks で「改行だけ反映」の素のテキストのまま。# 見出し**太字**、コードブロックを書いても、ただの文字として出てしまいます。

この章では、その本文を Markdownで整形して綺麗に表示 できるようにします。

やること 何ができるようになるか
Markdown レンダリング 記事を # 見出し **太字** - リスト で書ける
Qiita風コードブロック ```python:views.py でファイル名ラベル付き表示
シンタックスハイライト コードが Pygments で色付け表示される
サニタイズ(bleach) 変換後HTMLを安全に出力(XSS対策)
目次(TOC)サイドバー 見出しから自動で目次を作ってサイドバーに表示
ココココ

この章は「見せる側」の話。書いたMarkdownが本番でどう綺麗に出るか、を作り込むよ。次の #6.2 では「書く側」=WordPress風の記事エディタを作るからね!


Step1. Markdownライブラリを導入する

最初は「素のtextareaに書かれた Markdown を、表示時にHTMLへ変換する」だけを実現します。

~/myblog/requirements.txt に追加:

# Markdown → HTML 変換
markdown==3.10.2

# コードハイライト(markdownのcodehilite拡張 / 自前テンプレートタグの両方で使う)
pygments==2.20.0

# 変換後HTMLのサニタイズ(XSS対策)
bleach==6.3.0

バージョンは == で固定します。揃えておくと、読者の環境でも同じ挙動が再現できます。

ライブラリ選定の理由:

候補 選んだ理由 / 落ちた理由
markdown(採用) Python標準級・拡張機能豊富・Pygmentsとの相性◎。Django公式ドキュメントの例にも登場
markdown2 シンプルだが拡張性が低い
mistune 高速だが codehilite 連携と toc 拡張がやや手間

⚠️ 重要django-markdownx は採用しません。プレビューが本物そっくりにならない・画像が base64 で埋め込まれる、の2点が致命的。これは後段で Toast UI Editor + iframe プレビューに置き換えるからです。

📚 参考URL
- Python-Markdown 公式
- Pygments 公式

requirements.txtを変更したのでビルドが必要:

docker compose build --no-cache web

注意: ビルドのキャッシュが効いてしまうことがある。新しいパッケージを追加したときは --no-cache を付けると確実。


Step2. カスタムテンプレートタグを作る(Qiita風コードブロック対応)

テンプレートで {{ post.body|markdownify }} と書けるようにします。ただし普通の markdown ライブラリではできないことを1つ載せます。

やりたいこと:Qiita風にファイル名を出したい。

```python:views.py
def index(request):
    ...
```

通常の markdown ライブラリは python:views.py を1つの言語名として解釈してしまい、ファイル名が認識されません。前処理でファイル名付きブロックを取り出して、後でラベル付きで戻す戦略で解決します。

mkdir -p ~/myblog/blog/templatetags
touch ~/myblog/blog/templatetags/__init__.py
touch ~/myblog/blog/templatetags/markdown_tags.py
blog/templatetags/markdown_tags.py
"""
Markdown を HTML に変換するテンプレートタグ。

テンプレートでの使い方:
    {% load markdown_tags %}
    {{ post.body|markdownify|safe }}

特徴:
- Qiita風の「```python:filename.py」記法に対応
- Pygmentsでシンタックスハイライト
- ファイル名ラベル付きのコードブロックを生成
"""

import re

import bleach
import markdown as md
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe


# ============================================================
# bleach サニタイズ設定
# ============================================================
# 投稿者由来の HTML を「安全な要素・属性だけ」に絞り込む allowlist。
# 単独運用なら過剰だが、将来「複数人投稿」や「読者投稿」に拡張した時に
# XSS の入り口を残さないための保険。
# 注意:独自実装の要素(codehilite / 吹き出し / 目次 等)も許可リストに含めること。
#       漏れるとそれらの表示が壊れる。
# ============================================================

ALLOWED_TAGS = [
    'p', 'br', 'hr', 'div', 'span',
    'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
    'blockquote', 'pre', 'code',
    'ul', 'ol', 'li',
    'table', 'thead', 'tbody', 'tr', 'th', 'td',
    'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins', 'mark', 'sub', 'sup',
    'a', 'img',
    'cite', 'kbd', 'samp', 'var',
    'figure', 'figcaption',
    'button', 'svg', 'path', 'rect', 'circle',  # コピーボタン用
    'details', 'summary',
]

ALLOWED_ATTRIBUTES = {
    '*': ['class', 'id', 'title', 'data-source-line', 'aria-label', 'aria-hidden'],
    'a': ['href', 'rel', 'target'],
    'img': ['src', 'alt', 'width', 'height', 'loading'],
    'th': ['scope', 'colspan', 'rowspan'],
    'td': ['colspan', 'rowspan'],
    'button': ['type'],
    'svg': ['xmlns', 'width', 'height', 'viewBox', 'fill', 'stroke', 'stroke-width',
            'stroke-linecap', 'stroke-linejoin'],
    'path': ['d'],
    'rect': ['x', 'y', 'width', 'height', 'rx', 'ry'],
    'circle': ['cx', 'cy', 'r'],
}

ALLOWED_PROTOCOLS = ['http', 'https', 'mailto']


def _sanitize_html(html):
    """bleach allowlist でサニタイズしてXSSの入り口を防ぐ。"""
    return bleach.clean(
        html,
        tags=ALLOWED_TAGS,
        attributes=ALLOWED_ATTRIBUTES,
        protocols=ALLOWED_PROTOCOLS,
        strip=False,  # 不許可タグは消さずエスケープ表示(黙って消すとデバッグが辛い)
    )


register = template.Library()


def _replace_named_code_blocks(text):
    """「```lang:filename」記法を見つけて、後で復元できる目印に置き換える。

    通常のmarkdownライブラリはfilename部分を理解できないので、
    一旦取り出して、HTML変換後にファイル名ラベル付きで復元する。

    フェンスの入れ子に対応する(CommonMark準拠)。
    外側の長いフェンス(例: ````markdown)の“中”にある ```python:views.py は
    「記法を見せるための文字列」なので抽出せず、そのままコードとして表示する。
    トップレベルにある ```lang:filename だけを本物のファイル名ブロックとして取り出す。
    (単純な正規表現だと ```` の中の ``` まで拾ってしまい、入れ子の例が壊れる)
    """
    lines = text.split('\n')
    out = []
    blocks = []
    open_fence = ''  # 今いる“通常”フェンスの記号(``` や ```` ~~~)。空ならトップレベル
    i = 0
    n = len(lines)

    while i < n:
        line = lines[i]
        stripped = line.strip()
        m = re.match(r'^(`{3,}|~{3,})', stripped)

        if not m:
            out.append(line)
            i += 1
            continue

        marker = m.group(1)
        info = stripped[len(marker):].strip()

        if open_fence:
            # 外側フェンスの内側:閉じフェンス(同記号・同長以上・記号のみ)だけ反応
            if stripped == marker and marker[0] == open_fence[0] and len(marker) >= len(open_fence):
                open_fence = ''
            out.append(line)
            i += 1
            continue

        # ここはトップレベル
        named = re.match(r'^([\w+-]+):(.+)$', info)
        if marker[0] == '`' and named:
            # 本物のファイル名付きブロック → 同記号・同長以上の閉じフェンスまで収集
            language = named.group(1)
            filename = named.group(2).strip()
            code_lines = []
            i += 1
            while i < n:
                s2 = lines[i].strip()
                cm = re.match(r'^(`{3,}|~{3,})$', s2)
                if cm and s2[0] == marker[0] and len(s2) >= len(marker):
                    break
                code_lines.append(lines[i])
                i += 1
            idx = len(blocks)
            code = '\n'.join(code_lines) + '\n'
            blocks.append((language, filename, code))
            # markdownが触らないようなマーカーを残す
            out.append('')
            out.append(f'@@CODE_BLOCK_PLACEHOLDER_{idx}@@')
            out.append('')
            i += 1  # 閉じフェンス行をスキップ
            continue

        # 通常のフェンス開始(````markdown / ```python / ``` 等)→ 中身は触らない
        open_fence = marker
        out.append(line)
        i += 1

    return '\n'.join(out), blocks


def _render_code_block(language, filename, code):
    """ファイル名ラベル付きのコードブロックをHTML化する。"""
    from pygments import highlight
    from pygments.formatters import HtmlFormatter
    from pygments.lexers import get_lexer_by_name
    from pygments.util import ClassNotFound

    try:
        lexer = get_lexer_by_name(language, stripall=True)
    except ClassNotFound:
        lexer = get_lexer_by_name('text', stripall=True)

    formatter = HtmlFormatter(cssclass='codehilite', nowrap=False)
    highlighted = highlight(code, lexer, formatter)

    # ファイル名ラベル付きの構造で出力
    return (
        f'<div class="codehilite-wrap">'
        f'<div class="codehilite-filename">{escape(filename)}</div>'
        f'{highlighted}'
        f'</div>'
    )


@register.filter(name='markdownify')
def markdownify(value):
    """Markdown 文字列を HTML に変換する。

    使用拡張:
    - extra: テーブル・脚注などの追加記法
    - codehilite: コードブロックを Pygments でハイライト
    - toc: 目次の生成サポート
    - nl2br: 改行を <br> に変換
    """
    # Qiita風コードブロックを一旦取り出す
    text, named_blocks = _replace_named_code_blocks(value)

    # 通常のMarkdown変換
    html = md.markdown(
        text,
        extensions=['extra', 'codehilite', 'toc', 'nl2br'],
        extension_configs={
            'codehilite': {
                'css_class': 'codehilite',
                'linenums': False,
            },
        },
    )

    # 取り出したコードブロックを「ファイル名ラベル付き」で復元
    for idx, (language, filename, code) in enumerate(named_blocks):
        placeholder = f'@@CODE_BLOCK_PLACEHOLDER_{idx}@@'
        rendered = _render_code_block(language, filename, code)
        # markdownライブラリが <p>マーカー</p> に包んでしまう場合があるので両対応
        html = html.replace(f'<p>{placeholder}</p>', rendered)
        html = html.replace(placeholder, rendered)

    # bleach の allowlist でサニタイズしてから出力する(サニタイズ → mark_safe の順)
    return mark_safe(_sanitize_html(html))

ここで採用したテクニックの意図:

  • 前処理で先に取り出す:標準の markdown ライブラリのカスタムプロセッサで対応する道もありますが、API が頻繁に変わる・デバッグが辛い。前後処理で挟む方が「壊れにくい」のでこちらを採用しています。フェンスの開閉は行ごとに判定し、トップレベルのファイル名付きブロックだけを取り出します。
  • mark_safe の前に必ずサニタイズ:Django のテンプレートはデフォルトで HTML エスケープします。変換後 HTML を「そのまま出して良い」と明示するために mark_safe で包みますが、その前に bleach の allowlist でサニタイズしておきます。順番は「サニタイズ → mark_safe」が鉄則です。
  • extra / codehilite / toc / nl2br の組み合わせ:技術ブログとして「テーブル・コードハイライト・目次・段落の改行」を最低限カバーする、いちばん無難な組み合わせです。

⚠️ セキュリティ注意(mark_safe と XSS)
mark_safe は「この HTML はエスケープせずそのまま出す」という宣言なので、サニタイズしていない HTML を mark_safe すると XSS の穴になります<script>onclick 属性などがそのまま通る)。

いまは本文を書くのが管理者(自分)だけですが、将来コメント欄やユーザー投稿に拡張したときに穴を残さないよう、最初から bleach の allowlist でサニタイズしてから mark_safe しています。順番は「サニタイズ → mark_safe」が鉄則。

allowlist には、自前の要素(コードハイライトの span.codehilite・目次・コピーボタンの svg 等)も忘れず入れること。漏れると自分のデザインが消えるので、ALLOWED_TAGS / ALLOWED_ATTRIBUTES に追加しておきます。

(家訓:信頼できない値をそのまま HTML に流さない。)

目次抽出(extract_toc)も同じファイルに置く

サイドバーに出す目次のために、変換後HTMLから <h2 id="..."> <h3 id="..."> を拾ってネストしたリストを返す extract_toc フィルタも同じファイルに足します。markdowntoc 拡張が見出しに振ってくれた id を、正規表現で拾うだけです。

markdown_tags.py の末尾に追加:

blog/templatetags/markdown_tags.py
# 見出し(h2 / h3)の id とテキストを拾う正規表現。
# toc 拡張が振ってくれた id="..." を使って目次のジャンプ先にする。
HEADING_PATTERN = re.compile(
    r'<h([23])\s+id="([^"]+)"[^>]*>(.*?)</h\1>',
    re.DOTALL,
)


@register.filter(name='extract_toc')
def extract_toc(rendered_html):
    """レンダリング済みHTMLから目次を抽出する。

    h2 を親、h3 を子としてネスト構造で返す。
    h2 / h3 が無い場合は空文字(目次不要のケース)。
    """
    items = []
    for match in HEADING_PATTERN.finditer(rendered_html):
        level = int(match.group(1))
        anchor = match.group(2)
        # タグを剥がして純粋なテキストだけ取り出す
        text = re.sub(r'<[^>]+>', '', match.group(3)).strip()
        items.append((level, anchor, text))

    if not items:
        return ''

    html_parts = ['<ul class="toc-list">']
    h2_open = False   # 直前 h2 の <li> が開いたまま?
    in_sub = False    # h3 用の <ul> が開いたまま?

    def close_current_h2():
        nonlocal h2_open, in_sub
        if in_sub:
            html_parts.append('</ul>')
            in_sub = False
        if h2_open:
            html_parts.append('</li>')
            h2_open = False

    for level, anchor, text in items:
        anchor_e = escape(anchor)
        text_e = escape(text)
        if level == 2:
            close_current_h2()
            html_parts.append(
                f'<li class="toc-item toc-h2">'
                f'<a href="#{anchor_e}" data-toc-anchor="{anchor_e}">{text_e}</a>'
            )
            h2_open = True
        elif level == 3:
            # h2 が無いまま h3 から始まる文書のために、ダミーで包む
            if not h2_open:
                html_parts.append('<li class="toc-item toc-h2 toc-orphan">')
                h2_open = True
            if not in_sub:
                html_parts.append('<ul class="toc-sublist">')
                in_sub = True
            html_parts.append(
                f'<li class="toc-item toc-h3">'
                f'<a href="#{anchor_e}" data-toc-anchor="{anchor_e}">{text_e}</a>'
                f'</li>'
            )

    close_current_h2()
    html_parts.append('</ul>')
    return mark_safe(''.join(html_parts))


def render_post_markdown(value):
    """記事本文用:Markdown → (body_html, toc_html) のタプルを返す。

    ビューから呼んで body と toc を別々のコンテキスト変数に入れる。
    1リクエストにつき1回だけ変換して、本文と目次の両方で使い回す。
    """
    body = markdownify(value)
    toc = extract_toc(body)
    return body, toc

ビューからは render_post_markdown(post.body) を1回呼ぶだけで、(body_html, toc_html) がまとめて手に入ります(次の Step3 で使います)。2回変換を走らせない、というのがポイントです。


Step3. detailビューと detail.html を Markdown 表示に切り替える

第5章では記事本文を {{ post.body|linebreaks }} で「改行だけ反映」の素のテキストとして出していました。これを Markdown 変換に切り替えます。

ポイントは、本文HTMLと目次を一度に作ってビューから渡すこと。テンプレート側で markdownify を2回呼ぶ(本文用・目次用)と変換も2回走って無駄なので、Step2 で作った render_post_markdown() をビューで1回だけ呼びます。

detailビューを更新する

~/myblog/blog/views.pydetail ビュー(第5章で作成)に、本文・目次の変換を追加します。

blog/views.py
from django.db.models import F
from django.shortcuts import get_object_or_404, render

from .models import Post
from .templatetags.markdown_tags import render_post_markdown


def detail(request, slug):
    """記事詳細ページを表示する。"""
    if request.user.is_authenticated and request.user.is_staff:
        post = get_object_or_404(Post, slug=slug)
    else:
        post = get_object_or_404(Post, slug=slug, is_published=True)

    # 閲覧数を1加算(F()でアトミックに)
    Post.objects.filter(pk=post.pk).update(view_count=F('view_count') + 1)

    # 前後の記事(第5章のロジックそのまま)
    prev_post = None
    next_post = None
    if post.published_at:
        prev_post = (
            Post.objects.filter(is_published=True, published_at__lt=post.published_at)
            .order_by('-published_at').first()
        )
        next_post = (
            Post.objects.filter(is_published=True, published_at__gt=post.published_at)
            .order_by('published_at').first()
        )

    # 関連記事(同カテゴリの最新3件)
    related_posts = []
    if post.category:
        related_posts = (
            Post.objects.filter(is_published=True, category=post.category)
            .exclude(pk=post.pk).order_by('-published_at')[:3]
        )

    # ★ここが今回の追加:本文HTMLと目次HTMLを1回の変換でまとめて取得
    body_html, toc_html = render_post_markdown(post.body)

    context = {
        'post': post,
        'body_html': body_html,
        'toc_html': toc_html,
        'prev_post': prev_post,
        'next_post': next_post,
        'related_posts': related_posts,
    }
    return render(request, 'blog/detail.html', context)

detail.html の本文を差し替える

~/myblog/blog/templates/blog/detail.html の本文部分を、linebreaks から ビューが変換済みの body_html に変えます。

{# 第5章までは {{ post.body|linebreaks }} だった本文を、変換済みHTMLに差し替え #}
<div class="blog-post-body">
    {{ body_html|safe }}
</div>

body_html はビュー側で markdownify(bleachサニタイズ込み)を通した安全なHTMLなので、|safe でそのまま出します。

これで # 見出し```python:views.py がちゃんと整形・ハイライトされて表示されるようになりました。目次(toc_html)はこのあとサイドバーに出します。


Step4. コードハイライト用CSSを生成する

Pygments の monokai テーマから CSS を生成します。

docker compose run --rm web python -c "from pygments.formatters import HtmlFormatter; print(HtmlFormatter(style='monokai').get_style_defs('.codehilite'))" > blog/static/blog/codehilite.css

上のコマンドで生成された codehilite.css末尾に、Qiita風ファイル名ラベルやコードブロックの装飾を追記して仕上げます(生成された配色定義はそのまま残し、その下に足す):

blog/static/blog/codehilite.css
/* ============================================================
   ここから下は手書きで追記(Pygments生成分の下に足す)
   ============================================================ */

/* ファイル名ラベル付きコードブロック全体のラッパー */
.codehilite-wrap {
    margin-bottom: 1rem;
    border-radius: 6px;
    overflow: hidden;
    background: #272822;
}

/* ファイル名ラベル */
.codehilite-filename {
    background: #1a1a1a;
    color: #c0c0c0;
    padding: 6px 14px;
    font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
    font-size: 0.8rem;
    border-bottom: 1px solid #3a3a3a;
}

/* ラッパー内の codehilite はラウンドを取り除く(外側のラッパーが担当) */
.codehilite-wrap .codehilite {
    border-radius: 0;
    margin-bottom: 0;
}

/* コードブロック本体 */
.codehilite {
    background: #272822;
    border-radius: 6px;
    padding: 1rem;
    margin-bottom: 1rem;
    overflow-x: auto;
}

.codehilite pre {
    margin: 0;
    color: #f8f8f2;
    font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
    font-size: 0.875rem;
    line-height: 1.5;
}

/* インラインコード(コードブロック内のものは除く) */
.blog-post-body code:not(.codehilite code) {
    background: #f5f5f5;
    padding: 0.15em 0.4em;
    border-radius: 3px;
    font-size: 0.9em;
    color: #d63384;
}

テーブルの見た目も整える

extra 拡張で Markdown のテーブル(| 列 | 列 | 記法)も HTML の <table> に変換されますが、素の <table> には枠線がないので、そのままだとただの文字の羅列に見えます。境界線とヘッダ背景を付けます。

✏️ 更新(追加)~/myblog/blog/static/blog/blog.css の末尾に追記:

blog/static/blog/blog.css
/* テーブル:境界線とヘッダ背景 */
.blog-post-body table {
    width: 100%;
    border-collapse: collapse;
    margin: 1.2rem 0;
}

.blog-post-body table th,
.blog-post-body table td {
    padding: 0.5rem 0.75rem;
    border: 1px solid #374151;
}

.blog-post-body table th {
    background-color: #1f2937;
    color: #f3f4f6;
}

base.html<head> に読み込みを追加:

<link href="{% static 'blog/codehilite.css' %}" rel="stylesheet">

最後に collectstatic:

docker compose run --rm web python manage.py collectstatic --noinput

必ず collectstatic を打つ:Nginx は staticfiles/ を見ているので、blog/static/blog/ に置いただけでは反映されません。CSSを変更したら collectstatic、を毎回必ず実行します。

これで Markdown 表示の最低限が完成しました。ここで一度 commit しておくと良い


Step5. 目次(TOC)をサイドバーに表示する

render_post_markdowntoc_html を返してくれているので、それをサイドバーに出します。第5章の base.html は検索ボックスだけのシンプルな1カラムだったので、ここで2カラム化してサイドバーを作ります。

サイドバー用の共通コンテキストを作る

サイドバーには「よく読まれている記事」「カテゴリ一覧」も出します。これらは全ページ共通で必要なので、views.py に小さなヘルパーを作って使い回します。

blog/views.py
from .models import Category, Post


def _common_context():
    """全ページ共通のコンテキスト(サイドバー用)を返す。"""
    return {
        # よく読まれている記事(閲覧数の多い順で3件)
        'popular_posts': (
            Post.objects.filter(is_published=True).order_by('-view_count')[:3]
        ),
        # カテゴリ一覧
        'categories': Category.objects.all(),
    }

各ビューの context**_common_context() を展開して渡します(index / detail / category / search すべて)。例えば detail ビューなら:

blog/views.py
context = {
        'post': post,
        'body_html': body_html,
        'toc_html': toc_html,
        'prev_post': prev_post,
        'next_post': next_post,
        'related_posts': related_posts,
        **_common_context(),   # ← サイドバー用(人気記事・カテゴリ)を追加
    }
    return render(request, 'blog/detail.html', context)

base.html を2カラム+サイドバーにする

~/myblog/blog/templates/blog/base.html<main> を、本文(左)とサイドバー(右)の2カラムに置き換えます。サイドバーの一番上には、各ページが差し込める sidebar_extra ブロックを用意しておきます(記事ページではここに目次が入る)。

<main class="container">
    <div class="row g-5">

        {# メインコンテンツ:各ページが上書きする #}
        <div class="col-md-8">
            {% block content %}{% endblock %}
        </div>

        {# 共通サイドバー #}
        <div class="col-md-4">
            <div class="position-sticky" style="top: 2rem;">

                {# ページ固有のサイドバー要素(記事ページなら目次が入る) #}
                {% block sidebar_extra %}{% endblock %}

                {# About #}
                <div class="p-4 mb-3 bg-body-tertiary rounded">
                    <h4 class="fst-italic">About</h4>
                    <p class="mb-0">AIと一緒にDjangoでブログを作る過程を記事にしています。</p>
                </div>

                {# よく読まれている記事 #}
                <div>
                    <h4 class="fst-italic">よく読まれている記事</h4>
                    <ul class="list-unstyled">
                        {% for post in popular_posts %}
                        <li>
                            <a class="d-flex flex-column py-3 link-body-emphasis text-decoration-none border-top"
                               href="{% url 'blog:detail' post.slug %}">
                                <h6 class="mb-0">{{ post.title }}</h6>
                                <small class="text-body-secondary">{{ post.view_count }} views</small>
                            </a>
                        </li>
                        {% endfor %}
                    </ul>
                </div>

                {# カテゴリ一覧 #}
                <div class="p-4">
                    <h4 class="fst-italic">カテゴリ</h4>
                    <ol class="list-unstyled mb-0">
                        {% for category in categories %}
                        <li><a href="{% url 'blog:category' category.slug %}">{{ category.name }}</a></li>
                        {% endfor %}
                    </ol>
                </div>

            </div>
        </div>

    </div>
</main>

detail.html で目次をサイドバーに差し込む

detail.html の末尾({% block content %} の外)で、sidebar_extra ブロックを上書きして目次を出します。

{# 共通サイドバーの一番上に目次を差し込む #}
{% block sidebar_extra %}
    {% if toc_html %}
    <div class="post-toc mb-4" aria-label="目次">
        <h4 class="fst-italic">目次</h4>
        <nav class="post-toc-nav">
            {{ toc_html|safe }}
        </nav>
    </div>
    {% endif %}
{% endblock %}

toc_html が空(見出しが無い記事)のときは何も出ません。

目次のCSSを足す

~/myblog/blog/static/blog/blog.css に目次の見た目を追加:

blog/static/blog/blog.css
.post-toc {
    font-size: 0.875rem;
}
.post-toc-nav ul {
    list-style: none;
    padding-left: 0;
    margin: 0;
    border-left: 2px solid #e5e7eb;
}
.post-toc-nav .toc-sublist {
    padding-left: 0.5rem;
    margin: 0.25rem 0;
    border-left: 2px solid #f3f4f6;
}
.post-toc-nav li { margin: 0; line-height: 1.4; }
.post-toc-nav a {
    color: #6b7280;
    text-decoration: none;
    display: block;
    padding: 0.3rem 0 0.3rem 0.75rem;
    border-left: 2px solid transparent;
    margin-left: -2px;
    transition: color 0.15s, border-color 0.15s, background-color 0.15s;
    word-break: break-word;
}
.post-toc-nav a:hover { color: #1f2937; background-color: #f9fafb; }
.post-toc-nav a.toc-active {
    color: #2563eb;
    border-left-color: #2563eb;
    font-weight: 600;
}
.post-toc-nav .toc-h3 a { font-size: 0.825rem; color: #9ca3af; }

目次のアクティブ表示(スクロール追従)JS

読んでいる位置の見出しを目次でハイライトする小さなJSを作ります。IntersectionObserver で本文の見出しを監視するだけ。

touch ~/myblog/blog/static/blog/toc.js
blog/static/blog/toc.js
/**
 * 記事詳細ページの目次(TOC)アクティブハイライト
 *
 * IntersectionObserver で本文中の h2/h3 を監視し、
 * 現在ビューポートに入っている見出しに対応する目次リンクを強調表示する。
 */
(function () {
    'use strict';

    function init() {
        const tocLinks = document.querySelectorAll('.post-toc-nav a[data-toc-anchor]');
        if (tocLinks.length === 0) return;

        // anchor -> link 要素の対応表
        const linkMap = {};
        tocLinks.forEach(function (a) {
            linkMap[a.dataset.tocAnchor] = a;
        });

        // 本文中の対象見出しを集める
        const headings = Object.keys(linkMap)
            .map(function (id) { return document.getElementById(id); })
            .filter(function (el) { return el !== null; });
        if (headings.length === 0) return;

        const visibleIds = new Set();

        function updateActive() {
            let activeId = null;
            for (let i = 0; i < headings.length; i++) {
                if (visibleIds.has(headings[i].id)) {
                    activeId = headings[i].id;
                    break;
                }
            }
            tocLinks.forEach(function (a) { a.classList.remove('toc-active'); });
            if (activeId && linkMap[activeId]) {
                linkMap[activeId].classList.add('toc-active');
            }
        }

        const observer = new IntersectionObserver(function (entries) {
            entries.forEach(function (entry) {
                if (entry.isIntersecting) {
                    visibleIds.add(entry.target.id);
                } else {
                    visibleIds.delete(entry.target.id);
                }
            });
            updateActive();
        }, {
            // 画面上部 80px ~ 下部 60% を「読んでる範囲」とみなす
            rootMargin: '-80px 0px -60% 0px',
            threshold: 0,
        });

        headings.forEach(function (h) { observer.observe(h); });
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();

base.html</body> 直前で読み込みます(他ページでは目次が無いので即終了する=常時読み込みOK):

<script src="{% static 'blog/toc.js' %}" defer></script>

CSS・JSを追加したので collectstatic を忘れずに:

docker compose run --rm web python manage.py collectstatic --noinput

Step6. 起動確認

cd ~/myblog
docker compose up

http://localhost/posts/<記事のslug>/ を開いて、次を確認します:

確認項目 期待する表示
# 見出し **太字** 整形されて表示される
```python:views.py ファイル名ラベル付き+色付きコード
サイドバー About・よく読まれている記事・カテゴリが出る
目次 見出しから自動生成され、スクロールでハイライトが追従する
ココココ

本文がMarkdownで綺麗に出て、目次まで付いたね!これで「読む側」の体験はバッチリ。でも管理画面の入力はまだ素のtextarea…次の #6.2 で「書く側」を一気に良くするよ!


まとめ

この章で、素のテキストだった記事本文が Markdownで綺麗に表示 されるようになりました。

DBには素のMarkdownを保存
  ↓ 表示のたびに
render_post_markdown で (本文HTML, 目次HTML) に変換
  ↓
bleachでサニタイズ → detail.html / サイドバーに表示

やったこと:

  • Python-Markdown + Qiita風ファイル名付きコードブロック + Pygmentsハイライト
  • bleach allowlist でサニタイズ(XSS対策)
  • 見出しから目次を自動生成してサイドバーに表示(スクロール追従)

次章 #6.2 では、この「綺麗に表示される本文」を書くためのエディタ(Toast UI Editor + 本物そっくりのライブプレビュー + 画像アップロード)を作ります。

関連記事

Django記事詳細・カテゴリ・キーワード検索ページの実装【AIと一緒に作るDjangoブログ入門 #5】
Django
Django記事詳細・カテゴリ・キーワード検索ページの実装【AIと一緒に作るDjangoブログ入門 #5】

slug方式の記事詳細ページ、カテゴリ別の一覧、キーワード検索を実装。base.htmlでの共通レイアウト化、F()式…

Djangoのモデル設計とfixtureでブログのデータベースを作る【AIと一緒に作るDjangoブログ入門 #4】
Django
Djangoのモデル設計とfixtureでブログのデータベースを作る【AIと一緒に作るDjangoブログ入門 #4】

Post・Category・DisplaySlotの3つのモデルを設計し、管理画面で記事を管理できるようにする章。fi…

テンプレート継承でDjangoブログのトップページを作る【AIと一緒に作るDjangoブログ入門 #3】
Django
テンプレート継承でDjangoブログのトップページを作る【AIと一緒に作るDjangoブログ入門 #3】

views.pyでダミーデータを用意し、Djangoのテンプレート継承(base.html)でブログのトップページを作…