スケッチ: モバイルファーストなブログ(アプリケーション例) #

目的 #

**スマートフォンで快適に読める SSR のブログ**を、アプリケーションの例として 1 つ作る。 記事は、ごく簡単な Markdown(見出しによるセクション、箇条書き、段落、コードブロックだけ)で書く。

制約:

  • 依存は標準ライブラリのみ。
  • CSS の変換・ビルド工程を入れない(Tailwind、Sass、PostCSS などは使わない)。
  • go:embed は favicon と記事(Markdown ファイル)を埋め込むことにだけ使う。バイナリ 1 つで動くようにする。
  • レイアウト(HTML の骨格と CSS)は、Go の関数で Node を組み立ててレンダリングする。テンプレートファイルは使わない。
  • JavaScript は使わない。

アプリケーションとライブラリの分担 #

置き場所責務
ライブラリ: HTMLpkg/el設計方針の内容。Node、Renderer、要素名・属性名の const
ライブラリ: Markdownpkg/tinymarkdownごく簡単な Markdown を構文木(純粋なデータ)に変換する。pkg/el には依存しない
アプリケーションexamples/blog/main.go記事の読み込み、ルーティング、レイアウト、構文木から Node への変換、CSS、favicon の配信
  • 依存の向きは examples/blog → pkg/el と examples/blog → pkg/tinymarkdown だけにする。ライブラリ同士は依存させない。
  • 構文木を Node に変換する処理は、HTML の出し方(<section> を使うか、見出しに id を付けるか など)を決める部分なので、アプリケーション側に置く。数十行で済む見込み。
  • pkg/el に足りない要素名・属性名が見つかったら、ライブラリへの追加として扱う。今だけ必要なものは el.AttrName("...") でキャストして使える。

pkg/tinymarkdown: ごく簡単な Markdown パーサー #

対応する書き方 #

書き方意味
# 見出し 〜 ##### 見出しセクションの開始。# の数がセクションの深さ
`コード`インラインコード。構文木では文字列のまま持ち、Inlines で文字列とコードに分ける。バッククォートの数を揃えて閉じる(CommonMark と同じ)
[テキスト](URL)リンク。Inlines で Link に分ける。テキストにはインラインコードを書けるが、[ ] やリンクは入れられない。URL には空白・括弧を含められず、タイトルも書けない。インラインコードの方が強く結びつく
> 引用引用。連続する > の行が 1 つの引用になり、> の後ろは改めて Markdown として読む
- 項目箇条書きの 1 項目。連続する行が 1 つのリストになる。直後のインデントされた行は、その項目の続きになる。- を上の項目より深くインデントすると、その項目の子のリストになる(スペースとタブは 1 文字ずつ数える)
| 見出し | 見出し |表。| で始まる行が連続したものを 1 つの表として読む。2 行目は | --- | --- | のような区切り行で、列の数がヘッダと同じでなければならない(違えば段落になる)。セルの中のインライン記法はそのまま文字列として持ち、\| はセルの区切りではなく | として読む。列の揃え(:---:)は書いてもよいが無視する
``` で始まる行から ``` だけの行までコードブロック。中身は一切解釈せず、そのまま保持する
それ以外の空でない行段落。連続する行は 1 つの段落にまとめる
空行段落やリストの区切り

