Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
430f077
Delete unused migrate script
pascalecu May 17, 2026
c95f6fe
Add pycache to gitignore
pascalecu May 17, 2026
648eba5
Split automate.py into many files
pascalecu May 17, 2026
b943a92
Remove wildcard import from sections.py
pascalecu May 17, 2026
a665f10
Split images.py into multiple files
pascalecu May 17, 2026
b6e02fa
Improve logging
pascalecu May 17, 2026
d9c2363
Add more utils.plural helpers and better docstrings
pascalecu May 17, 2026
aaed12a
Improve file helpers
pascalecu May 17, 2026
9f77e15
Optimize split_text, improve markdown_escape and docstrings
pascalecu May 17, 2026
9324e4d
Parallelize image generation
pascalecu May 17, 2026
d62b122
Optimize and refactor image_copy
pascalecu May 17, 2026
b992e63
Improve image_lookup.py docstrings and code
pascalecu May 17, 2026
dfbe555
Make image building cleaner
pascalecu May 18, 2026
8c8cd68
Split generate_sample_program_index into multiple functions
pascalecu May 18, 2026
c8ab4dd
Make generate_auto_gen_test_docs more robust using contextlib.chdir
pascalecu May 18, 2026
bb2e55e
pluralize now returns count + correct word form
pascalecu May 18, 2026
3f0a068
Refactor projects.py
pascalecu May 18, 2026
16b3c3b
Split generate_main_page into multiple helpers
pascalecu May 18, 2026
87621b6
Optimize and refactor languages.py
pascalecu May 18, 2026
c94a8b4
Optimize article generation
pascalecu May 18, 2026
55a3a67
Make front matter generation more robust
pascalecu May 18, 2026
80cf42d
Generate "DO NOT EDIT" in a cleaner way
pascalecu May 18, 2026
f55b5e1
Add better docstrings and clean up section generation
pascalecu May 18, 2026
94b23e2
Consolidate more constants
pascalecu May 18, 2026
83026f1
Fix _add_solution_block sometimes treating code blocks as images
pascalecu May 18, 2026
3ecf5ba
Backport contextlib.chdir to Python 3.10
pascalecu May 18, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
/docs/Gemfile.lock
generated/
/requirements-dev.txt
__pycache__
176 changes: 176 additions & 0 deletions scripts/assets/image_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import logging
import shutil
import subprocess
import tempfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path

import subete
from constants import (
DEFAULT_LANGUAGE_IMAGE_NO_EXT,
DEFAULT_PROGRAM_IMAGE_NO_EXT,
DEFAULT_PROJECT_IMAGE_NO_EXT,
LANGUAGES_DIR,
PROGRAMS_DIR,
PROJECTS_DIR,
SOURCE_DIR,
)
from utils.files import mkdir

log = logging.getLogger(__name__)

ASSETS_DIR = Path("docs/assets/images")
LOGO_PATH = ASSETS_DIR / "icon-small.png"

FEATURED_GLOB = "featured-image.*"


@dataclass(frozen=True)
class ImageSpec:
src_dir: Path
dest_no_ext: str


def generate_images(repo: subete.Repo, workers: int = 8) -> int:
"""Generate all processed images using image-titler.

Returns:
0 if all succeeded, 1 if any failed.

"""
specs = [
*_language_specs(repo),
*_project_specs(repo),
*_program_specs(repo),
]

failures = _run_parallel(specs, workers=workers)
return 1 if failures else 0


def _run_parallel(specs: list[ImageSpec], workers: int) -> list[ImageSpec]:
failures: list[ImageSpec] = []

with ThreadPoolExecutor(max_workers=workers) as pool:
future_map = {pool.submit(_process_spec, spec): spec for spec in specs}

for future in as_completed(future_map):
spec = future_map[future]
try:
if not future.result():
failures.append(spec)
except Exception:
log.exception("Unexpected failure: %s", spec)
failures.append(spec)

for spec in failures:
log.error("Failed image spec: %s", spec)

return failures


def _process_spec(spec: ImageSpec) -> bool:
src_image = _find_featured_image(spec.src_dir)
if src_image is None:
return True

dest_path = ASSETS_DIR / f"{spec.dest_no_ext}{src_image.suffix}"
_ = mkdir(dest_path.parent)

log.info("Processing %s -> %s", src_image, dest_path)

with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)

