New pages: - Home: Redesigned with hero, impact stats, featured project - About: 6-section professional narrative - Projects: Hub with 4 project cards and status badges - Resume: Inline display with download placeholders - Contact: Form UI (disabled) with contact info - Blog: Markdown-based system with frontmatter support Infrastructure: - Blog system with markdown loader (python-frontmatter, markdown, pygments) - Sidebar callback for active state highlighting on navigation - Separated navigation into main pages and projects/dashboards groups Closes #36, #37, #38, #39, #40, #41, #42, #43 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
110 lines
2.8 KiB
Python
110 lines
2.8 KiB
Python
"""Markdown article loader with frontmatter support."""
|
|
|
|
from pathlib import Path
|
|
from typing import TypedDict
|
|
|
|
import frontmatter
|
|
import markdown
|
|
from markdown.extensions.codehilite import CodeHiliteExtension
|
|
from markdown.extensions.fenced_code import FencedCodeExtension
|
|
from markdown.extensions.tables import TableExtension
|
|
from markdown.extensions.toc import TocExtension
|
|
|
|
# Content directory (relative to this file's package)
|
|
CONTENT_DIR = Path(__file__).parent.parent / "content" / "blog"
|
|
|
|
|
|
class ArticleMeta(TypedDict):
|
|
"""Article metadata from frontmatter."""
|
|
|
|
slug: str
|
|
title: str
|
|
date: str
|
|
description: str
|
|
tags: list[str]
|
|
status: str # "published" or "draft"
|
|
|
|
|
|
class Article(TypedDict):
|
|
"""Full article with metadata and content."""
|
|
|
|
meta: ArticleMeta
|
|
content: str
|
|
html: str
|
|
|
|
|
|
def render_markdown(content: str) -> str:
|
|
"""Convert markdown to HTML with syntax highlighting.
|
|
|
|
Args:
|
|
content: Raw markdown string.
|
|
|
|
Returns:
|
|
HTML string with syntax-highlighted code blocks.
|
|
"""
|
|
md = markdown.Markdown(
|
|
extensions=[
|
|
FencedCodeExtension(),
|
|
CodeHiliteExtension(css_class="highlight", guess_lang=False),
|
|
TableExtension(),
|
|
TocExtension(permalink=True),
|
|
"nl2br",
|
|
]
|
|
)
|
|
return str(md.convert(content))
|
|
|
|
|
|
def get_article(slug: str) -> Article | None:
|
|
"""Load a single article by slug.
|
|
|
|
Args:
|
|
slug: Article slug (filename without .md extension).
|
|
|
|
Returns:
|
|
Article dict or None if not found.
|
|
"""
|
|
filepath = CONTENT_DIR / f"{slug}.md"
|
|
if not filepath.exists():
|
|
return None
|
|
|
|
post = frontmatter.load(filepath)
|
|
|
|
meta: ArticleMeta = {
|
|
"slug": slug,
|
|
"title": post.get("title", slug.replace("-", " ").title()),
|
|
"date": str(post.get("date", "")),
|
|
"description": post.get("description", ""),
|
|
"tags": post.get("tags", []),
|
|
"status": post.get("status", "published"),
|
|
}
|
|
|
|
return {
|
|
"meta": meta,
|
|
"content": post.content,
|
|
"html": render_markdown(post.content),
|
|
}
|
|
|
|
|
|
def get_all_articles(include_drafts: bool = False) -> list[Article]:
|
|
"""Load all articles from the content directory.
|
|
|
|
Args:
|
|
include_drafts: If True, include articles with status="draft".
|
|
|
|
Returns:
|
|
List of articles sorted by date (newest first).
|
|
"""
|
|
if not CONTENT_DIR.exists():
|
|
return []
|
|
|
|
articles: list[Article] = []
|
|
for filepath in CONTENT_DIR.glob("*.md"):
|
|
slug = filepath.stem
|
|
article = get_article(slug)
|
|
if article and (include_drafts or article["meta"]["status"] == "published"):
|
|
articles.append(article)
|
|
|
|
# Sort by date descending
|
|
articles.sort(key=lambda a: a["meta"]["date"], reverse=True)
|
|
return articles
|