コードブロックの扱い:

  • 開始行は ``` で始まる行。``` の後ろの文字列(例: ```go の go)は読み飛ばす。シンタックスハイライトはしないので言語名は使わない。
  • 終了行は ``` だけの行(前後の空白は無視する)。
  • バッククォートは 3 つ固定。```` のように 4 つ以上で囲む書き方や、~~~ には対応しない。
  • 中身の行は、# や - で始まっていてもセクションや箇条書きにしない。空行も中身として保持する。
  • 開始行がインデントされていたら、その深さまで中身の行頭の空白を取り除く。深さに足りない行は、持っている分だけ取り除く(CommonMark と同じ)。
  • 終了行がないまま文書が終わったら、そこまでをコードブロックとする。

対応しないもの(明示的に範囲外とする):

  • インライン記法のうち強調、画像、自動リンク(<https://...>)、参照形式のリンク。テキストはそのまま文字列として扱う(インラインコードとリンクだけは Inlines で分けられる)。
  • 番号付きリスト、画像、HTML の直書き。###### の見出し。表の列の揃えと、先頭に | のない表。
  • 4 つ以上のバッククォートや ~~~ によるコードブロック、インデントによるコードブロック。

構文木の案 #

設計方針の「Node は純粋なデータ」にならい、構文木も描画ロジックを持たない値にする。

package tinymarkdown

// Document is the root of a parsed document.
type Document struct {
	Children []Block
}

// Block is a marker interface for block-level nodes.
type Block interface{ block() }

// Section is a heading together with everything up to the next heading
// of the same or a shallower level.
type Section struct {
	Level    int    // 1..5
	Title    string
	Children []Block
}

type Paragraph struct{ Text string }

type List struct{ Items []Item }

// Item is the text after "- " and the list nested under it.
type Item struct {
	Text     string
	Children []Item
}

type CodeBlock struct {
	Code string // lines between the fences, joined with "\n"
}

func Parse(src string) Document
  • 見出しを単なる 1 行として扱わず、Section として子要素を持つ入れ子の木にする。これで <section> の入れ子にそのまま対応させられる。
  • 最初の見出しより前にある段落やリストは、Document.Children に直接入れる。
  • 構文エラーは起こらない(解釈できない行はすべて段落として扱う)ので、Parse は error を返さない。
  • テストは入力と期待する構文木の組を並べ、reflect.DeepEqual で比較する。

examples/blog: アプリケーション #

画面とルーティング #

Go 1.22 以降の http.ServeMux のパターンを使う。

ルート内容
GET /{$}記事一覧(新しい順)。タイトルと日付
GET /posts/{slug}記事本文
GET /favicon.svggo:embed で埋め込んだ favicon
/(上のどれにも当たらないもの)404 のエラーページ

記事の置き場所 #

記事は examples/blog/posts/ に置き、go:embed でバイナリに埋め込む。

//go:embed posts/*.md
var postsFS embed.FS
  • 記事を読む関数は fs.FS を受け取る形にする(loadPosts(fsys fs.FS) ([]Post, error))。本番では fs.Sub(postsFS, "posts") を渡し、テストでは fstest.MapFS を渡せる。
  • ファイル名は 2026-10-14-hello.md の形にし、日付とスラッグをファイル名から取る。
  • 記事の先頭に pkg/tinyfrontmatter の front matter(--- で囲んだ key: value の行)を書ける。使えるキーは title、description、prev、next だけで、ほかのキーは打ち間違いとして起動時のエラーにする。ドキュメント(TODO.md、docs/**/*.md)にも同じキーを書ける。
  • タイトルは front matter の title、なければ最初の # 見出しから取る。本文に # 見出しがなければ、記事ページで <h1> を補う。
  • 説明文は front matter の description、なければ最初の段落から取る。<meta name="description"> と OGP に使う。
  • prev と next は前後の記事へのリンクで、記事ページの </article> の下に ← Previous(左)と Next →(右)のナビゲーションとして描く。値は [text](url) のリンクか URL だけの 1 つを書く。URL は本文のリンクと同じ規則で解決するが、相対 URL はこのサイトが配信する記事・ドキュメントに指すときだけリンクにし、GitHub 上のファイルへのリンクにはしない。URL がこのサイトのページを指すときタイトルはページのタイトルになり、そうでなければ書いた値がそのままテキストになる。両方未指定ならナビゲーション自体を出さない。
  • ドキュメント(/docs/)の一覧は TODO.md、docs/ の順、その中はファイルパスの順に並ぶ。docs/blog.metadata.json があれば、docs.head に書いた文書を先頭に、docs.tail に書いた文書を末尾に、それぞれ書いた順で置き、残りは今の順で間に並べる。値はリポジトリのルートからのファイルパス(バンドルは docs/x.md)の配列で、どちらも省略できる。JSON として読めない、未知のキー、文字列の配列でない、文書でないパス、同じパスが 2 回(head と tail をまたいでも)は起動時のエラーにする。並べ替えるのは文書の一覧だけで、記事の順、URL、リンクは変わらない。このファイルはブログ全体のメタデータを機能ごとのキーで引き受ける(今は docs だけ)。
  • 記事は起動時に一度だけ全部読む。記事を書き換えたら再ビルドが必要になる(ブログの例としてはこれで十分)。

レイアウトは Go の関数 #

func layout(title string, body ...el.Node) el.Node // <!doctype>、<head>、<header>、<main>、<footer>
func postList(posts []Post) el.Node
func postPage(p Post) el.Node
func renderBlocks(bs []tinymarkdown.Block) []el.Node    // 構文木を Node に変換する
  • <head> には次を入れる。
    • <meta name="viewport" content="width=device-width, initial-scale=1">
    • <meta name="color-scheme" content="light dark">
    • <link rel="icon" href="/favicon.svg" type="image/svg+xml">
  • CSS は Go の文字列定数として持ち、layout の中で <style> に Raw(css) で埋め込む。設計方針の「Raw() は静的な CSS など信頼できる文字列のみ」の実例になる。
  • 記事本文はすべて Text() を通すので、Markdown に HTML が書かれていてもエスケープされる。コードブロックの中身も同じ。
  • コードブロックは <pre> にするだけ。シンタックスハイライトはしない。
  • 描画は render(w, status, node) の 1 箇所にまとめる(設計方針の 6)。エラーページもこれを通す(下記)。

エラーメッセージの表示 #

エラーも素のテキストではなく、ほかの画面と同じ layout で HTML のページとして返す。

func errorPage(status int, message string) el.Node
func renderError(w http.ResponseWriter, r *http.Request, status int, message string, err error)
状況ステータス利用者に見せるメッセージの例
存在しない記事(/posts/{slug} に該当なし)404「記事が見つかりませんでした」
どのルートにも当たらない URL404「ページが見つかりませんでした」
描画中のエラー、ハンドラ内の panic500「表示中にエラーが起きました」
  • エラーページには、ステータスコード、短いメッセージ、記事一覧(/)へ戻るリンクを出す。
  • 利用者に見せるのは決まった文言だけにする。err の中身(ファイルパスなど内部の情報)は画面に出さず、log/slog でサーバーのログにだけ書く。
  • 404 の判定は、/posts/{slug} のハンドラで記事が見つからなかった場合と、/ に登録した最後の受け皿のハンドラの 2 か所で行う。
  • panic は、ServeMux 全体を包む小さなミドルウェアで recover して 500 ページにする。
  • **描画は一度 bytes.Buffer に書いてから送る。** ResponseWriter に直接ストリーム出力すると、途中でエラーが起きた時点でステータス 200 が送信済みになり、500 ページに切り替えられないため。記事は小さいので、バッファしても問題にならない。あわせて Content-Length も付けられる(設計方針「拡張の方向性」の最適化)。
  • メソッドが合わない場合(例: POST /)も、メソッドを指定しない受け皿の / に当たるので 404 のエラーページになる。405 を返し分けることはしない(読むだけのブログでは、ブラウザから普通に操作して起きることはないため)。
  • 起動時の記事読み込みのエラー(ファイル名が YYYY-MM-DD-slug.md の形でない、スラッグが重複している など)は、画面ではなくログに出してプロセスを終了する。

OGP #

  • 一覧と記事ページの <head> に og:title、og:type(一覧は website、記事は article)、og:url、og:description、og:site_name を出す。エラーページには出さない。
  • og:url は絶対 URL が必要なので、-base-url https://example.com で公開時のオリジンを渡す(省略時は http://<addr>)。サイト内のリンクはルート相対なので、パス付きのベース URL は受け付けない。
  • og:image は画像の用意が必要になるので、今は扱わない。

モバイルファーストの CSS(変換なし) #

ブログは読むのが主なので、読みやすさを中心に少ない行数で済ませる。

  • 狭い画面を基準に書き、@media (min-width: 40rem) で広い画面向けに余白を足す。
  • 本文の幅は max-inline-size: 40rem 程度、行間は 1.7 前後にする。
  • clamp() で見出しの文字サイズを画面幅に合わせて変える。
  • prefers-color-scheme でダークモードに対応する。
  • 一覧のリンクはタップしやすいよう、行全体をリンクにして高さを 44px 以上にする。
  • コードブロックは折り返さず、overflow-x: auto で横スクロールさせる(狭い画面で本文の幅を崩さないため)。
  • カスタムプロパティで色と余白をまとめる。全体で 100 行以内を目安にする。

favicon #

//go:embed favicon.svg
var favicon []byte
  • SVG 1 つだけにする(.ico は作らない)。
  • Content-Type: image/svg+xml と、適度な Cache-Control(例: max-age=86400)を付けて返す。
  • embed で持つファイルは更新時刻がゼロ値なので、http.ServeContent に頼らず、直接 w.Write で返す。

テスト #

  • pkg/tinymarkdown: 入力と構文木の組を並べたテーブルテスト。
  • examples/blog: fstest.MapFS に記事を入れてハンドラを作り、httptest で一覧・記事・favicon を確認する。エラーは、存在しない記事と存在しない URL が 404 のエラーページになること、panic するハンドラが 500 のエラーページになり、本文に内部の情報が出ないことを確認する。本文の確認には PlainRenderer を使う。

ディレクトリ構成の案 #

examples/blog/
  main.go           // 起動、フラグ、ServeMux の組み立て
  favicon.svg
  main_test.go
  posts/            // 記事。go:embed で埋め込む(例: 2026-10-14-hello.md)
pkg/el/             // HTML ライブラリ(設計方針の内容)
pkg/tinymarkdown/
  tinymarkdown.go
  tinymarkdown_test.go

最初は main.go 1 ファイルに収め、長くなったら layout.go などに分ける。

進める順序 #

1. pkg/el(設計方針の内容。ブログで使う範囲から) 2. pkg/tinymarkdown(セクション、箇条書き、段落、コードブロック)とテスト 3. examples/blog: 記事一覧と記事ページ。CSS は最低限 4. examples/blog: エラーページ(404 と 500) 5. examples/blog: favicon、モバイル向けの CSS の仕上げ、テスト