try:
subprocess.run(
[
"image-titler",
"--path",
str(src_image),
"--output",
str(tmp_dir),
"--logo",
str(LOGO_PATH),
"--no_title",
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)

produced = _pick_output(tmp_dir)
if produced is None:
log.error("No output produced for %s", src_image)
return False

_safe_move(produced, dest_path)
return True

except subprocess.CalledProcessError:
log.exception("image-titler failed for %s", src_image)
return False


def _pick_output(tmp_dir: Path) -> Path | None:
"""Pick deterministic output file."""
files = sorted(p for p in tmp_dir.iterdir() if p.is_file())
return files[0] if files else None


def _safe_move(src: Path, dest: Path) -> None:
"""Move file without crashing on existing destination."""
if dest.exists():
log.warning("Overwriting existing file: %s", dest)
dest.unlink()

shutil.move(str(src), str(dest))


def _find_featured_image(dir_path: Path) -> Path | None:
if not dir_path.is_dir():
return None
return next(dir_path.glob(FEATURED_GLOB), None)


def _language_specs(repo: subete.Repo) -> list[ImageSpec]:
specs = [ImageSpec(LANGUAGES_DIR, DEFAULT_LANGUAGE_IMAGE_NO_EXT)]

for lang in repo:
name = lang.pathlike_name()
specs.append(
ImageSpec(LANGUAGES_DIR / name, f"the-{name}-programming-language"),
)

return specs


def _project_specs(repo: subete.Repo) -> list[ImageSpec]:
specs = [ImageSpec(PROJECTS_DIR, DEFAULT_PROJECT_IMAGE_NO_EXT)]

for project in repo.approved_projects():
name = project.pathlike_name()
specs.append(
ImageSpec(PROJECTS_DIR / name, f"{name}-in-every-language"),
)

return specs


def _program_specs(repo: subete.Repo) -> list[ImageSpec]:
specs = [ImageSpec(SOURCE_DIR, DEFAULT_PROGRAM_IMAGE_NO_EXT)]

for lang in repo:
lang_name = lang.pathlike_name()

for program in repo[str(lang)]:
proj = program.project_pathlike_name()
specs.append(
ImageSpec(
PROGRAMS_DIR / proj / lang_name,
f"{proj}-in-{lang_name}",
),
)

return specs
98 changes: 98 additions & 0 deletions scripts/assets/image_copy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import logging
import shutil
from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path

import subete
from constants import LANGUAGES_DIR, PROGRAMS_DIR, PROJECTS_DIR
from subete import imghdr
from utils.files import mkdir

log = logging.getLogger(__name__)

ASSETS_ROOT = Path("docs/assets/images")


@dataclass(frozen=True)
class CopySpec:
src_dir: Path
dest_dir: Path


def copy_article_images(repo: subete.Repo) -> None:
"""Copy all article images (languages, projects, programs)."""
specs = _build_specs(repo)

with ThreadPoolExecutor(max_workers=8) as ex:
futures = [ex.submit(_copy_images, spec) for spec in specs]

for f in as_completed(futures):
try:
f.result()
except Exception:
log.exception("Failed copying images")


def _build_specs(repo: subete.Repo) -> Iterable[CopySpec]:
yield from _language_specs(repo)
yield from _project_specs(repo)
yield from _program_specs(repo)


def _language_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for lang in repo:
name = lang.pathlike_name()
yield CopySpec(
LANGUAGES_DIR / name,
ASSETS_ROOT / "languages" / name,
)


def _project_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for project in repo.approved_projects():
name = project.pathlike_name()
yield CopySpec(
PROJECTS_DIR / name,
ASSETS_ROOT / "projects" / name,
)


def _program_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for lang in repo:
lang_name = lang.pathlike_name()

for program in repo[str(lang)]:
proj = program.project_pathlike_name()
yield CopySpec(
PROGRAMS_DIR / proj / lang_name,
ASSETS_ROOT / "projects" / proj / lang_name,
)


def _copy_images(spec: CopySpec) -> None:
if not spec.src_dir.is_dir():
return

images = _list_images(spec.src_dir)
if not images:
return

_ = mkdir(spec.dest_dir)

for src in images:
dest = spec.dest_dir / src.name
log.info("Copying %s -> %s", src, dest)
shutil.copy2(src, dest)


def _list_images(src_dir: Path) -> list[Path]:
if not src_dir.is_dir():
return []

return [
p
for p in src_dir.iterdir()
if p.is_file() and p.stem != "featured-image" and imghdr.what(p)
]
Loading
Loading