diff --git a/pygitweb/src/pygitweb/actions.py b/pygitweb/src/pygitweb/actions.py
index ed77ea1..c07a40c 100644
--- a/pygitweb/src/pygitweb/actions.py
+++ b/pygitweb/src/pygitweb/actions.py
@@ -666,12 +666,28 @@ def _project_has_boards(project: str) -> bool:
 		return False
 
 
+BLOG_MANIFEST_PATH: str = "articles/manifest.json"
+
+
+def project_has_blog(project: str) -> bool:
+	try:
+		return get_blob_at_ref_path(project, "HEAD", BLOG_MANIFEST_PATH) is not None
+	except Exception:
+		return False
+
+
 class BoardLink(TypedDict):
 	href: str
 	text: str
 	style: str | None
 
 
+class BlogLink(TypedDict):
+	href: str
+	text: str
+	style: str | None
+
+
 def board_link_spec(project: str) -> BoardLink:
 	if _project_has_boards(project):
 		return {
@@ -687,6 +703,20 @@ def board_link_spec(project: str) -> BoardLink:
 	}
 
 
+def blog_link_spec(project: str) -> BlogLink:
+	if project_has_blog(project):
+		return {
+			"href": f"/project/{quote(project, safe='/')}/blog/",
+			"text": "view blog",
+			"style": None,
+		}
+	return {
+		"href": "#",
+		"text": "no blog",
+		"style": "color: #999; cursor: not-allowed;",
+	}
+
+
 def create_board_link(project: str) -> Cell:
 	href = "/board/create?project=" + quote(project, safe="")
 	if settings.AUTH:
@@ -720,6 +750,7 @@ def git_summary(project: str, extra_rows: list[list[Cell]] | None = None) -> HTM
 		project_url=f"/project/{project}",
 		project_url_enc=f"/project/{project_enc}",
 		board=board_link_spec(project),
+		blog=blog_link_spec(project),
 		hooks=_hooks_context(project, project_enc),
 		merge_request=_merge_request_context(project),
 		open_merge_requests=_open_merge_requests(project),
@@ -772,6 +803,25 @@ def git_blob(project: str, h: str | None, f: str | None, *, raw: bool = False) -
 	)
 
 
+def git_blog_raw(project: str, path: str) -> Response:
+	"""Serve a blob from HEAD for the project blog viewer."""
+	if not is_valid_pathname(path):
+		raise HTTPException(status_code=404, detail="File not found")
+	result = get_blob_at_ref_path(project, "HEAD", path)
+	if not result:
+		raise HTTPException(status_code=404, detail="File not found")
+	blob, _ = result
+	data = blob.data
+	media_type, _ = mimetypes.guess_type(path.split("/")[-1])
+	if media_type is None:
+		try:
+			data.decode("utf-8")
+			media_type = "text/plain; charset=utf-8"
+		except UnicodeDecodeError:
+			media_type = "application/octet-stream"
+	return Response(content=data, media_type=media_type)
+
+
 def _blame_hunk_end_line(hunk: pygit2.BlameHunk) -> int:
 	return hunk.final_start_line_number + hunk.lines_in_hunk - 1
 
