Files
personal-portfolio/portfolio_app/utils/markdown_loader.py
lmiranda 138e6fe497 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>
2026-01-15 15:40:01 -05:00

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