From ecc094293364bd958d51b4d04cb237280d70831c Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 14 Apr 2026 13:00:52 +0400 Subject: [PATCH 1/4] Fix CI: drop uv venv, setup-uv already creates the venv astral-sh/setup-uv@v5 with python-version creates a .venv automatically. Running uv venv again fails because it already exists. Just use uv pip install directly into the existing venv. --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6ab336..5c9d7ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ 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/ @@ -56,7 +56,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 @@ -81,7 +81,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 From 1d2f61e392c20b025a04b32774abf4c4c7da3cba Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 14 Apr 2026 13:11:57 +0400 Subject: [PATCH 2/4] fix(ci): pin Black >=25.1.0 to fix formatting mismatch The loose >=23.12.0 constraint allowed CI to resolve an older Black version that formats code differently from local dev (25.x), causing spurious lint failures on content.py and server.py. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index baabb63..ed4fbff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black>=23.12.0", + "black>=25.1.0", "pytest>=7.4.4", "pytest-cov>=4.1.0", "bump-my-version>=0.15.0", From d79d06b750ab602f18ebf9323eaac81d28c79a51 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 14 Apr 2026 13:18:23 +0400 Subject: [PATCH 3/4] refactor: migrate from Black to Ruff + ty for linting and type checking Replace Black with the uv-native Ruff (format + lint) and ty (type checking). Ruff auto-fixed import sorting, deprecated typing imports (Dict/List/Set -> builtins), unnecessary f-strings, and redundant open() mode args. Fixed one ty error (Optional parameter annotation). Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/ci.yml | 10 +++++-- CLAUDE.md | 65 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 14 ++++++--- src/makefolio/builder.py | 18 +++++------ src/makefolio/cli.py | 8 ++--- src/makefolio/content.py | 22 +++++++------- src/makefolio/server.py | 10 +++---- src/makefolio/utils.py | 4 +-- 8 files changed, 114 insertions(+), 37 deletions(-) create mode 100644 CLAUDE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c9d7ff..6f35da5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,8 +26,14 @@ jobs: - name: Install dependencies 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: | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8589cf5 --- /dev/null +++ b/CLAUDE.md @@ -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`. diff --git a/pyproject.toml b/pyproject.toml index ed4fbff..8f90313 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,8 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black>=25.1.0", + "ruff>=0.11.0", + "ty>=0.0.1a7", "pytest>=7.4.4", "pytest-cov>=4.1.0", "bump-my-version>=0.15.0", @@ -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" diff --git a/src/makefolio/builder.py b/src/makefolio/builder.py index d08ba10..92c8049 100644 --- a/src/makefolio/builder.py +++ b/src/makefolio/builder.py @@ -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 @@ -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) @@ -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) @@ -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) @@ -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"] @@ -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 diff --git a/src/makefolio/cli.py b/src/makefolio/cli.py index 1f6b618..ac9677a 100644 --- a/src/makefolio/cli.py +++ b/src/makefolio/cli.py @@ -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() @@ -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() diff --git a/src/makefolio/content.py b/src/makefolio/content.py index 0713f13..2fa183f 100644 --- a/src/makefolio/content.py +++ b/src/makefolio/content.py @@ -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 @@ -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) @@ -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 @@ -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() @@ -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: diff --git a/src/makefolio/server.py b/src/makefolio/server.py index 349bf2c..f15e322 100644 --- a/src/makefolio/server.py +++ b/src/makefolio/server.py @@ -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", diff --git a/src/makefolio/utils.py b/src/makefolio/utils.py index 88b9960..ca954c1 100644 --- a/src/makefolio/utils.py +++ b/src/makefolio/utils.py @@ -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): @@ -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") From fcdcd0ba344666318e9170bd31f9cdbe08383c46 Mon Sep 17 00:00:00 2001 From: martian56 Date: Tue, 14 Apr 2026 13:21:56 +0400 Subject: [PATCH 4/4] chore: update gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index be7f5d5..5cfc20f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ __pycache__/ build/ develop-eggs/ dist/ +.pytest_cache/ +.ruffle_cache/ downloads/ eggs/ .eggs/ @@ -20,6 +22,7 @@ wheels/ .installed.cfg *.egg .venv/ +.coverage # Virtual environments venv/