はじめに
前章(第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
""" 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 フィルタも同じファイルに足します。markdown の toc 拡張が見出しに振ってくれた id を、正規表現で拾うだけです。
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.py の detail ビュー(第5章で作成)に、本文・目次の変換を追加します。
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風ファイル名ラベルやコードブロックの装飾を追記して仕上げます(生成された配色定義はそのまま残し、その下に足す):
/* ============================================================ ここから下は手書きで追記(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-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_markdown が toc_html を返してくれているので、それをサイドバーに出します。第5章の base.html は検索ボックスだけのシンプルな1カラムだったので、ここで2カラム化してサイドバーを作ります。
サイドバー用の共通コンテキストを作る
サイドバーには「よく読まれている記事」「カテゴリ一覧」も出します。これらは全ページ共通で必要なので、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 ビューなら:
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 に目次の見た目を追加:
.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
/** * 記事詳細ページの目次(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 + 本物そっくりのライブプレビュー + 画像アップロード)を作ります。