diff --git a/pygitweb/src/pygitweb/blog_routes_test.py b/pygitweb/src/pygitweb/blog_routes_test.py
new file mode 100644
index 0000000..c05734c
--- /dev/null
+++ b/pygitweb/src/pygitweb/blog_routes_test.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import json
+from collections.abc import Generator
+from pathlib import Path
+from unittest.mock import patch
+
+import pygit2
+import pytest
+from fastapi.testclient import TestClient
+from pygit2 import Signature
+
+from pygitweb.config import settings
+from pygitweb.main import app
+
+SIG = Signature("tester", "tester@example.com")
+
+
+def _commit_tree(repo: pygit2.Repository, repo_dir: Path, files: dict[str, str], message: str) -> None:
+	for rel, content in files.items():
+		path = repo_dir / rel
+		path.parent.mkdir(parents=True, exist_ok=True)
+		path.write_text(content, encoding="utf-8")
+	index = repo.index
+	for rel in files:
+		index.add(rel)
+	index.write()
+	tree = index.write_tree()
+	parents = [repo.head.target] if not repo.head_is_unborn else []
+	repo.create_commit("HEAD", SIG, SIG, message, tree, parents)
+
+
+@pytest.fixture
+def blog_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
+	root = tmp_path / "blog-root"
+	repo_dir = root / "demo"
+	repo_dir.mkdir(parents=True)
+	repo = pygit2.init_repository(str(repo_dir), bare=False)
+	manifest = [
+		{
+			"name": "Hello Blog",
+			"url": "?article=articles/2024.01.01/post.md",
+			"tags": "intro test",
+			"created": "Jan 1 2024",
+			"preview": "",
+			"prev": "",
+			"next": "",
+		}
+	]
+	_commit_tree(
+		repo,
+		repo_dir,
+		{
+			"README.md": "seed\n",
+			"articles/manifest.json": json.dumps(manifest),
+			"articles/2024.01.01/post.md": "# Hello\n\nBody text.\n",
+			"assets/note.txt": "asset\n",
+		},
+		"add blog",
+	)
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "AUTH", False),
+	):
+		yield {"project": "demo", "client": TestClient(app), "root": str(root)}
+
+
+@pytest.fixture
+def no_blog_env(tmp_path: Path) -> Generator[dict[str, str | TestClient], None, None]:
+	root = tmp_path / "no-blog-root"
+	repo_dir = root / "plain"
+	repo_dir.mkdir(parents=True)
+	repo = pygit2.init_repository(str(repo_dir), bare=False)
+	_commit_tree(repo, repo_dir, {"README.md": "no blog here\n"}, "initial")
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "AUTH", False),
+	):
+		yield {"project": "plain", "client": TestClient(app)}
+
+
+def test_blog_page_renders_when_manifest_exists(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{blog_env['project']}/blog/")
+	assert resp.status_code == 200
+	assert 'id="blog-root"' in resp.text
+	assert 'data-manifest="articles/manifest.json"' in resp.text
+	assert f'data-content-base="/project/{blog_env["project"]}/blog/raw/"' in resp.text
+	assert "/static/blog.js" in resp.text
+
+
+def test_blog_page_404_without_manifest(no_blog_env: dict[str, str | TestClient]) -> None:
+	client = no_blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{no_blog_env['project']}/blog/")
+	assert resp.status_code == 404
+
+
+def test_blog_raw_serves_manifest_and_markdown(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	project = blog_env["project"]
+	manifest_resp = client.get(f"/project/{project}/blog/raw/articles/manifest.json")
+	assert manifest_resp.status_code == 200
+	payload = manifest_resp.json()
+	assert payload[0]["name"] == "Hello Blog"
+
+	md_resp = client.get(f"/project/{project}/blog/raw/articles/2024.01.01/post.md")
+	assert md_resp.status_code == 200
+	assert "# Hello" in md_resp.text
+
+	asset_resp = client.get(f"/project/{project}/blog/raw/assets/note.txt")
+	assert asset_resp.status_code == 200
+	assert asset_resp.text == "asset\n"
+
+
+def test_blog_raw_rejects_missing_and_traversal(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	project = blog_env["project"]
+	assert client.get(f"/project/{project}/blog/raw/missing.md").status_code == 404
+	assert client.get(f"/project/{project}/blog/raw/../README.md").status_code == 404
+
+
+def test_summary_includes_blog_link(blog_env: dict[str, str | TestClient]) -> None:
+	client = blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{blog_env['project']}")
+	assert resp.status_code == 200
+	assert ">Blog</td>" in resp.text or ">Blog<" in resp.text
+	assert f'href="/project/{blog_env["project"]}/blog/">view blog</a>' in resp.text
+
+
+def test_summary_shows_no_blog_when_missing(no_blog_env: dict[str, str | TestClient]) -> None:
+	client = no_blog_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(f"/project/{no_blog_env['project']}")
+	assert resp.status_code == 200
+	assert ">no blog</a>" in resp.text
+	assert 'style="color: #999; cursor: not-allowed;"' in resp.text
diff --git a/pygitweb/src/pygitweb/main.py b/pygitweb/src/pygitweb/main.py
index c10fd06..8b22b34 100644
--- a/pygitweb/src/pygitweb/main.py
+++ b/pygitweb/src/pygitweb/main.py
@@ -21,6 +21,7 @@ from fastapi.staticfiles import StaticFiles
 
 from pygitweb import __meta__
 from pygitweb.actions import (
+	BLOG_MANIFEST_PATH,
 	SearchFlagQuery,
 	SearchSortQuery,
 	SummaryRefsPayload,
@@ -32,6 +33,7 @@ from pygitweb.actions import (
 	git_blob,
 	git_blobdiff,
 	git_blobpatch,
+	git_blog_raw,
 	git_commit,
 	git_commitdiff,
 	git_heads,
@@ -49,6 +51,7 @@ from pygitweb.actions import (
 	git_tags,
 	git_tree,
 	parse_pagination,
+	project_has_blog,
 	summary_ref_options,
 	summary_ref_state,
 )
@@ -628,6 +631,32 @@ def project_board(
 	)
 
 
+@app.get("/project/{project:path}/blog/", response_class=HTMLResponse)
+def project_blog(project: ValidatedReadableProject) -> HTMLResponse:
+	"""Markdown blog viewer for repos with articles/manifest.json at HEAD."""
+	if not project_has_blog(project):
+		raise HTTPException(status_code=404, detail="Blog not found")
+	project_enc = quote(project, safe="/")
+	project_url = f"/project/{project_enc}"
+	blog_url = f"{project_url}/blog/"
+	return render_page(
+		"blog.html",
+		title=f"{project} - Blog",
+		project=project,
+		project_url=project_url,
+		blog_url=blog_url,
+		content_base=f"{blog_url}raw/",
+		manifest_path=BLOG_MANIFEST_PATH,
+		nav="projects",
+	)
+
+
+@app.get("/project/{project:path}/blog/raw/{path:path}")
+def project_blog_raw(project: ValidatedReadableProject, path: str) -> Response:
+	"""Raw blob from HEAD for the blog viewer (markdown, manifest, assets)."""
+	return git_blog_raw(project, path)
+
+
 @app.get("/project/{project:path}/board-ref-manifest")
 def project_board_ref_manifest(
 	project: ValidatedReadableProject,
diff --git a/pygitweb/src/pygitweb/static/blog.js b/pygitweb/src/pygitweb/static/blog.js
new file mode 100644
index 0000000..f516f40
--- /dev/null
+++ b/pygitweb/src/pygitweb/static/blog.js
@@ -0,0 +1,377 @@
+function contentUrl(contentBase, path) {
+	const clean = String(path || "").replace(/^\/+/, "");
+	return contentBase.replace(/\/?$/, "/") + clean;
+}
+
+function sortNum(a, b) {
+	return a - b;
+}
+
+function isSafeNavUrl(url) {
+	const value = String(url || "").trim();
+	if (!value || value.startsWith("//") || /^[a-z][a-z0-9+.-]*:/i.test(value)) {
+		return false;
+	}
+	return true;
+}
+
+function articleDir(articlePath) {
+	const clean = String(articlePath || "").replace(/^\/+/, "");
+	const slash = clean.lastIndexOf("/");
+	return slash === -1 ? "" : clean.slice(0, slash + 1);
+}
+
+function resolveRelative(articlePath, href) {
+	const base = new URL(articleDir(articlePath), "https://blog.invalid/");
+	let resolved;
+	try {
+		resolved = new URL(href, base);
+	} catch (_ex) {
+		return null;
+	}
+	if (resolved.origin !== "https://blog.invalid") {
+		return null;
+	}
+	let path = resolved.pathname.replace(/^\/+/, "");
+	path += resolved.search;
+	path += resolved.hash;
+	return path;
+}
+
+function renderGraphs(root) {
+	if (typeof Viz !== "function") {
+		return;
+	}
+	for (const c of root.querySelectorAll("code.graphviz")) {
+		const p = document.createElement("p");
+		p.innerHTML = Viz(c.innerText, { format: "svg" });
+		p.className = "graph";
+		const pre = c.parentElement;
+		if (pre) {
+			pre.replaceWith(p);
+		}
+	}
+}
+
+function isRelativeResource(href) {
+	if (!href || href.charAt(0) === "#") {
+		return false;
+	}
+	if (href.charAt(0) === "/") {
+		return false;
+	}
+	if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
+		return false;
+	}
+	if (href.indexOf("//") === 0) {
+		return false;
+	}
+	if (href.charAt(0) === "?") {
+		return false;
+	}
+	return true;
+}
+
+function rewriteRelativeResources(root, contentBase, articlePath) {
+	for (const img of root.querySelectorAll("img[src]")) {
+		const src = img.getAttribute("src");
+		if (!src || !isRelativeResource(src)) {
+			continue;
+		}
+		const resolved = resolveRelative(articlePath, src);
+		if (resolved) {
+			img.setAttribute("src", contentUrl(contentBase, resolved));
+		}
+	}
+	for (const anchor of root.querySelectorAll("a[href]")) {
+		const href = anchor.getAttribute("href");
+		if (!href || !isRelativeResource(href)) {
+			continue;
+		}
+		const resolved = resolveRelative(articlePath, href);
+		if (!resolved) {
+			continue;
+		}
+		const hashIndex = resolved.indexOf("#");
+		const beforeHash = hashIndex === -1 ? resolved : resolved.slice(0, hashIndex);
+		const hash = hashIndex === -1 ? "" : resolved.slice(hashIndex);
+		const pathOnly = beforeHash.split("?")[0];
+		if (/\.(md|markdown)$/i.test(pathOnly)) {
+			anchor.setAttribute("href", "?article=" + encodeURIComponent(pathOnly) + hash);
+		} else {
+			anchor.setAttribute("href", contentUrl(contentBase, resolved));
+		}
+	}
+}
+
+function addLineNumbers(root) {
+	for (const block of root.querySelectorAll("pre > code:not(.graphviz)")) {
+		const lines = block.textContent.split("\n");
+		if (lines.length && lines[lines.length - 1] === "") {
+			lines.pop();
+		}
+		if (lines.length <= 1) {
+			continue;
+		}
+		const pre = block.parentElement;
+		if (!pre) {
+			continue;
+		}
+		const wrap = document.createElement("div");
+		wrap.className = "blog-code";
+		const langClass = block.className;
+		for (let i = 0; i < lines.length; i++) {
+			const row = document.createElement("div");
+			row.className = "blog-code-line";
+			const num = document.createElement("span");
+			num.className = "blog-code-lineno";
+			num.setAttribute("aria-hidden", "true");
+			num.textContent = String(i + 1);
+			const code = document.createElement("code");
+			if (langClass) {
+				code.className = langClass;
+			}
+			code.textContent = lines[i];
+			row.append(num, code);
+			wrap.append(row);
+		}
+		pre.replaceWith(wrap);
+	}
+}
+
+function runWhenReady(tryFn, scriptId) {
+	try {
+		tryFn();
+	} catch (_ex) {
+		const script = document.querySelector("#" + scriptId);
+		if (script) {
+			script.addEventListener("load", tryFn);
+		}
+	}
+}
+
+async function loadmd(articleEl, contentBase, path) {
+	const converter = new showdown.Converter();
+	const resp = await fetch(contentUrl(contentBase, path));
+	if (!resp.ok) {
+		articleEl.replaceChildren();
+		const failure = document.createElement("p");
+		failure.className = "text-danger";
+		failure.textContent = "Failed to load article.";
+		articleEl.append(failure);
+		return;
+	}
+	const md = await resp.text();
+	articleEl.innerHTML = converter.makeHtml(md);
+	rewriteRelativeResources(articleEl, contentBase, path);
+	addLineNumbers(articleEl);
+	if (window.location.hash !== "") {
+		try {
+			const target = articleEl.querySelector(window.location.hash);
+			if (target) {
+				target.scrollIntoView();
+			}
+		} catch (_ex) {
+		}
+	}
+	runWhenReady(() => {
+		if (typeof MathJax !== "undefined" && MathJax.typeset) {
+			MathJax.typeset();
+		}
+	}, "MathJax-script");
+	runWhenReady(() => {
+		if (typeof hljs === "undefined" || !hljs.highlightElement) {
+			return;
+		}
+		for (const block of articleEl.querySelectorAll(".blog-code-line > code, pre > code:not(.graphviz)")) {
+			hljs.highlightElement(block);
+		}
+	}, "Highlight-script");
+	runWhenReady(() => renderGraphs(articleEl), "GraphViz-script");
+}
+
+function appendTextBlock(parent, className, text) {
+	const el = document.createElement("div");
+	el.className = className;
+	el.textContent = text;
+	parent.append(el);
+}
+
+function appendArticleLink(parent, item, dateStr) {
+	const p = document.createElement("p");
+	p.append(document.createTextNode(dateStr + ": "));
+	const anchor = document.createElement("a");
+	const url = String(item.url || "");
+	anchor.href = isSafeNavUrl(url) ? url : "#";
+	anchor.textContent = String(item.name || "");
+	p.append(anchor);
+	parent.append(p);
+}
+
+function archive(articleEl, manifest) {
+	const nestedByDate = {};
+	const undated = [];
+	for (const item of manifest) {
+		const cdate = new Date(item.created);
+		if (Number.isNaN(cdate.getTime())) {
+			undated.push(item);
+			continue;
+		}
+		const y = cdate.getFullYear();
+		const m = cdate.getMonth();
+		const d = cdate.getDate();
+		if (!nestedByDate[y]) {
+			nestedByDate[y] = {};
+		}
+		if (!nestedByDate[y][m]) {
+			nestedByDate[y][m] = {};
+		}
+		if (!nestedByDate[y][m][d]) {
+			nestedByDate[y][m][d] = [];
+		}
+		nestedByDate[y][m][d].push(item);
+	}
+
+	let opened = false;
+	const dateOpts = { weekday: "long", year: "numeric", month: "long", day: "numeric" };
+	const years = Object.keys(nestedByDate).sort(sortNum).reverse();
+	for (const y of years) {
+		const yearDetails = document.createElement("details");
+		if (!opened) {
+			yearDetails.open = true;
+		}
+		const yearSummary = document.createElement("summary");
+		yearSummary.textContent = y;
+		yearDetails.append(yearSummary);
+		const yearQuote = document.createElement("blockquote");
+		yearQuote.append(yearDetails);
+		const months = Object.keys(nestedByDate[y]).sort(sortNum).reverse();
+		for (const m of months) {
+			const monthDetails = document.createElement("details");
+			if (!opened) {
+				monthDetails.open = true;
+				opened = true;
+			}
+			const monthSummary = document.createElement("summary");
+			monthSummary.textContent = new Date(2000, Number(m), 1).toLocaleString("default", { month: "long" });
+			monthDetails.append(monthSummary);
+			const days = Object.keys(nestedByDate[y][m]).sort(sortNum).reverse();
+			for (const d of days) {
+				for (const item of nestedByDate[y][m][d]) {
+					const dateStr = new Date(item.created).toLocaleString("default", dateOpts);
+					appendArticleLink(monthDetails, item, dateStr);
+				}
+			}
+			const monthQuote = document.createElement("blockquote");
+			monthQuote.append(monthDetails);
+			yearDetails.append(monthQuote);
+		}
+		articleEl.append(yearQuote);
+	}
+	if (undated.length) {
+		const details = document.createElement("details");
+		const summary = document.createElement("summary");
+		summary.textContent = "Undated";
+		details.append(summary);
+		for (const item of undated) {
+			appendArticleLink(details, item, "Undated");
+		}
+		const quote = document.createElement("blockquote");
+		quote.append(details);
+		articleEl.append(quote);
+	}
+}
+
+function hideSearchResults(resultsEl) {
+	resultsEl.classList.remove("show");
+}
+
+function searchChange(searchEl, resultsEl, searchManifest) {
+	resultsEl.replaceChildren();
+	const q = searchEl.value.trim().toLowerCase();
+	if (!q) {
+		hideSearchResults(resultsEl);
+		return;
+	}
+	for (const item of searchManifest) {
+		const name = String(item.name || "");
+		const tags = String(item.tags || "");
+		if (!name.toLowerCase().includes(q) && !tags.toLowerCase().includes(q)) {
+			continue;
+		}
+		const row = document.createElement("div");
+		row.className = "blog-search-result";
+		row.setAttribute("role", "option");
+		appendTextBlock(row, "blog-search-result-title", name);
+		appendTextBlock(row, "blog-search-result-snippet", tags);
+		appendTextBlock(row, "blog-search-result-date", String(item.created || ""));
+		const url = String(item.url || "");
+		if (isSafeNavUrl(url)) {
+			row.addEventListener("click", () => {
+				window.location.href = url;
+			});
+		}
+		resultsEl.appendChild(row);
+	}
+	if (resultsEl.childElementCount) {
+		resultsEl.classList.add("show");
+	} else {
+		hideSearchResults(resultsEl);
+	}
+}
+
+async function populateManifest(contentBase, path) {
+	const resp = await fetch(contentUrl(contentBase, path));
+	if (!resp.ok) {
+		return [];
+	}
+	return await resp.json();
+}
+
+async function loadManifest(articleEl, contentBase, path) {
+	const manifest = await populateManifest(contentBase, path);
+	archive(articleEl, manifest);
+	return manifest;
+}
+
+function bindSearch(root, searchManifest) {
+	const searchEl = root.querySelector(".blog-search");
+	const resultsEl = root.querySelector(".blog-search-results");
+	const container = root.querySelector(".blog-search-container");
+	if (!searchEl || !resultsEl || !container) {
+		return;
+	}
+	searchEl.addEventListener("input", () => searchChange(searchEl, resultsEl, searchManifest));
+	document.addEventListener("keydown", (event) => {
+		if (event.key === "Escape") {
+			hideSearchResults(resultsEl);
+		}
+	});
+	document.addEventListener("click", (event) => {
+		if (!container.contains(event.target) && !resultsEl.contains(event.target)) {
+			hideSearchResults(resultsEl);
+		}
+	});
+}
+
+async function boot() {
+	const root = document.getElementById("blog-root");
+	const articleEl = document.getElementById("article");
+	if (!root || !articleEl) {
+		return;
+	}
+	const contentBase = root.dataset.contentBase || "";
+	const manifestPath = root.dataset.manifest || "articles/manifest.json";
+	const u = new URL(window.location.href);
+	const article = u.searchParams.get("article");
+	let searchManifest = [];
+	if (article) {
+		await loadmd(articleEl, contentBase, article);
+		searchManifest = await populateManifest(contentBase, manifestPath);
+	} else {
+		searchManifest = await loadManifest(articleEl, contentBase, manifestPath);
+	}
+	bindSearch(root, searchManifest);
+}
+
+boot();
diff --git a/pygitweb/src/pygitweb/static/main.css b/pygitweb/src/pygitweb/static/main.css
index 775aa36..ade4049 100644
--- a/pygitweb/src/pygitweb/static/main.css
+++ b/pygitweb/src/pygitweb/static/main.css
@@ -1368,3 +1368,173 @@ pre {
 	border: 1px solid var(--pgw-border);
 	background-color: var(--pgw-bg-raised);
 }
+
+/* Project blog viewer */
+.blog-article {
+	max-width: 64em;
+	margin: 0 auto;
+}
+
+.blog-article.markdown-body {
+	blockquote {
+		background-color: var(--pgw-bg-muted);
+		border-left: 2px solid var(--pgw-border);
+		padding: 1.25rem 1.75rem;
+		border-radius: var(--tblr-border-radius, 0.25rem);
+		color: var(--pgw-text);
+	}
+
+	img {
+		display: block;
+		margin-left: auto;
+		margin-right: auto;
+		width: 60%;
+		max-width: 100%;
+		background-color: transparent;
+	}
+
+	p > code {
+		background-color: var(--pgw-code-bg);
+		border: 1px solid var(--pgw-code-border);
+		border-radius: var(--tblr-border-radius-sm, 0.125rem);
+		padding: 0.125rem 0.375rem;
+	}
+
+	p.graph {
+		display: flex;
+		align-items: center;
+	}
+
+	p > svg {
+		display: block;
+		margin-left: auto;
+		margin-right: auto;
+	}
+
+	.graph > polygon {
+		fill: transparent;
+	}
+
+	.node > text {
+		fill: var(--pgw-text);
+	}
+
+	.node > ellipse {
+		stroke: var(--pgw-text);
+	}
+
+	.edge > polygon {
+		fill: var(--pgw-text);
+	}
+
+	.edge > * {
+		stroke: var(--pgw-text);
+	}
+}
+
+.blog-code {
+	max-width: 100%;
+	overflow: auto;
+	margin: 0 0 1rem;
+	padding: 0.5em 0;
+	background-color: var(--pgw-code-bg);
+	border: 1px solid var(--pgw-code-border);
+	border-radius: var(--tblr-border-radius, 0.25rem);
+	font-family: "Fira Code", var(--tblr-font-monospace, ui-monospace, monospace);
+	font-size: 0.875rem;
+	line-height: 1.5;
+	color: var(--pgw-text);
+}
+
+.blog-code-line {
+	display: flex;
+	flex-direction: row;
+	flex-wrap: nowrap;
+	align-items: flex-start;
+	min-height: 1.5em;
+}
+
+.blog-code-lineno {
+	flex: 0 0 auto;
+	box-sizing: border-box;
+	min-width: 2.5em;
+	padding: 0 0.75em 0 0.5em;
+	border-right: 1px solid var(--pgw-border);
+	text-align: right;
+	color: var(--pgw-text-muted);
+	user-select: none;
+	font: inherit;
+	line-height: inherit;
+}
+
+.blog-code-line > code {
+	flex: 1 1 auto;
+	box-sizing: border-box;
+	min-width: 0;
+	padding: 0 0.75em;
+	margin: 0;
+	background: none !important;
+	border: none !important;
+	border-radius: 0 !important;
+	color: inherit;
+	font: inherit;
+	line-height: inherit;
+	white-space: pre;
+}
+
+.blog-search-container {
+	position: relative;
+}
+
+.blog-search-results {
+	position: absolute;
+	top: 100%;
+	left: 0;
+	width: 100%;
+	max-width: 28rem;
+	margin-top: 0.25rem;
+	display: none;
+	max-height: 70vh;
+	overflow-y: auto;
+	z-index: 20;
+}
+
+.blog-search-results.show {
+	display: block;
+}
+
+.blog-search-result {
+	padding: 0.75rem 1rem;
+	border-bottom: 1px solid var(--pgw-border);
+	color: var(--pgw-text);
+	text-decoration: none;
+	display: block;
+	cursor: pointer;
+	transition: background-color 0.15s ease;
+}
+
+.blog-search-result:hover {
+	background-color: var(--pgw-table-hover);
+}
+
+.blog-search-result:last-child {
+	border-bottom: none;
+}
+
+.blog-search-result-title {
+	font-weight: 600;
+	margin-bottom: 0.25rem;
+}
+
+.blog-search-result-snippet {
+	font-size: 0.875rem;
+	color: var(--pgw-text-muted);
+	line-height: 1.4;
+}
+
+.blog-search-result-date {
+	font-size: 0.75rem;
+	color: var(--pgw-text-dim, var(--pgw-text-muted));
+	margin-top: 0.25rem;
+}
+
diff --git a/pygitweb/src/pygitweb/templates/blog.html b/pygitweb/src/pygitweb/templates/blog.html
new file mode 100644
index 0000000..97b271d
--- /dev/null
+++ b/pygitweb/src/pygitweb/templates/blog.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+{% from "macros.html" import page_header %}
+{% block head %}
+<script defer src="/static/vendor/showdown-2.1.0.min.js"></script>
+<script defer id="Highlight-script" src="/static/vendor/highlight-11.9.0.min.js"></script>
+<script async id="MathJax-script" src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-chtml.min.js"></script>
+<script async id="GraphViz-script" src="https://cdnjs.cloudflare.com/ajax/libs/viz.js/1.8.2/viz.js"></script>
+{% endblock %}
+{% block content %}
+<div
+	id="blog-root"
+	class="blog-root"
+	data-content-base="{{ content_base }}"
+	data-manifest="{{ manifest_path }}"
+	data-blog-url="{{ blog_url }}"
+>
+	{% call page_header(project, "Blog") %}
+	<a href="{{ project_url }}" class="btn btn-outline-secondary">Project</a>
+	<a href="{{ blog_url }}" class="btn btn-outline-secondary">Articles</a>
+	{% endcall %}
+	<div class="card">
+		<div class="card-body">
+			<div class="blog-search-container mb-3" style="max-width: 28rem;">
+				<div class="input-icon w-100">
+					<span class="input-icon-addon">
+						<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-search" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0" /><path d="M21 21l-6 -6" /></svg>
+					</span>
+					<input class="form-control blog-search" type="search" placeholder="Search articles" aria-label="Search articles" autocomplete="off">
+				</div>
+				<div class="blog-search-results dropdown-menu" id="blog-search-results" role="listbox"></div>
+			</div>
+			<div id="article" class="blog-article markdown-body"></div>
+		</div>
+	</div>
+</div>
+{% endblock %}
+{% block scripts %}
+<script type="module" src="/static/blog.js"></script>
+{% endblock %}
diff --git a/pygitweb/src/pygitweb/templates/summary.html b/pygitweb/src/pygitweb/templates/summary.html
index 8180888..50ddda0 100644
--- a/pygitweb/src/pygitweb/templates/summary.html
+++ b/pygitweb/src/pygitweb/templates/summary.html
@@ -64,6 +64,7 @@
   {% call overview_card("Tags") %}{{ link(project_url ~ "?a=tags", "view tags") }}{% endcall %}
   {% call overview_card("Remotes") %}{{ link(project_url ~ "?a=remotes", "view remotes") }}{% endcall %}
   {% call overview_card("Board") %}{{ link(board.href, board.text, style=board.style) }}{% endcall %}
+  {% call overview_card("Blog") %}{{ link(blog.href, blog.text, style=blog.style) }}{% endcall %}
   {% call overview_card("Search") %}{{ link(project_url_enc ~ "/search", "search files") }}{% endcall %}
   {% call overview_card("Hooks") %}
   {% with mode=hooks.mode, project_enc=hooks.project_enc, installed=hooks.installed, available=hooks.available %}
diff --git a/pygitweb/src/pygitweb/templates_env_test.py b/pygitweb/src/pygitweb/templates_env_test.py
index 61a6e96..51724fe 100644
--- a/pygitweb/src/pygitweb/templates_env_test.py
+++ b/pygitweb/src/pygitweb/templates_env_test.py
@@ -1,4 +1,4 @@
-from pygitweb.actions import project_board_link
+from pygitweb.actions import blog_link_spec, project_board_link
 from pygitweb.templates_env import env, html_format, html_join, html_link, render_page, trusted_html
 
 
@@ -94,3 +94,23 @@ def test_project_board_link_offers_create_when_missing(monkeypatch) -> None:
 	plain = str(project_board_link("my proj"))
 	assert "style=" not in plain
 	assert ">create board</a>" in plain
+
+
+def test_blog_link_spec_points_at_existing_blog(monkeypatch) -> None:
+	monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: True)
+	spec = blog_link_spec("demo")
+	assert spec == {
+		"href": "/project/demo/blog/",
+		"text": "view blog",
+		"style": None,
+	}
+
+
+def test_blog_link_spec_muted_when_missing(monkeypatch) -> None:
+	monkeypatch.setattr("pygitweb.actions.project_has_blog", lambda _project: False)
+	spec = blog_link_spec("my proj")
+	assert spec == {
+		"href": "#",
+		"text": "no blog",
+		"style": "color: #999; cursor: not-allowed;",
+	}
