Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,16 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv venv && uv pip install -e ".[dev]"
run: uv pip install -e ".[dev]"

- name: Run linter (Black)
run: uv run black --check src/
- name: Format check (Ruff)
run: uv run ruff format --check src/

- name: Lint (Ruff)
run: uv run ruff check src/

- name: Type check (ty)
run: uv run ty check src/

- name: Test with pytest
run: |
Expand Down Expand Up @@ -56,7 +62,7 @@ jobs:
python-version: "3.12"

- name: Install build dependencies
run: uv venv && uv pip install build twine
run: uv pip install build twine

- name: Build package
run: uv run python -m build
Expand All @@ -81,7 +87,7 @@ jobs:
python-version: "3.12"

- name: Install build dependencies
run: uv venv && uv pip install build twine
run: uv pip install build twine

- name: Build package
run: uv run python -m build
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ __pycache__/
build/
develop-eggs/
dist/
.pytest_cache/
.ruffle_cache/
downloads/
eggs/
.eggs/
Expand All @@ -20,6 +22,7 @@ wheels/
.installed.cfg
*.egg
.venv/
.coverage

# Virtual environments
venv/
Expand Down
65 changes: 65 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

makefolio is a Python static site generator for professional portfolio websites. It processes Markdown content with YAML frontmatter into HTML using Jinja2 templates and a theme system.

## Commands

```bash
# Install for development
uv pip install -e ".[dev]"

# Build the package
uv run python -m build

# Lint & format (Ruff, line-length 100, target py311)
uv run ruff format --check src/
uv run ruff format src/ # auto-fix
uv run ruff check src/
uv run ruff check --fix src/ # auto-fix

# Type check (ty)
uv run ty check src/

# Run tests
uv run pytest --cov=src/makefolio --cov-report=term

# CLI usage (after install)
uv run makefolio init my-portfolio
uv run makefolio build
uv run makefolio serve
uv run makefolio new project --name my-project
```

## Architecture

The package lives in `src/makefolio/` with a Click-based CLI as the entry point:

- **cli.py** — Click commands: `init`, `build`, `serve`, `new`. Entry point registered as `makefolio` console script.
- **builder.py** — `Builder` class orchestrates the build pipeline: loads config, parses all content directories (projects, experience, education, pages), sets up Jinja2 with theme templates, and renders everything to `build/`.
- **content.py** — `ContentParser` parses Markdown+frontmatter files via `python-frontmatter` and `markdown` libraries. `SiteConfig` loads `config.yaml`.
- **server.py** — `DevServer` runs a threaded HTTP server with watchdog-based file watching for hot reload. Handles clean URL routing (extensionless paths → `.html`).
- **utils.py** — `init_project()` scaffolds new projects with directory structure, default config, and theme. `create_content_file()` generates content files with type-specific frontmatter templates.
- **themes/default/** — Bundled theme with Jinja2 templates and static assets (CSS/JS). Falls back to package-bundled theme if not found in the project directory.

### Content Model

A portfolio site has four content types, each in its own subdirectory under `content/`:
- `projects/` — portfolio project entries
- `experience/` — work experience items (sorted by `start_date` descending)
- `education/` — education items (sorted by `start_date` descending)
- Root `content/*.md` — standalone pages (e.g., `about.md`)

Site-wide configuration lives in `content/config.yaml` (site metadata, social links, skills, navigation).

### Build Output

`Builder.build()` wipes and recreates the `build/` directory, copies static assets from both the project and theme, then renders all templates with a unified context containing all parsed content.

## Workflow

- Always commit changes when a task is complete. Group related changes into logical commits rather than one large commit.
- Follow Conventional Commits: `feat:`, `fix:`, `refactor:`, `style:`, `docs:`, `chore:`, `test:`, `ci:`, `perf:`. Use a scope when helpful, e.g. `feat(templates): add scroll-reveal animations`.
14 changes: 10 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ dependencies = [

[project.optional-dependencies]
dev = [
"black>=23.12.0",
"ruff>=0.11.0",
"ty>=0.0.1a7",
"pytest>=7.4.4",
"pytest-cov>=4.1.0",
"bump-my-version>=0.15.0",
Expand All @@ -54,8 +55,13 @@ where = ["src"]
[tool.setuptools.package-data]
makefolio = ["themes/**/*"]

[tool.black]
[tool.ruff]
line-length = 100
target-version = ['py311']
include = '\.pyi?$'
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "UP"]

[tool.ty.environment]
python-version = "3.11"

