from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple, Iterable
# --------- small helpers ---------
_YFM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", re.DOTALL) # YAML front matter
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _load_yaml_str(s: str) -> Dict[str, Any]:
if not isinstance(data, dict):
raise ValueError("YAML root must be a mapping (object).")
def _parse_front_matter_or_yaml(path: Path) -> Tuple[Dict[str, Any], Optional[str]]:
Returns (data, body). For .yml/.yaml: body=None.
For .md/.mdx with YAML front matter: body is the markdown content string.
if path.suffix.lower() in {".yml", ".yaml"}:
return _load_yaml_str(text), None
# Markdown (optional YAML front matter)
fm_yaml, body = m.group(1), m.group(2)
data = _load_yaml_str(fm_yaml)
# No front matter: treat whole file as body with empty meta
def _slug_from_filename(path: Path) -> str:
# e.g., content/projects/sunrise-drive.yml -> sunrise-drive
def _iter_files(root: Path, patterns: Iterable[str]) -> Iterable[Path]:
yield from root.glob(pat)
# --------- loader API ---------
root: Path = Path("content")
site_file: str = "site.yml"
projects_dir: str = "projects"
Minimal Pages CMS content reader for:
- single file: content/site.yml
- collections: content/{projects,team,logos}/*.yml|.yaml|.md|.mdx
def __init__(self, paths: ContentPaths | None = None) -> None:
self.paths = paths or ContentPaths()
# ----- Single file (site) -----
def load_site(self) -> Dict[str, Any]:
p = self.paths.root / self.paths.site_file
raise FileNotFoundError(f"Missing site file: {p}")
data, body = _parse_front_matter_or_yaml(p)
# If you ever store long site prose in markdown, expose it:
data.setdefault("body", body)
# ----- Collections -----
def load_projects(self) -> List[Dict[str, Any]]:
return self._load_collection(self.paths.projects_dir)
def load_team(self) -> List[Dict[str, Any]]:
return self._load_collection(self.paths.team_dir)
def load_logos(self) -> List[Dict[str, Any]]:
return self._load_collection(self.paths.logos_dir)
def _load_collection(self, subdir: str) -> List[Dict[str, Any]]:
root = self.paths.root / subdir
items: List[Dict[str, Any]] = []
for path in _iter_files(root, ("*.yml", "*.yaml", "*.md", "*.mdx")):
data, body = _parse_front_matter_or_yaml(path)
data.setdefault("slug", data.get("slug") or _slug_from_filename(path))
data.setdefault("_path", str(path)) # helpful for debugging
if body is not None and "body" not in data:
# --------- convenience: common sorts/filters ---------
def sort_projects_for_grid(projects: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
Typical grid: by 'order' (asc), then 'year' (desc), then 'title' (asc).
Missing fields fall back safely.
def key(p: Dict[str, Any]):
order = p.get("order", 10_000)
year = p.get("year", -9_999_999)
title = (p.get("title") or "").lower()
return (order, -int(year) if isinstance(year, int) else -9_999_999, title)
return sorted(projects, key=key)
def featured_projects(projects: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
return [p for p in projects if p.get("featured")]