2
0

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ブログをハイブリッドレンダリングで実装した話

2
Last updated at Posted at 2026-03-28

管理画面はmarked.js、公開画面はleague/commonmark — MarkdownのハイブリッドレンダリングをLaravelで実装した話🔀

はじめに

バイク中古・新車一括検索プラットフォーム「MotoHub」を個人開発しています。

前回の記事でブログ機能の設計全体像を書きました。今回はその中でも一番面白かった MarkdownのハイブリッドレンダリングMarkdown の実装詳細を書きます。

管理画面(執筆時)はクライアントサイドでリアルタイムプレビュー、公開画面(読者向け)はサーバーサイドでSEO最適化HTML出力。 DBにはMarkdown生テキストを保存して、レンダリングは用途に応じて使い分ける設計です。


なぜハイブリッドなのか

Markdownのレンダリング方法は大きく2つあります。

方式 メリット デメリット
クライアントサイド(JS) リアルタイムプレビュー可能 SEOクローラーに見えない
サーバーサイド(PHP) SEO最適、初期表示高速 プレビューにHTTPリクエスト必要

MotoHubブログでは:

  • 管理画面 → リアルタイムプレビューが欲しい → marked.js(クライアント)
  • 公開画面 → SEOが最重要 → league/commonmark(サーバー)

両方の良いとこ取りです。


技術スタック

管理画面(クライアントサイド):
├── marked.js v12(CDN)
├── リアルタイムプレビュー(150msデバウンス)
└── highlight.js(コードハイライト)

公開画面(サーバーサイド):
├── league/commonmark
├── GithubFlavoredMarkdownExtension(テーブル、打消し線)
├── AutolinkExtension(URL自動リンク)
└── HeadingPermalinkExtension(見出しにid付与 → 目次用)

共通:
└── DB保存: Markdown生テキスト(longText)

管理画面:marked.js でリアルタイムプレビュー

スプリットペインエディタ

左にMarkdown入力、右にリアルタイムプレビュー。Zennのエディタに近い構成です。

📸【ここにスクショ】スプリットペインエディタの画面

<!-- 左ペイン: Markdown入力 -->
<textarea id="markdown-editor" class="editor-textarea"
    placeholder="Markdownで記事を書く..."></textarea>

<!-- 右ペイン: プレビュー -->
<div id="preview-content" class="preview-content prose"></div>

プレビューの更新ロジック

入力のたびにプレビューを更新しますが、毎キーストロークで更新するとパフォーマンスが悪いので 150msのデバウンス を入れています。

marked.setOptions({
    gfm: true,
    breaks: true,
});

let previewTimer = null;

editor.addEventListener('input', function() {
    clearTimeout(previewTimer);
    previewTimer = setTimeout(() => {
        preview.innerHTML = marked.parse(editor.value);
        updateStats(editor.value);
    }, 150);
});

文字数・読了時間のリアルタイム表示

ステータスバーに文字数と推定読了時間を表示しています。日本語は約500文字/分で計算。

function updateStats(md) {
    const chars = md.replace(/\s/g, '').length;
    const minutes = Math.max(1, Math.ceil(chars / 500));
    wordCount.textContent = chars.toLocaleString() + ' 文字';
    readingTime.textContent = '' + minutes + ' 分で読了';
}

image.png


管理画面:画像アップロード(3パターン対応)

エディタには3つの方法で画像を挿入できます。

① ドラッグ&ドロップ

テキストエリアに画像ファイルをD&Dすると、自動でアップロードされて ![filename](url) がMarkdownに挿入されます。

editor.addEventListener('drop', function(e) {
    e.preventDefault();
    const files = Array.from(e.dataTransfer.files)
        .filter(f => f.type.startsWith('image/'));
    files.forEach(uploadImage);
});

② クリップボードからペースト

スクリーンショットをCtrl+Vで直接ペースト。ブログ記事にスクショを貼りたい時に便利です。