18 changes: 9 additions & 9 deletions src/makefolio/builder.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Site builder and renderer."""

import shutil
from pathlib import Path
from typing import Dict, Any
from datetime import datetime
from xml.etree.ElementTree import Element, SubElement, ElementTree
from pathlib import Path
from typing import Any
from xml.etree.ElementTree import Element, ElementTree, SubElement

from jinja2 import Environment, FileSystemLoader, select_autoescape

Expand Down Expand Up @@ -114,12 +114,12 @@ def _copy_static_files(self):
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)

def _render_home(self, context: Dict[str, Any]):
def _render_home(self, context: dict[str, Any]):
template = self.env.get_template("index.html")
html = template.render(**context)
(self.output_path / "index.html").write_text(html, encoding="utf-8")

def _render_projects(self, context: Dict[str, Any]):
def _render_projects(self, context: dict[str, Any]):
projects_dir = self.output_path / "projects"
projects_dir.mkdir(exist_ok=True)

Expand All @@ -138,7 +138,7 @@ def _render_projects(self, context: Dict[str, Any]):
slug = project["slug"]
(projects_dir / f"{slug}.html").write_text(html, encoding="utf-8")

def _render_experience(self, context: Dict[str, Any]):
def _render_experience(self, context: dict[str, Any]):
experience_dir = self.output_path / "experience"
experience_dir.mkdir(exist_ok=True)

Expand All @@ -154,7 +154,7 @@ def _render_experience(self, context: Dict[str, Any]):
slug = exp["slug"]
(experience_dir / f"{slug}.html").write_text(html, encoding="utf-8")

def _render_education(self, context: Dict[str, Any]):
def _render_education(self, context: dict[str, Any]):
education_dir = self.output_path / "education"
education_dir.mkdir(exist_ok=True)

Expand All @@ -169,7 +169,7 @@ def _render_education(self, context: Dict[str, Any]):
slug = edu["slug"]
(education_dir / f"{slug}.html").write_text(html, encoding="utf-8")

def _render_pages(self, context: Dict[str, Any]):
def _render_pages(self, context: dict[str, Any]):
page_template = self.env.get_template("page.html")
for page in context["pages"]:
slug = page["slug"]
Expand All @@ -179,7 +179,7 @@ def _render_pages(self, context: Dict[str, Any]):
html = page_template.render(**page_context)
(self.output_path / f"{slug}.html").write_text(html, encoding="utf-8")

def _generate_sitemap(self, context: Dict[str, Any]):
def _generate_sitemap(self, context: dict[str, Any]):
site_url = context["site"].get("url", "").rstrip("/")
if not site_url:
return
Expand Down
8 changes: 4 additions & 4 deletions src/makefolio/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from makefolio.builder import Builder
from makefolio.server import DevServer
from makefolio.utils import init_project, create_content_file
from makefolio.utils import create_content_file, init_project


@click.group()
Expand Down Expand Up @@ -38,10 +38,10 @@ def init(name, path):

init_project(target_dir)
click.echo(f"Created new makefolio project at {target_dir}")
click.echo(f"\nNext steps:")
click.echo("\nNext steps:")
click.echo(f" cd {target_dir}")
click.echo(f" makefolio build")
click.echo(f" makefolio serve")
click.echo(" makefolio build")
click.echo(" makefolio serve")


@main.command()
Expand Down
22 changes: 11 additions & 11 deletions src/makefolio/content.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""Content parsing and site configuration."""

import math
from pathlib import Path
from typing import Any

import frontmatter
import markdown
from pathlib import Path
from typing import Dict, List, Any, Set
import yaml


WORDS_PER_MINUTE = 200


Expand All @@ -17,8 +17,8 @@ class ContentParser:
def __init__(self):
self.md = markdown.Markdown(extensions=["fenced_code", "tables", "codehilite"])

def parse_file(self, file_path: Path) -> Dict[str, Any]:
with open(file_path, "r", encoding="utf-8") as f:
def parse_file(self, file_path: Path) -> dict[str, Any]:
with open(file_path, encoding="utf-8") as f:
post = frontmatter.load(f)

html_content = self.md.convert(post.content)
Expand All @@ -37,7 +37,7 @@ def parse_file(self, file_path: Path) -> Dict[str, Any]:
"reading_time": reading_time,
}

def parse_directory(self, dir_path: Path) -> List[Dict[str, Any]]:
def parse_directory(self, dir_path: Path) -> list[dict[str, Any]]:
items = []
if not dir_path.exists():
return items
Expand All @@ -51,10 +51,10 @@ def parse_directory(self, dir_path: Path) -> List[Dict[str, Any]]:
return items

@staticmethod
def collect_tags(items: List[Dict[str, Any]]) -> List[str]:
def collect_tags(items: list[dict[str, Any]]) -> list[str]:
"""Collect and deduplicate tags across a list of content items."""
seen: Set[str] = set()
tags: List[str] = []
seen: set[str] = set()
tags: list[str] = []
for item in items:
for tag in item.get("meta", {}).get("tags", []):
lower = tag.lower()
Expand All @@ -72,11 +72,11 @@ def __init__(self, config_path: Path):
self.config_path = config_path
self.data = self._load_config()

def _load_config(self) -> Dict[str, Any]:
def _load_config(self) -> dict[str, Any]:
if not self.config_path.exists():
return {}

with open(self.config_path, "r", encoding="utf-8") as f:
with open(self.config_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {}

def get(self, key: str, default: Any = None) -> Any:
Expand Down
10 changes: 5 additions & 5 deletions src/makefolio/server.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
"""Development server with hot reload."""

import http.server
import socketserver
import os
import signal
import socket
import socketserver
import threading
import signal
import time
import os
from pathlib import Path
from watchdog.observers import Observer

from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer

from makefolio.builder import Builder


REBUILD_EXTENSIONS = (".md", ".yaml", ".html", ".css", ".js")
STATIC_EXTENSIONS = (
".html",
Expand Down
4 changes: 2 additions & 2 deletions src/makefolio/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Utility functions for project scaffolding and content creation."""

import shutil
from pathlib import Path
from datetime import datetime
from pathlib import Path


def init_project(target_dir: Path):
Expand Down Expand Up @@ -83,7 +83,7 @@ def init_project(target_dir: Path):
shutil.copytree(theme_source, theme_target, dirs_exist_ok=True)


def create_content_file(source_path: Path, content_type: str, name: str = None) -> Path:
def create_content_file(source_path: Path, content_type: str, name: str | None = None) -> Path:
"""Create a new content file with frontmatter template for the given type."""
if not name:
timestamp = datetime.now().strftime("%Y-%m-%d")
Expand Down
Loading