feat: Implement Sprint 8 - Portfolio website expansion (MVP)
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>
This commit is contained in:
109
portfolio_app/utils/markdown_loader.py
Normal file
109
portfolio_app/utils/markdown_loader.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user