editor.addEventListener('paste', function(e) {
    const items = e.clipboardData.items;
    for (const item of items) {
        if (item.type.startsWith('image/')) {
            e.preventDefault();
            uploadImage(item.getAsFile());
            break;
        }
    }
});

③ ツールバーのファイル選択ボタン

ツールバーの画像アイコンをクリックでファイル選択ダイアログが開きます。ツールバーは position: sticky でスクロールに追従するので、長い記事を書いている時も画面上部に常にあります。

画像はカーソル位置に挿入

最初の実装では画像が常に末尾に追加されてしまい使いにくかったので、カーソル位置に挿入するよう修正しました。

let lastCursorPos = 0;

// blur時にカーソル位置を保存
editor.addEventListener('blur', function() {
    lastCursorPos = this.selectionStart;
});

async function uploadImage(file) {
    // ... アップロード処理 ...
    
    const imageMarkdown = `![${file.name}](${data.full_url})`;
    const pos = editor.selectionStart || lastCursorPos;
    
    editor.focus();
    editor.setRangeText(imageMarkdown, pos, pos, 'end');
    updatePreview();
}

画像の保存先:Storage::disk 抽象化

// app/Http/Controllers/Admin/BlogImageController.php
$path = $file->storeAs(
    "{$directory}/{$subDir}",  // blog/images/2026/03/
    $filename,                  // ランダム20文字.webp
    config('blog.image_disk')   // 'public' or 'r2'
);

DBには相対パス(blog/images/2026/03/xxxx.webp)のみ保存。将来のCloudflare R2移行時にDB変更が不要な設計です。


公開画面:league/commonmark でサーバーサイドレンダリング

MarkdownService

Markdownの変換と目次生成を担当するサービスクラスです。

// app/Services/MarkdownService.php

class MarkdownService
{
    private MarkdownConverter $converter;

    public function __construct()
    {
        $config = [
            'heading_permalink' => [
                'apply_id_to_heading' => true,
                'insert' => 'after',
                'symbol' => '#',
                'min_heading_level' => 2,
                'max_heading_level' => 3,
            ],
        ];

        $environment = new Environment($config);
        $environment->addExtension(new CommonMarkCoreExtension());
        $environment->addExtension(new GithubFlavoredMarkdownExtension());
        $environment->addExtension(new AutolinkExtension());
        $environment->addExtension(new HeadingPermalinkExtension());

        $this->converter = new MarkdownConverter($environment);
    }

    public function toHtml(string $markdown): string
    {
        return (string) $this->converter->convert($markdown);
    }
}

HeadingPermalinkExtension が重要

この拡張機能は見出しに自動でid属性を付与してくれます。

## バッテリーの確認と充電

↓ 変換後

<h2 id="バッテリーの確認と充電">
    バッテリーの確認と充電
    <a href="#バッテリーの確認と充電" class="heading-permalink">#</a>
</h2>

これにより:

  • 目次からのページ内リンクが機能する
  • 特定の見出しへの直リンク共有ができる
  • Googleの強調スニペットに見出し単位で拾われやすくなる

目次(TOC)の自動生成

目次はJavaScriptで生成するブログが多いですが、MotoHubではサーバーサイドで生成しています。理由はSEOクローラーに目次を認識させるためです。

AST解析で見出しを抽出

public function extractToc(string $markdown): array
{
    $parser = new MarkdownParser($this->environment);
    $document = $parser->parse($markdown);

    $toc = [];
    $query = new Query();
    $query->where(Query::type(Heading::class));

    foreach ($query->findAll($document) as $heading) {
        $level = $heading->getLevel();
        if ($level < 2 || $level > 3) continue;

        $text = '';
        foreach ($heading->children() as $child) {
            if (method_exists($child, 'getLiteral')) {
                $text .= $child->getLiteral();
            }
        }

        $toc[] = [
            'level' => $level,
            'text'  => trim($text),
            'id'    => Str::slug($text),
        ];
    }

    return $toc;
}

目次の表示

デスクトップでは記事の左サイドバーに position: sticky で固定表示。モバイルでは記事冒頭に折りたたみ表示。

image.png

.article-toc-sidebar {
    width: 200px;
    position: sticky;
    top: 80px;
    max-height: calc(100vh - 100px);
    overflow-y: auto;
}

/* モバイルでは非表示にして、記事内にインライン表示 */
@media (max-width: 900px) {
    .article-toc-sidebar { display: none; }
    .article-toc-inline { display: block; }
}

marked.js と league/commonmark のレンダリング差異

同じMarkdownでも、marked.jsとleague/commonmarkで微妙にレンダリングが異なるケースがあります。

記法 marked.js league/commonmark
テーブル GFM対応 GithubFlavoredMarkdownExtensionで対応
改行 breaks: true で対応 デフォルトで対応
URL自動リンク デフォルト対応 AutolinkExtension必要
見出しid 自動付与なし HeadingPermalinkExtensionで付与

管理画面のプレビューと公開画面の表示が「ほぼ同じ」であれば実用上問題ないので、完全一致は求めていません。致命的な差が出るテーブル記法とURL自動リンクは両方で対応しています。


BlogPostモデルの自動処理

読了時間の自動算出

protected static function computeAutoFields(BlogPost $post): void
{
    if ($post->isDirty('body')) {
        $plainText = strip_tags($post->body);
        $post->reading_time_minutes = max(1,
            (int) ceil(mb_strlen($plainText) / 500)
        );
    }
}

日本語は約500文字/分。isDirty('body') で本文が変更された時だけ再計算します。

excerptの自動生成

if (empty($post->excerpt) && !empty($post->body)) {
    $plainText = strip_tags($post->body);
    $cleaned = preg_replace('/[#*`\[\]()>~\-|]/', '', $plainText);
    $cleaned = preg_replace('/\s+/', ' ', trim($cleaned));
    $post->excerpt = Str::limit($cleaned, 150);
}

手動入力がなければ本文先頭150文字から自動生成。Markdownの記号(#, *, `)を除去してプレーンテキスト化しています。


関連記事のタグスコアリング

記事詳細ページの下部に表示する関連記事は、タグの共通数でスコアリングしています。

// app/Services/BlogRelatedPostService.php
$related = BlogPost::select('blog_posts.*')
    ->selectRaw('COUNT(blog_post_tag.blog_tag_id) as relevance_score')
    ->join('blog_post_tag', 'blog_posts.id', '=', 'blog_post_tag.blog_post_id')
    ->whereIn('blog_post_tag.blog_tag_id', $tagIds)
    ->where('blog_posts.id', '!=', $post->id)
    ->where('blog_posts.status', 'published')
    ->groupBy('blog_posts.id')
    ->orderByDesc('relevance_score')
    ->limit(3)
    ->get();

ポイント:

  • 同シリーズの記事は除外(シリーズナビで「前/次」として表示するため)
  • タグが0件 or マッチ0件の場合は最新記事3件をフォールバック

まとめ

コンポーネント 技術 理由
管理画面プレビュー marked.js リアルタイム、UX重視
公開画面レンダリング league/commonmark SEO、サーバーサイド完結
DB保存形式 Markdown生テキスト 柔軟性、全文検索性
目次 commonmark AST解析 JS不要、SEO対応
画像アップロード D&D / ペースト / 選択 3パターン対応
画像保存 Storage::disk 抽象化 R2移行対応
読了時間 自動算出(500文字/分) 保存時にObserverで計算
関連記事 タグスコアリング 共通タグ数でランキング

次回は、このブログをCloudflare環境にデプロイした時に踏んだ地雷をまとめます。


前回の記事:個人開発のバイクサイトにブログ機能を52ファイルでフルスクラッチした設計の全貌

MotoHub: https://motohub.jp
MotoHubブログ: https://motohub.jp/blog

2
0
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
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?