From 430f07769e40b9ed3cb107a65bc5b589e683b3f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Sun, 17 May 2026 16:08:10 +0300
Subject: [PATCH 01/26] Delete unused migrate script
---
scripts/migrate.py | 78 ----------------------------------------------
1 file changed, 78 deletions(-)
delete mode 100644 scripts/migrate.py
diff --git a/scripts/migrate.py b/scripts/migrate.py
deleted file mode 100644
index 48b8545b91..0000000000
--- a/scripts/migrate.py
+++ /dev/null
@@ -1,78 +0,0 @@
-import os
-from pathlib import Path
-
-
-def project_section(section: str, bound: str):
- for item in os.listdir("archive/projects/"):
- if item != "index.md":
- doc = open(f"archive/projects/{item}/index.md", encoding="utf-8").readlines()
- try:
- start = doc.index(f"## {section}\n")
- end = doc.index(f"## {bound}\n")
- description = "".join(doc[start + 2: end - 1])
- with open(f"sources/projects/{item}/{section.lower()}.md", "w", encoding="utf-8") as desc:
- desc.write(description)
- except ValueError as err:
- print(f"{item} has no {section}")
- generate_front_matter(
- Path(f"archive/projects/{item}/index.md"),
- Path(f"sources/projects/{item}/front_matter.yaml")
- )
-
-
-def language_section(bound: str):
- for item in os.listdir("archive/languages/_posts/"):
- doc = open(f"archive/languages/_posts/{item}", encoding="utf-8").readlines()
- try:
- start = doc.index("---\n", 1)
- end = doc.index(f"## {bound}\n")
- description = "".join(doc[start + 2: end - 1])
- with open(f"sources/languages/{item.split('.')[0].split('-')[-1]}/description.md", "w", encoding="utf-8") as desc:
- desc.write(description)
- except ValueError as err:
- print(f"{item} has no {bound}")
- generate_front_matter(
- Path(f"archive/languages/_posts/{item}"),
- Path(f"sources/languages/{item.split('.')[0].split('-')[-1]}/front_matter.yaml")
- )
-
-
-def program_section(section: str, bound: str):
- for item in os.listdir("archive/projects/"):
- if item != "index.md" and os.path.exists(f"archive/projects/{item}/_posts/"):
- for post in os.listdir(f"archive/projects/{item}/_posts/"):
- doc = open(f"archive/projects/{item}/_posts/{post}", encoding="utf-8").readlines()
- lower_copy = [x.lower().replace("the ", "") for x in doc]
- try:
- start = lower_copy.index(f"## {section.lower().replace('the ', '')}\n")
- end = lower_copy.index(f"## {bound.lower().replace('the ', '')}\n")
- description = "".join(doc[start + 2: end - 1])
- Path(f"sources/programs/{item}/{'-'.join(post.split('.')[0].split('-')[3:])}/").mkdir(parents=True, exist_ok=True)
- with open(f"sources/programs/{item}/{'-'.join(post.split('.')[0].split('-')[3:])}/{section.lower().replace(' ', '-')}.md", "w", encoding="utf-8") as desc:
- desc.write(description)
- except ValueError as err:
- print(f"{item}:{post} has no {section}")
- generate_front_matter(
- Path(f"archive/projects/{item}/_posts/{post}"),
- Path(f"sources/programs/{item}/{'-'.join(post.split('.')[0].split('-')[3:])}/front_matter.yaml")
- )
-
-
-
-def generate_front_matter(input: Path, output: Path):
- with open(input, "r", encoding="utf-8") as f:
- doc = f.readlines()
- start = doc.index("---\n")
- end = doc.index("---\n", start + 1)
- front_matter = "".join(doc[start + 1: end])
- with open(output, "w", encoding="utf-8") as f:
- f.write(front_matter)
-
-
-if __name__ == "__main__":
- project_section("Description", "Requirements")
- project_section("Requirements", "Testing")
- project_section("Testing", "Articles")
- language_section("Articles")
- program_section("How to Implement the Solution", "How to Run the Solution")
- program_section("How to Run the Solution", "Further Reading")
From c95f6febe1afd9c39b7681eef030a40a1cf8b8ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 01:17:09 +0300
Subject: [PATCH 02/26] Add pycache to gitignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index fbd69abb0a..d8f364eef9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@
/docs/Gemfile.lock
generated/
/requirements-dev.txt
+__pycache__
\ No newline at end of file
From 648eba5754b3a3dc4deb33d5ca543f43ff643a64 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 01:18:17 +0300
Subject: [PATCH 03/26] Split automate.py into many files
---
scripts/assets/images.py | 266 ++++++
scripts/automate.py | 1119 +------------------------
scripts/cli.py | 43 +
scripts/constants.py | 9 +
scripts/generators/__init__.py | 0
scripts/generators/languages.py | 232 +++++
scripts/generators/main_page.py | 84 ++
scripts/generators/projects.py | 154 ++++
scripts/generators/sample_programs.py | 152 ++++
scripts/generators/tests.py | 26 +
scripts/logging_setup.py | 8 +
scripts/markdown/__init__.py | 0
scripts/markdown/articles.py | 75 ++
scripts/markdown/authors.py | 10 +
scripts/markdown/front_matter.py | 51 ++
scripts/markdown/note.py | 31 +
scripts/markdown/sections.py | 65 ++
scripts/repo/__init__.py | 0
scripts/repo/queries.py | 12 +
scripts/utils/__init__.py | 0
scripts/utils/files.py | 13 +
scripts/utils/plural.py | 12 +
scripts/utils/text.py | 23 +
23 files changed, 1268 insertions(+), 1117 deletions(-)
create mode 100644 scripts/assets/images.py
create mode 100644 scripts/cli.py
create mode 100644 scripts/constants.py
create mode 100644 scripts/generators/__init__.py
create mode 100644 scripts/generators/languages.py
create mode 100644 scripts/generators/main_page.py
create mode 100644 scripts/generators/projects.py
create mode 100644 scripts/generators/sample_programs.py
create mode 100644 scripts/generators/tests.py
create mode 100644 scripts/logging_setup.py
create mode 100644 scripts/markdown/__init__.py
create mode 100644 scripts/markdown/articles.py
create mode 100644 scripts/markdown/authors.py
create mode 100644 scripts/markdown/front_matter.py
create mode 100644 scripts/markdown/note.py
create mode 100644 scripts/markdown/sections.py
create mode 100644 scripts/repo/__init__.py
create mode 100644 scripts/repo/queries.py
create mode 100644 scripts/utils/__init__.py
create mode 100644 scripts/utils/files.py
create mode 100644 scripts/utils/plural.py
create mode 100644 scripts/utils/text.py
diff --git a/scripts/assets/images.py b/scripts/assets/images.py
new file mode 100644
index 0000000000..35f87deb69
--- /dev/null
+++ b/scripts/assets/images.py
@@ -0,0 +1,266 @@
+import functools
+import logging
+import os
+import pathlib
+import shutil
+import subprocess
+import tempfile
+
+import subete
+from constants import (
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ DEFAULT_PROGRAM_IMAGE_NO_EXT,
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+)
+from subete import imghdr
+
+log = logging.getLogger(__name__)
+
+
+def get_program_image(program: subete.SampleProgram) -> str | None:
+ """Gets the filename of the image for a sample program
+
+ :param subete.SampleProgram program: the sample program to get the image for.
+ :return: Filename of image if found, None otherwise.
+ """
+ project_path = program.project_pathlike_name()
+ language_path = program.language_pathlike_name()
+ image_path = pathlib.Path(f"sources/programs/{project_path}/{language_path}")
+ return get_image(
+ image_path,
+ f"{project_path}-in-{language_path}",
+ get_project_image(program.project()),
+ )
+
+
+@functools.lru_cache
+def get_project_image(project: subete.Project) -> str | None:
+ """Gets the filename of the image for a project
+
+ :param subete.Project project: the project to create the index file
+ for in the normalized form (e.g., hello-world).
+ :return: Filename of image if found, None otherwise.
+ """
+ project_path = project.pathlike_name()
+ image_path = pathlib.Path(f"sources/projects/{project_path}")
+ return get_image(
+ image_path,
+ f"{project_path}-in-every-language",
+ get_default_project_image(),
+ )
+
+
+@functools.lru_cache
+def get_default_project_image() -> str | None:
+ """Gets the filename of the default project image
+
+ :return: Filename of image if found, None otherwise
+ """
+ return get_image(pathlib.Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)
+
+
+@functools.lru_cache
+def get_image(
+ image_path: pathlib.Path,
+ filename_prefix_no_ext: str,
+ default_filename: str | None = None,
+) -> str | None:
+ if image_path.is_dir():
+ path = next(image_path.glob("featured-image.*"), None)
+ if path:
+ return f"{filename_prefix_no_ext}{path.suffix}"
+
+ return default_filename
+
+
+def get_language_image(language: subete.LanguageCollection) -> str | None:
+ """Get image filename for a language
+
+ :param subete.LanguageCollection language: the collection sample programs for a language.
+ :return: Filename of image if found, None otherwise.
+ """
+ language_path = language.pathlike_name()
+ image_path = pathlib.Path(f"sources/languages/{language_path}")
+ return get_image(
+ image_path,
+ f"the-{language_path}-programming-language",
+ get_default_language_image(),
+ )
+
+
+@functools.lru_cache
+def get_default_language_image() -> str | None:
+ """Get default language image filename
+
+ :return: Filename of image if found, None otherwise.
+ """
+ return get_image(pathlib.Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)
+
+
+def copy_article_images(repo: subete.Repo) -> None:
+ """Copy article images to the appropriate directory
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ copy_language_images(repo)
+ copy_project_images(repo)
+ copy_program_images(repo)
+
+
+def copy_language_images(repo: subete.Repo) -> None:
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ copy_image(
+ f"sources/languages/{language_path}",
+ f"docs/assets/images/languages/{language_path}",
+ )
+
+
+def copy_project_images(repo: subete.Repo) -> None:
+ project: subete.Project
+ for project in repo.approved_projects():
+ project_path = project.pathlike_name()
+ copy_image(
+ f"sources/projects/{project_path}",
+ f"docs/assets/images/projects/{project_path}",
+ )
+
+
+def copy_program_images(repo: subete.Repo) -> None:
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ program: subete.SampleProgram
+ for program in repo[str(language)]:
+ project_path = program.project_pathlike_name()
+ copy_image(
+ f"sources/programs/{project_path}/{language_path}",
+ f"docs/assets/images/projects/{project_path}/{language_path}",
+ )
+
+
+def copy_image(src_dir: str, dest_dir: str) -> None:
+ src_dir_path = pathlib.Path(src_dir)
+ dest_dir_path = pathlib.Path(dest_dir)
+ if not src_dir_path.exists():
+ return
+
+ src_image_paths = [
+ path
+ for path in src_dir_path.iterdir()
+ if path.is_file() and path.stem != "featured-image" and imghdr.what(path)
+ ]
+ if src_image_paths:
+ os.makedirs(dest_dir, exist_ok=True)
+ for src_image_path in src_image_paths:
+ dest_image_path = dest_dir_path / src_image_path.name
+ log.info("Copying image %s -> %s", str(src_image_path), str(dest_image_path))
+ shutil.copy(src_image_path, dest_image_path)
+
+
+def generate_images(repo: subete.Repo) -> int:
+ """Use image-titler to resize and crop images and add logo
+
+ :param subete.Repo repo: the repo to pull from.
+ :return: 0 if no error, non-zero otherwise
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ status_code = 0
+ status_code = generate_language_images(repo, temp_dir, status_code)
+ status_code = generate_project_images(repo, temp_dir, status_code)
+ status_code = generate_program_images(repo, temp_dir, status_code)
+ return status_code
+
+
+def generate_language_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
+ status_code = generate_image(
+ temp_dir,
+ "sources/languages",
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ status_code,
+ )
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ status_code = generate_image(
+ temp_dir,
+ f"sources/languages/{language_path}",
+ f"the-{language_path}-programming-language",
+ status_code,
+ )
+
+ return status_code
+
+
+def generate_project_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
+ status_code = generate_image(
+ temp_dir,
+ "sources/projects",
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+ status_code,
+ )
+ for project in repo.approved_projects():
+ project_path = project.pathlike_name()
+ status_code = generate_image(
+ temp_dir,
+ f"sources/projects/{project_path}",
+ f"{project_path}-in-every-language",
+ status_code,
+ )
+
+
+def generate_program_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
+ status_code = generate_image(
+ temp_dir,
+ "sources",
+ DEFAULT_PROGRAM_IMAGE_NO_EXT,
+ status_code,
+ )
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ program: subete.SampleProgram
+ for program in repo[str(language)]:
+ program_path = program.project_pathlike_name()
+ status_code = generate_image(
+ temp_dir,
+ f"sources/programs/{program_path}/{language_path}",
+ f"{program_path}-in-{language_path}",
+ status_code,
+ )
+
+ return status_code
+
+
+def generate_image(temp_dir: str, src: str, dest_filename_no_ext: str, status_code: int) -> int:
+ src_image_path = next(pathlib.Path(src).glob("featured-image.*"), None)
+ if not src_image_path:
+ return status_code
+
+ dest = pathlib.Path("docs/assets/images")
+ logo = str(dest / "icon-small.png")
+
+ dest_image_path = dest / f"{dest_filename_no_ext}{src_image_path.suffix}"
+ log.info("Processing %s -> %s", str(src_image_path), str(dest_image_path))
+ try:
+ subprocess.run(
+ [
+ "image-titler",
+ "--path",
+ str(src_image_path),
+ "--output",
+ temp_dir,
+ "--logo",
+ logo,
+ "--no_title",
+ ],
+ check=True,
+ )
+ temp_image_path = next(pathlib.Path(temp_dir).iterdir())
+ shutil.move(temp_image_path, dest_image_path)
+ except subprocess.CalledProcessError as exc:
+ log.error("image-titler exited with %d status", exc.returncode)
+ status_code = 1
+
+ return status_code
diff --git a/scripts/automate.py b/scripts/automate.py
index 1311dd2aa5..2aa71401cd 100644
--- a/scripts/automate.py
+++ b/scripts/automate.py
@@ -1,1119 +1,4 @@
-from typing import Optional, Iterable, List, Set
-import argparse
-import datetime
-import functools
-import logging
-import os
-import pathlib
-import shutil
-import subprocess
-import tempfile
-import sys
-
-import snakemd
-import subete
-import glotter
-import yaml
-from subete import imghdr
-
-log = logging.getLogger("automate")
-AUTO_GEN_TEST_DOC_DIR = "sources/generated"
-
-DEFAULT_PROGRAM_IMAGE_NO_EXT = "sample-programs-in-every-language"
-DEFAULT_PROJECT_IMAGE_NO_EXT = "programming-projects-in-every-language"
-DEFAULT_LANGUAGE_IMAGE_NO_EXT = "programming-languages"
-
-AUTO_GEN_NOTE = "AUTO-GENERATED -- PLEASE DO NOT EDIT!"
-CONTRIBUTING_NOTE = "See .github/CONTRIBUTING.md for further details."
-PROGRAM_MD_FILENAMES = ["how-to-implement-the-solution.md", "how-to-run-the-solution.md"]
-PROJECT_MD_FILENAMES = ["description.md", "requirements.md"]
-LANGUAGE_MD_FILENAMES = ["description.md"]
-
-
-def _add_section(doc: snakemd.Document, source: str, source_instance: str, section: str, level: int = 2):
- """
- Adds a section to the document.
-
- :param snakemd.Document doc: the document to add the section to.
- :param str source: the specific source folder to pull from (e.g., languages).
- :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
- :param str section: the section to add to the document (e.g., Description).
- """
- doc.add_heading(section, level=level)
- fp = pathlib.Path(
- f"sources/{source}/{source_instance}/{section.lower().replace(' ', '-')}.md")
- if fp.exists():
- log.info(f"Adding {section} section to document from source, {fp}")
- doc.add_raw(fp.read_text(encoding="utf-8"))
- else:
- log.warning(f"Failed to find {section} in {fp}")
- doc.add_paragraph(
- f"No '{section}' section available. Please consider contributing."
- ).insert_link("Please consider contributing", "https://github.com/TheRenegadeCoder/sample-programs-website")
-
-
-def _add_testing_section(doc: snakemd.Document, source: str, source_instance: str):
- valid_path = pathlib.Path(f"sources/{source}/{source_instance}/valid-tests.md")
- invalid_path = pathlib.Path(f"sources/{source}/{source_instance}/invalid-tests.md")
- auto_gen_path = pathlib.Path(f"{AUTO_GEN_TEST_DOC_DIR}/{source_instance}/testing.md")
- if auto_gen_path.exists():
- _add_section(
- doc, pathlib.Path(AUTO_GEN_TEST_DOC_DIR).name, source_instance, "Testing", level=2
- )
- elif valid_path.exists() and invalid_path.exists():
- doc.add_heading("Testing", level=2)
- doc.add_paragraph(
- f"""
- Every project in the Sample Programs repo should be tested. In this section,
- we specify the set of tests specific to {" ".join(source_instance.split('-')).title()}.
- To keep things simple, we split up testing into two subsets: valid and invalid.
- Valid tests refer to tests that occur under correct input conditions. Invalid
- tests refer to tests that occur on bad input (e.g., letters instead of numbers).
- """
- )
- _add_section(doc, source, source_instance, "Valid Tests", level=3)
- _add_section(doc, source, source_instance, "Invalid Tests", level=3)
- else:
- _add_section(doc, source, source_instance, "Testing", level=2)
-
-
-def _add_project_article_section(doc: snakemd.Document, repo: subete.Repo, project: subete.Project):
- """
- Generates a list of articles for each project page.
-
- :param snakemd.Document doc: the document to add the section to.
- :param subete.Repo repo: the repo to pull from.
- :param subete.Project project: the project to add to the document.
- """
- log.info(f"Generating article section of {project}")
- doc.add_heading("Articles", level=2)
- articles = []
- for lang in repo:
- try:
- program: subete.SampleProgram = lang[project.name()]
- except KeyError:
- continue
-
- program_escaped = _markdown_escape(str(program))
- link = snakemd.Inline(
- program_escaped,
- link=program.documentation_url()
- )
- articles.append(link)
-
- num_articles = len(articles)
- if num_articles > 0:
- verb = pluralize(num_articles, "is", "are")
- word = pluralize(num_articles, "article")
- doc.add_paragraph(f"There {verb} {num_articles} {word}:")
- doc.add_block(snakemd.MDList(articles))
- else:
- log.warning(f"Failed to find any articles for {project}")
- doc.add_paragraph(
- f"No articles available. Please consider contributing."
- ).insert_link("Please consider contributing", "https://github.com/TheRenegadeCoder/sample-programs-website")
-
-
-def _add_language_article_section(doc: snakemd.Document, repo: subete.Repo, language: str):
- """
- Generates a list of articles for each language page.
-
- :param snakemd.Document doc: the document to add the section to.
- :param subete.Repo repo: the repo to pull from.
- :param str language: the language to add to the document in its lookup form (e.g., Python).
- """
- doc.add_heading("Articles", level=2)
- num_articles = len(list(repo[language]))
- verb = pluralize(num_articles, "is", "are")
- word = pluralize(num_articles, "article")
- doc.add_paragraph(f"There {verb} {num_articles} {word}:")
-
- articles = []
- for program in repo[language]:
- program_escaped = _markdown_escape(str(program))
- link = snakemd.Inline(
- program_escaped,
- link=program._sample_program_doc_url
- )
- articles.append(link)
- doc.add_block(snakemd.MDList(articles))
-
-
-def _split_text(text: str) -> tuple[str, str]:
- mid = len(text) // 2
- best_index = -1
- min_dist = float('inf')
-
- for i, char in enumerate(text):
- if char.isspace():
- dist = abs(i - mid)
- if dist <= min_dist:
- min_dist = dist
- best_index = i
- elif i > mid:
- # Optimization: if we are past mid and distance is increasing, stop
- break
-
- if best_index == -1:
- return text, ""
-
- return text[:best_index], text[best_index + 1:]
-
-def _generate_front_matter(
- doc: snakemd.Document,
- title: str,
- times: Optional[List[Optional[datetime.datetime]]] = None,
- image: Optional[str] = None,
- authors: Optional[Set[str]] = None,
- tags: Optional[Iterable[str]] = None
-):
- """
- Generates front matter and adds it to the document.
-
- :param snakemd.Document doc: the document to add the front matter to.
- :param str title: the title of the document.
- :param Optional[List[Optional[datetime.datetime]]] times: optional list of
- date/times that may be `None`.
- :param str image: optional filename of the image.
- :param Set[str] authors: optional list of authors
- :param Iterable[str] tags: optional list of tags
- """
-
- top_title, bottom_title = _split_text(title)
-
- front_matter = {
- "title": title,
- "title1": top_title,
- "title2": bottom_title,
- "layout": "default"
- }
-
- filtered_times = list(filter(None, times or []))
- if filtered_times:
- front_matter["date"] = min(filtered_times).date()
- front_matter["last-modified"] = max(filtered_times).date()
-
- if image:
- front_matter["featured-image"] = image
-
- if authors:
- front_matter["authors"] = sorted(authors, key=str.casefold)
-
- if tags:
- front_matter["tags"] = sorted(tags, key=str.casefold)
-
- yaml_block = yaml.safe_dump(front_matter, sort_keys=True, allow_unicode=True)
- doc.add_raw(f"---\n{yaml_block}---")
-
-
-def _generate_no_edit_note(
- doc: snakemd.Document, source: str, source_instance: str, filenames: List[str]
-):
- """
- Generates "DO NOT EDIT" note
-
- :param snakemd.Document doc: the document to add the note to.
- :param str source: the specific source folder to pull from (e.g., languages).
- :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
- :param list[str] filenames: the markdown filenames
- """
-
- note_filenames = "\n".join(
- f"- sources/{source}/{source_instance}/{filename}" for filename in filenames
- )
- note = f"""\
-"""
- doc.add_raw(note)
-
-
-def _generate_sample_program_index(program: subete.SampleProgram, path: pathlib.Path):
- """
- Creates a sample program documentation file.
-
- :param subete.SampleProgram program: the sample program to create the documentation for.
- :param pathlib.Path path: the path to the documentation file.
- """
- doc: snakemd.Document = snakemd.new_doc()
- root_path = pathlib.Path(
- f"programs/{program.project_pathlike_name()}/{program.language_pathlike_name()}"
- )
- authors: Set[str] = program.authors()
- doc_authors: Set[str] = program.doc_authors()
- _generate_front_matter(
- doc,
- str(program),
- times=_get_program_datetimes(program),
- image=_get_program_image(program),
- authors=authors | doc_authors,
- tags=[program.language_pathlike_name(), program.project_pathlike_name()]
- )
- _generate_no_edit_note(doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- PROGRAM_MD_FILENAMES,
- )
-
- project_name = program.project_name()
- language_escaped = _markdown_escape(program.language_name())
- language_docs_url = program.language_collection().lang_docs_url()
- doc.add_block(
- snakemd.Paragraph(
- [
- "Welcome to the ",
- snakemd.Inline(project_name, link=program.project().requirements_url()),
- " in ",
- snakemd.Inline(language_escaped, link=language_docs_url),
- " page! Here, you'll find the source code for this program as well as a description ",
- "of how the program works."
- ]
- )
- )
- doc.add_heading("Current Solution", level=2)
-
- if program.image_type():
- image_dest = path / pathlib.Path(program.project_path()).name
- shutil.copy(program.project_path(), image_dest)
- image_uri = "/" + "/".join(image_dest.parts[1:])
- doc.add_block(
- snakemd.Raw(
- f''' '''
- )
- )
- else:
- doc.add_paragraph("{% raw %}")
- doc.add_code(program.code(), lang=language_escaped.lower().replace(" ", "_"))
- doc.add_paragraph("{% endraw %}")
-
- doc.add_block(
- snakemd.Paragraph(
- [
- f"{project_name} in ",
- snakemd.Inline(language_escaped, link=language_docs_url),
- " was written by:",
- ]
- )
- )
- _add_authors_to_doc(doc, authors)
-
- doc_authors: Set[str] = program.doc_authors()
- if doc_authors:
- doc.add_paragraph("This article was written by:")
- _add_authors_to_doc(doc, doc_authors)
-
- doc.add_paragraph("If you see anything you'd like to change or update, please consider contributing.") \
- .insert_link("please consider contributing", "https://github.com/TheRenegadeCoder/sample-programs")
-
- created_at: datetime.datetime = program.created()
- modified: datetime.datetime = program.modified()
- doc_modified: Optional[datetime.datetime] = program.doc_modified()
- if created_at != modified and doc_modified and doc_modified < modified:
- datetime_format = "%b %d %Y %H:%M:%S"
- doc.add_paragraph(
- "**Note**: The solution shown above is the current solution in the Sample "
- f"Programs repository as of {modified.strftime(datetime_format)}. "
- f"The solution was first committed on {created_at.strftime(datetime_format)}. "
- f"The documentation was last updated on {doc_modified.strftime(datetime_format)}. "
- "As a result, documentation below may be outdated."
- )
-
- _add_section(
- doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- "How to Implement the Solution"
- )
- _add_section(
- doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- "How to Run the Solution"
- )
- try:
- doc.dump("index", directory=str(path))
- except Exception:
- log.exception(f"Failed to write {path}")
-
-
-def _get_program_datetimes(program: subete.SampleProgram) -> List[Optional[datetime.datetime]]:
- """
- Get list of date/times for a sample program.
-
- :param subete.SampleProgram program: Sample program to get date/times for.
- :return: List of date/times for sample program
- """
-
- return [program.created(), program.modified(), program.doc_created(), program.doc_modified()]
-
-
-def _add_authors_to_doc(doc: snakemd.Document, authors: Set[str]):
- """
- Add a sorted list of authors to a document.
-
- :param snakemd.Document doc: the document to add the list of authors to.
- :param authors: List of authors
- """
- doc.add_block(snakemd.MDList(sorted(authors, key=lambda x: x.casefold())))
-
-
-def _get_program_image(program: subete.SampleProgram) -> Optional[str]:
- """
- Gets the filename of the image for a sample program
-
- :param subete.SampleProgram program: the sample program to get the image for.
- :return: Filename of image if found, None otherwise.
- """
- project_path = program.project_pathlike_name()
- language_path = program.language_pathlike_name()
- image_path = pathlib.Path(f"sources/programs/{project_path}/{language_path}")
- return _get_image(
- image_path,
- f"{project_path}-in-{language_path}",
- _get_project_image(program.project())
- )
-
-
-@functools.lru_cache()
-def _get_project_image(project: subete.Project) -> Optional[str]:
- """
- Gets the filename of the image for a project
-
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :return: Filename of image if found, None otherwise.
- """
-
- project_path = project.pathlike_name()
- image_path = pathlib.Path(f"sources/projects/{project_path}")
- return _get_image(
- image_path,
- f"{project_path}-in-every-language",
- _get_default_project_image()
- )
-
-
-@functools.lru_cache()
-def _get_default_project_image() -> Optional[str]:
- """
- Gets the filename of the default project image
-
- :return: Filename of image if found, None otherwise
- """
- return _get_image(pathlib.Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)
-
-
-@functools.lru_cache()
-def _get_image(
- image_path: pathlib.Path, filename_prefix_no_ext: str, default_filename: Optional[str] = None
-) -> str:
- if image_path.is_dir():
- path = next(image_path.glob("featured-image.*"), None)
- if path:
- return f"{filename_prefix_no_ext}{path.suffix}"
-
- return default_filename
-
-
-def _generate_project_index(
- repo: subete.Repo, project: subete.Project, previous: subete.Project, next: subete.Project
-):
- """
- Creates an index file for a single project. The path is assumed
- to be `projects/project/index.md`.
-
- :param subete.Repo repo: the repo to pull from.
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :param subete.Project previous: the previous project alphabetically.
- :param subete.Project next: the next project alphabetically.
- """
- doc: snakemd.Document = snakemd.new_doc()
- project_name: str = project.name()
- times: List[Optional[datetime.datetime]] = [project.doc_created(), project.doc_modified()]
- for language in repo:
- language: subete.Language
- for program in language:
- program: subete.SamplePrograms
- if program.project_name() == project_name:
- times += _get_program_datetimes(program)
-
- _generate_front_matter(
- doc,
- project.name(),
- image=_get_project_image(project),
- times=times,
- tags=[project.pathlike_name()]
- )
- _generate_no_edit_note(doc, "projects", project.pathlike_name(), PROJECT_MD_FILENAMES)
- doc.add_paragraph(
- f"Welcome to the {project.name()} page! Here, you'll find a description "
- f"of the project as well as a list of sample programs "
- f"written in various languages."
- )
- doc_authors: Set[str] = project.doc_authors()
- if doc_authors:
- doc.add_paragraph("This article was written by:")
- _add_authors_to_doc(doc, doc_authors)
-
- _add_section(doc, "projects", project.pathlike_name(), "Description")
- _add_section(doc, "projects", project.pathlike_name(), "Requirements")
- _add_testing_section(doc, "projects", project.pathlike_name())
- if not project.has_testing():
- doc.add_block(snakemd.Paragraph([
- snakemd.Inline("Note:", bold=True),
- f" {project.name()} is not currently tested by Glotter2. Consider contributing!"
- ]))
-
- _add_project_article_section(doc, repo, project)
- doc.add_horizontal_rule()
- doc.add_paragraph("")
- doc.add_paragraph("")
- doc.add_block(snakemd.Paragraph([snakemd.Inline(f"<-- Previous Project ({previous})", link=previous.requirements_url())]))
- doc.add_paragraph("
")
- doc.add_paragraph("")
- doc.add_block(snakemd.Paragraph([snakemd.Inline(f"Next Project ({next}) -->", link=next.requirements_url())]))
- doc.add_paragraph("
")
- doc.add_paragraph(" ")
- doc.dump("index", directory=f"docs/projects/{project.pathlike_name()}")
-
-
-def _generate_language_index(language: subete.LanguageCollection):
- """
- Creates a language file for a single language. The path is assumed
- to be `languages/language/index.md`.
-
- :param subete.LanguageCollection language: the collection sample programs for a language.
- """
- doc: snakemd.Document = snakemd.new_doc()
- times: List[Optional[datetime.datetime]] = []
- for program in language:
- program: subete.SampleProgram
- times += _get_program_datetimes(program)
-
- times += [language.doc_created(), language.doc_modified()]
-
- doc_authors: Set[str] = language.doc_authors()
- language_escaped = _markdown_escape(language.name())
- _generate_front_matter(
- doc,
- f"The {language} Programming Language",
- times=times,
- image=_get_language_image(language),
- authors=doc_authors,
- tags=[language.pathlike_name()]
- )
- _generate_no_edit_note(doc, "languages", language.pathlike_name(), LANGUAGE_MD_FILENAMES)
- doc.add_paragraph(
- f"Welcome to the {language_escaped} page! Here, you'll find a description "
- f"of the language as well as a list of sample programs "
- f"in that language."
- )
- if doc_authors:
- doc.add_paragraph("This article was written by:")
- _add_authors_to_doc(doc, doc_authors)
-
- _add_section(doc, "languages", language.pathlike_name(), "Description")
- _add_language_article_section(doc, repo, str(language))
- try:
- doc.dump("index", directory=f"docs/languages/{language.pathlike_name()}")
- except Exception:
- log.exception(f"Failed to write {language.pathlike_name()}")
-
-
-def _get_language_image(language: subete.LanguageCollection) -> Optional[str]:
- """
- Get image filename for a language
-
- :param subete.LanguageCollection language: the collection sample programs for a language.
- :return: Filename of image if found, None otherwise.
- """
- language_path = language.pathlike_name()
- image_path = pathlib.Path(f"sources/languages/{language_path}")
- return _get_image(
- image_path,
- f"the-{language_path}-programming-language",
- _get_default_language_image()
- )
-
-
-@functools.lru_cache()
-def _get_default_language_image() -> Optional[str]:
- """
- Get default language image filename
-
- :return: Filename of image if found, None otherwise.
- """
-
- return _get_image(pathlib.Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)
-
-
-def generate_main_page(repo: subete.Repo):
- """
- Generate the main page.
-
- :param subete.Repo repo: the repo to pull from.
- """
- authors: Set[str] = set()
- times: List[Optional[datetime.datetime]] = []
- num_articles = 0
- for language in repo:
- language: subete.LanguageCollection
- num_articles += 1 # 1 article per language
- authors |= language.doc_authors()
- times += [language.doc_created(), language.doc_modified()]
- program: subete.SampleProgram
- for program in language:
- authors |= program.authors() | program.doc_authors()
- times += _get_program_datetimes(program)
-
- num_articles += 1 # 1 article per sample program
-
- for project in repo.approved_projects():
- num_articles += 1 # 1 article per approved project
- project: subete.Project
- authors |= project.doc_authors()
- times += [project.doc_created(), project.doc_modified()]
-
- log.info("Generating main page")
- main_page: snakemd.Document = snakemd.new_doc()
- _generate_front_matter(
- main_page,
- "Sample Programs in Every Language",
- times=times,
- )
- main_page.add_paragraph(
- "Welcome to Sample Programs in Every Language, a collection of code snippets "
- "in as many languages as possible. Thanks for taking an interest in our collection "
- f"which currently contains {num_articles} articles written by {len(authors)} authors."
- )
- paragraph = snakemd.Paragraph(
- [
- snakemd.Inline(
- "If you'd like to contribute to this growing collection, check out our "
- ),
- snakemd.Inline(
- "contributing document",
- link="https://github.com/TheRenegadeCoder/sample-programs/blob/master/.github/CONTRIBUTING.md"
- ),
- snakemd.Inline(
- " for more information. In addition, you can explore our documentation which is organized by "
- ),
- snakemd.Inline("project", link="/projects"),
- snakemd.Inline(" and by "),
- snakemd.Inline("language", link="/languages"),
- snakemd.Inline(". If you don't find what you're look for, check out our list of related "),
- snakemd.Inline("open-source projects", link="/related"),
- snakemd.Inline(
- ". Finally, if code isn't your thing but you'd still like to help, there are plenty "
- "of other ways to "
- ),
- snakemd.Inline(
- "support the project",
- link="https://therenegadecoder.com/updates/5-ways-you-can-support-the-renegade-coder/"
- ),
- snakemd.Inline(".")
- ]
- )
- main_page.add_paragraph(str(paragraph))
- try:
- main_page.dump("index", "docs")
- except Exception:
- log.exception("Failed to write docs/index")
-
-
-def generate_project_paths(repo: subete.Repo):
- """
- Creates the project directory which contains all of the project folders
- and index.md files.
-
- :param subete.Repo repo: the repo to pull from.
- """
- projects = repo.approved_projects()
- projects.sort(key=lambda x: x.name().casefold())
- for i, project in enumerate(projects):
- project: subete.Project
- log.info("Generating project paths for %s", str(project))
- path = pathlib.Path(f"docs/projects/{project.pathlike_name()}")
- path.mkdir(exist_ok=True, parents=True)
- _generate_project_index(repo, project, projects[i - 1], projects[(i + 1) % len(projects)])
-
-
-def generate_sample_programs(repo: subete.Repo):
- """
- Creates the language folders in each project directory.
-
- :param subete.Repo repo: the repo to pull from.
- """
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- log.info("Generate sample programs for %s", str(program))
- program: subete.SampleProgram
- path = pathlib.Path(
- f"docs/projects/{program.project_pathlike_name()}/{language.pathlike_name()}"
- )
- path.mkdir(exist_ok=True, parents=True)
- _generate_sample_program_index(program, path)
-
-
-def generate_language_paths(repo: subete.Repo):
- """
- Creates the language directory which contains all of the language folders
- and index.md files.
-
- :param subete.Repo repo: the repo to pull from.
- """
- for language in repo:
- log.info("Generating language paths for %s", str(language))
- language: subete.LanguageCollection
- path = pathlib.Path(f"docs/languages/{language.pathlike_name()}")
- path.mkdir(exist_ok=True, parents=True)
- _generate_language_index(language)
-
-
-def generate_auto_gen_test_docs(repo: subete.Repo):
- """
- Generate auto-generated test documentation
-
- :param subete.Repo repo: the repo to pull from.
- """
- log.info("Generating test documentation")
- curr_dir = os.getcwd()
- doc_dir = pathlib.Path(AUTO_GEN_TEST_DOC_DIR).absolute()
- os.chdir(repo.sample_programs_repo_dir())
- glotter.generate_test_docs(
- doc_dir=doc_dir,
- repo_name="Sample Programs",
- repo_url="https://github.com/TheRenegadeCoder/sample-programs"
- )
- os.chdir(curr_dir)
-
-
-def generate_languages_index(repo: subete.Repo):
- """
- Creates the index.md files for the root directories.
-
- :param subete.Repo repo: the repo to pull from.
- """
- log.info("Generating language index")
- language_index_path = pathlib.Path("docs/languages")
- times: List[Optional[datetime.datetime]] = []
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- program: subete.SampleProgram
- times += _get_program_datetimes(program)
-
- language_index = snakemd.new_doc()
- _generate_front_matter(
- language_index,
- "Programming Languages",
- times=times,
- image=_get_default_language_image()
- )
- num_languages = len(list(repo))
- verb = pluralize(num_languages, "is", "are")
- singular = pluralize(num_languages, "language")
- welcome_text = (
- "Welcome to the Languages page! Here, you'll find a list of all of the languages represented in the collection. "
- f"At this time, there {verb} {num_languages} {singular}, of which {repo.total_tests()} are tested"
- )
- untestables = repo.total_untestables()
- if untestables:
- verb_untestables = pluralize(untestables, "is", "are")
- welcome_text += f", {untestables} {verb_untestables} untestable"
-
- num_programs = repo.total_programs()
- singular = pluralize(num_programs, "snippet")
- welcome_text += f", and {num_programs} code {singular}."
- language_index.add_paragraph(welcome_text)
-
- language_index.add_heading("Language Breakdown", level=2)
- _generate_language_breakdown_percentage(repo, language_index)
-
- language_index.add_heading("Language Collections by Letter", level=2)
- language_index.add_paragraph(
- "To help you navigate the collection, the following languages are organized alphabetically and grouped by first letter. "
- "To go to a particular letter, just click one of the links below."
- )
- language_index.add_raw(_get_language_letter_links(repo))
-
- return_to_top = [
- "« ",
- snakemd.Inline("Return to Top", link="#language-collections-by-letter"),
- " »"
- ]
- language_index.add_block(
- snakemd.Paragraph(["To return here, just click the "] + return_to_top + [" link."])
- )
-
- for letter in repo.sorted_language_letters():
- language_index.add_heading(letter.upper(), level=3)
- languages: list[subete.LanguageCollection] = repo.languages_by_letter(letter)
- snippets = sum(language.total_programs() for language in languages)
- tests = sum(1 if language.has_testinfo()
- else 0 for language in languages)
- untestables = sum(1 if language.has_untestable_info()
- else 0 for language in languages)
- verb = pluralize(tests, "is", "are")
- num_languages = len(languages)
- singular = pluralize(tests, "language")
- verb_untestables = pluralize(untestables, "is", "are")
- language_statement = (
- f"The '{letter.upper()}' collection contains {num_languages} {singular}, "
- f"of which {tests} {verb} tested"
- )
- if untestables:
- language_statement += f", {untestables} {verb_untestables} untestable"
-
- language_index.add_paragraph(f"{language_statement}, and {snippets} code snippets.")
- languages.sort(key=lambda x: x.name().casefold())
- languages_list = [
- _get_language_link_and_testability(x)
- for x in languages
- ]
- language_index.add_block(snakemd.MDList(languages_list))
- language_index.add_block(snakemd.Paragraph(return_to_top))
-
- language_index.dump("index", directory=str(language_index_path))
-
-
-def _get_language_letter_links(repo: subete.Repo) -> str:
- # Have to use raw HTML for this since there is no way to add a class attribute
- # in Markdown
- language_letter_links = [
- '
'
- ] + [
- f' {letter.upper()} '
- for letter in repo.sorted_language_letters()
- ] + [
- " "
- ]
- return "\n".join(language_letter_links)
-
-
-def _get_language_link_and_testability(
- language: subete.LanguageCollection
-) -> snakemd.Paragraph:
- language_escaped = _markdown_escape(language.name())
- language_link = snakemd.Inline(language_escaped, link=language.lang_docs_url())
- num_programs = language.total_programs()
- singular = pluralize(num_programs, "code snippet")
- phrase = f"{num_programs} {singular}"
- if language.has_testinfo():
- return snakemd.Paragraph([language_link, f" ({phrase})"])
-
- if language.has_untestable_info():
- testability = [
- f" ({phrase}, ",
- snakemd.Inline("untestabled", link=language.untestable_info_url()),
- ")"
- ]
- else:
- testability = [snakemd.Inline(f" {phrase}, (untested)")]
-
- return snakemd.Paragraph([language_link] + testability)
-
-
-def _generate_language_breakdown_percentage(repo: subete.Repo, doc: snakemd.Document):
- language_info = sorted(
- ((language.name(), language.percentage(), language.color()) for language in repo),
- key=lambda x: (-x[1], x[0])
- )
- max_language_percentage = language_info[0][1]
-
- doc.add_paragraph("Here are the percentages for each language in the collection:")
- doc.add_raw("""\
-
-Click here to expand or collapse...
-"""
- )
-
- for language_name, percentage, color in language_info:
- bar_graph_width = 100.0 * percentage / max_language_percentage
- bar_graph_style = f"width: {bar_graph_width:.2f}%; background-color: {color};"
- doc.add_raw(f"""\
-
- {language_name}
- {percentage:.2f}%
-
- """
- )
-
- doc.add_raw("""\
-
- """
- )
-
-
-def generate_projects_index(repo: subete.Repo):
- """
- Generate index.md for file for Projects page
-
- :param subete.Repo repo: the repo to pull from.
- """
- log.info("Generating projects index")
- projects_index_path = pathlib.Path("docs/projects")
- projects_index: snakemd.Document = snakemd.new_doc()
- times: List[Optional[datetime.datetime]] = []
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- program: subete.SampleProgram
- times += _get_program_datetimes(program)
-
- _generate_front_matter(
- projects_index,
- "Programming Projects in Every Language",
- times=times,
- image=_get_default_project_image()
- )
- project_tests = sum(
- 1 if project.has_testing() else 0
- for project in repo.approved_projects()
- )
- projects_index.add_paragraph(
- "Welcome to the Projects page! Here, you'll find a list of all of the projects represented in the collection. "
- f"At this time, the repo supports {repo.total_approved_projects()} projects, of which {project_tests} are tested."
- )
- projects_index.add_heading("Projects List", level=2)
- projects_index.add_paragraph(
- "To help you navigate the collection, the following projects are organized alphabetically."
- )
- repo.approved_projects().sort(key=lambda x: x.name().casefold())
- projects = [
- snakemd.Inline(
- project.name(),
- link=project.requirements_url()
- )
- for project in repo.approved_projects()
- ]
- projects_index.add_block(snakemd.MDList(projects))
- projects_index.dump("index", directory=str(projects_index_path))
-
-
-def copy_article_images(repo: subete.Repo):
- """
- Copy article images to the appropriate directory
-
- :param subete.Repo repo: the repo to pull from.
- """
- _copy_language_images(repo)
- _copy_project_images(repo)
- _copy_program_images(repo)
-
-
-def _copy_language_images(repo: subete.Repo):
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- _copy_image(
- f"sources/languages/{language_path}",
- f"docs/assets/images/languages/{language_path}"
- )
-
-
-def _copy_project_images(repo: subete.Repo):
- project: subete.Project
- for project in repo.approved_projects():
- project_path = project.pathlike_name()
- _copy_image(
- f"sources/projects/{project_path}",
- f"docs/assets/images/projects/{project_path}"
- )
-
-
-def _copy_program_images(repo: subete.Repo):
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- project_path = program.project_pathlike_name()
- _copy_image(
- f"sources/programs/{project_path}/{language_path}",
- f"docs/assets/images/projects/{project_path}/{language_path}"
- )
-
-
-def _copy_image(src_dir: str, dest_dir: str):
- src_dir_path = pathlib.Path(src_dir)
- dest_dir_path = pathlib.Path(dest_dir)
- if not src_dir_path.exists():
- return
-
- src_image_paths = [
- path
- for path in src_dir_path.iterdir()
- if path.is_file() and path.stem != "featured-image" and imghdr.what(path)
- ]
- if src_image_paths:
- os.makedirs(dest_dir, exist_ok=True)
- for src_image_path in src_image_paths:
- dest_image_path = dest_dir_path / src_image_path.name
- log.info("Copying image %s -> %s", str(src_image_path), str(dest_image_path))
- shutil.copy(src_image_path, dest_image_path)
-
-
-def generate_images(repo: subete.Repo) -> int:
- """
- Use image-titler to resize and crop images and add logo
-
- :param subete.Repo repo: the repo to pull from.
- :return: 0 if no error, non-zero otherwise
- """
-
- with tempfile.TemporaryDirectory() as temp_dir:
- status_code = 0
- status_code = _generate_language_images(repo, temp_dir, status_code)
- status_code = _generate_project_images(repo, temp_dir, status_code)
- status_code = _generate_program_images(repo, temp_dir, status_code)
- return status_code
-
-
-def _generate_language_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = _generate_image(
- temp_dir, "sources/languages", DEFAULT_LANGUAGE_IMAGE_NO_EXT, status_code
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- status_code = _generate_image(
- temp_dir, f"sources/languages/{language_path}",
- f"the-{language_path}-programming-language", status_code
- )
-
- return status_code
-
-
-def _generate_project_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = _generate_image(
- temp_dir, "sources/projects", DEFAULT_PROJECT_IMAGE_NO_EXT, status_code
- )
- for project in repo.approved_projects():
- project_path = project.pathlike_name()
- status_code = _generate_image(
- temp_dir,
- f"sources/projects/{project_path}",
- f"{project_path}-in-every-language",
- status_code
- )
-
-
-def _generate_program_images(repo:subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = _generate_image(
- temp_dir, "sources", DEFAULT_PROGRAM_IMAGE_NO_EXT, status_code
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- program_path = program.project_pathlike_name()
- status_code = _generate_image(
- temp_dir,
- f"sources/programs/{program_path}/{language_path}",
- f"{program_path}-in-{language_path}",
- status_code
- )
-
- return status_code
-
-
-def _generate_image(temp_dir: str, src: str, dest_filename_no_ext: str, status_code: int) -> int:
- src_image_path = next(pathlib.Path(src).glob("featured-image.*"), None)
- if not src_image_path:
- return status_code
-
- dest = pathlib.Path("docs/assets/images")
- logo = str(dest / "icon-small.png")
-
- dest_image_path = dest / f"{dest_filename_no_ext}{src_image_path.suffix}"
- log.info("Processing %s -> %s", str(src_image_path), str(dest_image_path))
- try:
- subprocess.run(
- [
- "image-titler",
- "--path", str(src_image_path),
- "--output", temp_dir,
- "--logo", logo,
- "--no_title"
- ],
- check=True
- )
- temp_image_path = next(pathlib.Path(temp_dir).iterdir())
- shutil.move(temp_image_path, dest_image_path)
- except subprocess.CalledProcessError as exc:
- log.error("image-titler exited with %d status", exc.returncode)
- status_code = 1
-
- return status_code
-
-
-def clean(folder: str):
- """
- Deletes the contents of the docs directory.
- """
- path = pathlib.Path(folder)
- if path.exists():
- for child in path.glob('*'):
- if child.is_file():
- child.unlink()
- else:
- clean(child)
- path.rmdir()
-
-
-def pluralize(count: int, singular: str, plural: Optional[str]=None):
- """
- Pluralize an item
-
- :param count: Count of number of items
- :param singular: Singular form of item
- :param plural: Plural form of item. If None, use singular plus an "s"
- :return: Pluralized item
- """
-
- if plural is None:
- plural = f"{singular}s"
-
- return singular if count == 1 else plural
-
-
-def _markdown_escape(s: str) -> str:
- return s.replace("*", r"\*")
-
+from cli import main
if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("--local", "-l", action="store_true", help="Use local contents of website")
- parsed_args = parser.parse_args()
-
- logging.basicConfig(format="%(name)-12s | %(levelname)-8s | %(message)s", level=logging.INFO)
- clean("docs/projects")
- clean("docs/languages")
- clean(AUTO_GEN_TEST_DOC_DIR)
-
- subete.repo.logger.setLevel(logging.WARNING) # Reduce the noise of subete
- log.info("Loading repos (this may take several minutes)")
- website_repo_dir = "." if parsed_args.local else None
- repo = subete.load(sample_programs_website_repo_dir=website_repo_dir)
- repo.set_additional_language_colors("additional-language-colors.yml")
-
- generate_main_page(repo)
- generate_language_paths(repo)
- generate_auto_gen_test_docs(repo)
- generate_project_paths(repo)
- generate_sample_programs(repo)
- generate_languages_index(repo)
- generate_projects_index(repo)
- copy_article_images(repo)
- status_code = generate_images(repo)
- sys.exit(status_code)
+ main()
diff --git a/scripts/cli.py b/scripts/cli.py
new file mode 100644
index 0000000000..a6cf97d6c1
--- /dev/null
+++ b/scripts/cli.py
@@ -0,0 +1,43 @@
+import argparse
+import logging
+import sys
+
+import subete
+from assets.images import copy_article_images, generate_images
+from constants import AUTO_GEN_TEST_DOC_DIR
+from generators.languages import generate_language_paths, generate_languages_index
+from generators.main_page import generate_main_page
+from generators.projects import generate_project_paths, generate_projects_index
+from generators.sample_programs import generate_sample_programs
+from generators.tests import generate_auto_gen_test_docs
+from logging_setup import setup_logging
+from utils.files import clean
+
+log = logging.getLogger(__name__)
+
+
+def main() -> None:
+ setup_logging()
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--local", "-l", action="store_true", help="Use local contents of website")
+ parsed_args = parser.parse_args()
+
+ clean("docs/projects")
+ clean("docs/languages")
+ clean(AUTO_GEN_TEST_DOC_DIR)
+
+ log.info("Loading repos (this may take several minutes)")
+ website_repo_dir = "." if parsed_args.local else None
+ repo = subete.load(sample_programs_website_repo_dir=website_repo_dir)
+ repo.set_additional_language_colors("additional-language-colors.yml")
+
+ generate_main_page(repo)
+ generate_language_paths(repo)
+ generate_auto_gen_test_docs(repo)
+ generate_project_paths(repo)
+ generate_sample_programs(repo)
+ generate_languages_index(repo)
+ generate_projects_index(repo)
+ copy_article_images(repo)
+ status_code = generate_images(repo)
+ sys.exit(status_code)
diff --git a/scripts/constants.py b/scripts/constants.py
new file mode 100644
index 0000000000..cd76a86b01
--- /dev/null
+++ b/scripts/constants.py
@@ -0,0 +1,9 @@
+AUTO_GEN_TEST_DOC_DIR = "sources/generated"
+DEFAULT_PROGRAM_IMAGE_NO_EXT = "sample-programs-in-every-language"
+DEFAULT_PROJECT_IMAGE_NO_EXT = "programming-projects-in-every-language"
+DEFAULT_LANGUAGE_IMAGE_NO_EXT = "programming-languages"
+AUTO_GEN_NOTE = "AUTO-GENERATED -- PLEASE DO NOT EDIT!"
+CONTRIBUTING_NOTE = "See .github/CONTRIBUTING.md for further details."
+PROGRAM_MD_FILENAMES = ["how-to-implement-the-solution.md", "how-to-run-the-solution.md"]
+PROJECT_MD_FILENAMES = ["description.md", "requirements.md"]
+LANGUAGE_MD_FILENAMES = ["description.md"]
diff --git a/scripts/generators/__init__.py b/scripts/generators/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
new file mode 100644
index 0000000000..266ca07cfb
--- /dev/null
+++ b/scripts/generators/languages.py
@@ -0,0 +1,232 @@
+import datetime
+import logging
+from pathlib import Path
+
+import snakemd
+import subete
+from assets.images import get_default_language_image, get_language_image
+from constants import LANGUAGE_MD_FILENAMES
+from markdown.articles import add_language_article_section
+from markdown.authors import add_authors_to_doc
+from markdown.front_matter import generate_front_matter
+from markdown.note import generate_no_edit_note
+from markdown.sections import add_section
+from repo.queries import get_program_datetimes
+from utils.plural import pluralize
+from utils.text import markdown_escape
+
+log = logging.getLogger(__name__)
+
+
+def generate_language_index(repo: subete.Repo, language: subete.LanguageCollection) -> None:
+ """Creates a language file for a single language. The path is assumed
+ to be `languages/language/index.md`.
+
+ :param subete.LanguageCollection language: the collection sample programs for a language.
+ """
+ doc: snakemd.Document = snakemd.new_doc()
+ times: list[datetime.datetime | None] = []
+ for program in language:
+ program: subete.SampleProgram
+ times += get_program_datetimes(program)
+
+ times += [language.doc_created(), language.doc_modified()]
+
+ doc_authors: set[str] = language.doc_authors()
+ language_escaped = markdown_escape(language.name())
+ generate_front_matter(
+ doc,
+ f"The {language} Programming Language",
+ times=times,
+ image=get_language_image(language),
+ authors=doc_authors,
+ tags=[language.pathlike_name()],
+ )
+ generate_no_edit_note(doc, "languages", language.pathlike_name(), LANGUAGE_MD_FILENAMES)
+ doc.add_paragraph(
+ f"Welcome to the {language_escaped} page! Here, you'll find a description "
+ f"of the language as well as a list of sample programs "
+ f"in that language.",
+ )
+ if doc_authors:
+ doc.add_paragraph("This article was written by:")
+ add_authors_to_doc(doc, doc_authors)
+
+ add_section(doc, "languages", language.pathlike_name(), "Description")
+ add_language_article_section(doc, repo, str(language))
+ try:
+ doc.dump("index", directory=f"docs/languages/{language.pathlike_name()}")
+ except Exception:
+ log.exception(f"Failed to write {language.pathlike_name()}")
+
+
+def generate_language_paths(repo: subete.Repo) -> None:
+ """Creates the language directory which contains all of the language folders
+ and index.md files.
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ for language in repo:
+ log.info("Generating language paths for %s", str(language))
+ language: subete.LanguageCollection
+ path = Path(f"docs/languages/{language.pathlike_name()}")
+ path.mkdir(exist_ok=True, parents=True)
+ generate_language_index(repo, language)
+
+
+def get_language_letter_links(repo: subete.Repo) -> str:
+ # Have to use raw HTML for this since there is no way to add a class attribute
+ # in Markdown
+ language_letter_links = (
+ [
+ '',
+ ]
+ + [
+ f' {letter.upper()} '
+ for letter in repo.sorted_language_letters()
+ ]
+ + [
+ " ",
+ ]
+ )
+ return "\n".join(language_letter_links)
+
+
+def generate_languages_index(repo: subete.Repo) -> None:
+ """Creates the index.md files for the root directories.
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ log.info("Generating language index")
+ language_index_path = Path("docs/languages")
+ times: list[datetime.datetime | None] = []
+ for language in repo:
+ language: subete.LanguageCollection
+ for program in language:
+ program: subete.SampleProgram
+ times += get_program_datetimes(program)
+
+ language_index = snakemd.new_doc()
+ generate_front_matter(
+ language_index,
+ "Programming Languages",
+ times=times,
+ image=get_default_language_image(),
+ )
+ num_languages = len(list(repo))
+ verb = pluralize(num_languages, "is", "are")
+ singular = pluralize(num_languages, "language")
+ welcome_text = (
+ "Welcome to the Languages page! Here, you'll find a list of all of the languages represented in the collection. "
+ f"At this time, there {verb} {num_languages} {singular}, of which {repo.total_tests()} are tested"
+ )
+ untestables = repo.total_untestables()
+ if untestables:
+ verb_untestables = pluralize(untestables, "is", "are")
+ welcome_text += f", {untestables} {verb_untestables} untestable"
+
+ num_programs = repo.total_programs()
+ singular = pluralize(num_programs, "snippet")
+ welcome_text += f", and {num_programs} code {singular}."
+ language_index.add_paragraph(welcome_text)
+
+ language_index.add_heading("Language Breakdown", level=2)
+ generate_language_breakdown_percentage(repo, language_index)
+
+ language_index.add_heading("Language Collections by Letter", level=2)
+ language_index.add_paragraph(
+ "To help you navigate the collection, the following languages are organized alphabetically and grouped by first letter. "
+ "To go to a particular letter, just click one of the links below.",
+ )
+ language_index.add_raw(get_language_letter_links(repo))
+
+ return_to_top = [
+ "« ",
+ snakemd.Inline("Return to Top", link="#language-collections-by-letter"),
+ " »",
+ ]
+ language_index.add_block(
+ snakemd.Paragraph(["To return here, just click the "] + return_to_top + [" link."]),
+ )
+
+ for letter in repo.sorted_language_letters():
+ language_index.add_heading(letter.upper(), level=3)
+ languages: list[subete.LanguageCollection] = repo.languages_by_letter(letter)
+ snippets = sum(language.total_programs() for language in languages)
+ tests = sum(1 if language.has_testinfo() else 0 for language in languages)
+ untestables = sum(1 if language.has_untestable_info() else 0 for language in languages)
+ verb = pluralize(tests, "is", "are")
+ num_languages = len(languages)
+ singular = pluralize(tests, "language")
+ verb_untestables = pluralize(untestables, "is", "are")
+ language_statement = (
+ f"The '{letter.upper()}' collection contains {num_languages} {singular}, "
+ f"of which {tests} {verb} tested"
+ )
+ if untestables:
+ language_statement += f", {untestables} {verb_untestables} untestable"
+
+ language_index.add_paragraph(f"{language_statement}, and {snippets} code snippets.")
+ languages.sort(key=lambda x: x.name().casefold())
+ languages_list = [get_language_link_and_testability(x) for x in languages]
+ language_index.add_block(snakemd.MDList(languages_list))
+ language_index.add_block(snakemd.Paragraph(return_to_top))
+
+ language_index.dump("index", directory=str(language_index_path))
+
+
+def get_language_link_and_testability(
+ language: subete.LanguageCollection,
+) -> snakemd.Paragraph:
+ language_escaped = markdown_escape(language.name())
+ language_link = snakemd.Inline(language_escaped, link=language.lang_docs_url())
+ num_programs = language.total_programs()
+ singular = pluralize(num_programs, "code snippet")
+ phrase = f"{num_programs} {singular}"
+ if language.has_testinfo():
+ return snakemd.Paragraph([language_link, f" ({phrase})"])
+
+ if language.has_untestable_info():
+ testability = [
+ f" ({phrase}, ",
+ snakemd.Inline("untestabled", link=language.untestable_info_url()),
+ ")",
+ ]
+ else:
+ testability = [snakemd.Inline(f" {phrase}, (untested)")]
+
+ return snakemd.Paragraph([language_link] + testability)
+
+
+def generate_language_breakdown_percentage(repo: subete.Repo, doc: snakemd.Document):
+ language_info = sorted(
+ ((language.name(), language.percentage(), language.color()) for language in repo),
+ key=lambda x: (-x[1], x[0]),
+ )
+ max_language_percentage = language_info[0][1]
+
+ doc.add_paragraph("Here are the percentages for each language in the collection:")
+ doc.add_raw(
+ """\
+
+Click here to expand or collapse...
+""",
+ )
+
+ for language_name, percentage, color in language_info:
+ bar_graph_width = 100.0 * percentage / max_language_percentage
+ bar_graph_style = f"width: {bar_graph_width:.2f}%; background-color: {color};"
+ doc.add_raw(
+ f"""\
+
+ {language_name}
+ {percentage:.2f}%
+
+ """,
+ )
+
+ doc.add_raw(
+ """\
+
+ """,
+ )
diff --git a/scripts/generators/main_page.py b/scripts/generators/main_page.py
new file mode 100644
index 0000000000..b5259cb882
--- /dev/null
+++ b/scripts/generators/main_page.py
@@ -0,0 +1,84 @@
+import datetime
+import logging
+
+import snakemd
+import subete
+from markdown.front_matter import generate_front_matter
+from repo.queries import get_program_datetimes
+
+log = logging.getLogger(__name__)
+
+
+def generate_main_page(repo: subete.Repo) -> None:
+ """Generate the main page.
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ authors: set[str] = set()
+ times: list[datetime.datetime | None] = []
+ num_articles = 0
+ for language in repo:
+ language: subete.LanguageCollection
+ num_articles += 1 # 1 article per language
+ authors |= language.doc_authors()
+ times += [language.doc_created(), language.doc_modified()]
+ program: subete.SampleProgram
+ for program in language:
+ authors |= program.authors() | program.doc_authors()
+ times += get_program_datetimes(program)
+
+ num_articles += 1 # 1 article per sample program
+
+ for project in repo.approved_projects():
+ num_articles += 1 # 1 article per approved project
+ project: subete.Project
+ authors |= project.doc_authors()
+ times += [project.doc_created(), project.doc_modified()]
+
+ log.info("Generating main page")
+ main_page: snakemd.Document = snakemd.new_doc()
+ generate_front_matter(
+ main_page,
+ "Sample Programs in Every Language",
+ times=times,
+ )
+ main_page.add_paragraph(
+ "Welcome to Sample Programs in Every Language, a collection of code snippets "
+ "in as many languages as possible. Thanks for taking an interest in our collection "
+ f"which currently contains {num_articles} articles written by {len(authors)} authors.",
+ )
+ paragraph = snakemd.Paragraph(
+ [
+ snakemd.Inline(
+ "If you'd like to contribute to this growing collection, check out our ",
+ ),
+ snakemd.Inline(
+ "contributing document",
+ link="https://github.com/TheRenegadeCoder/sample-programs/blob/master/.github/CONTRIBUTING.md",
+ ),
+ snakemd.Inline(
+ " for more information. In addition, you can explore our documentation which is organized by ",
+ ),
+ snakemd.Inline("project", link="/projects"),
+ snakemd.Inline(" and by "),
+ snakemd.Inline("language", link="/languages"),
+ snakemd.Inline(
+ ". If you don't find what you're look for, check out our list of related ",
+ ),
+ snakemd.Inline("open-source projects", link="/related"),
+ snakemd.Inline(
+ ". Finally, if code isn't your thing but you'd still like to help, there are plenty "
+ "of other ways to ",
+ ),
+ snakemd.Inline(
+ "support the project",
+ link="https://therenegadecoder.com/updates/5-ways-you-can-support-the-renegade-coder/",
+ ),
+ snakemd.Inline("."),
+ ],
+ )
+ main_page.add_paragraph(str(paragraph))
+ try:
+ main_page.dump("index", "docs")
+ except Exception:
+ log.exception("Failed to write docs/index")
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
new file mode 100644
index 0000000000..24993a4479
--- /dev/null
+++ b/scripts/generators/projects.py
@@ -0,0 +1,154 @@
+import datetime
+import logging
+from pathlib import Path
+
+import snakemd
+import subete
+from assets.images import get_default_project_image, get_project_image
+from constants import PROJECT_MD_FILENAMES
+from markdown.articles import add_project_article_section
+from markdown.authors import add_authors_to_doc
+from markdown.front_matter import generate_front_matter
+from markdown.note import generate_no_edit_note
+from markdown.sections import add_section, add_testing_section
+from repo.queries import get_program_datetimes
+
+log = logging.getLogger(__name__)
+
+
+def generate_project_paths(repo: subete.Repo) -> None:
+ """Creates the project directory which contains all of the project folders
+ and index.md files.
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ projects = repo.approved_projects()
+ projects.sort(key=lambda x: x.name().casefold())
+ for i, project in enumerate(projects):
+ project: subete.Project
+ log.info("Generating project paths for %s", str(project))
+ path = Path(f"docs/projects/{project.pathlike_name()}")
+ path.mkdir(exist_ok=True, parents=True)
+ generate_project_index(repo, project, projects[i - 1], projects[(i + 1) % len(projects)])
+
+
+def generate_project_index(
+ repo: subete.Repo,
+ project: subete.Project,
+ previous: subete.Project,
+ next: subete.Project,
+) -> None:
+ """Creates an index file for a single project. The path is assumed
+ to be `projects/project/index.md`.
+
+ :param subete.Repo repo: the repo to pull from.
+ :param subete.Project project: the project to create the index file
+ for in the normalized form (e.g., hello-world).
+ :param subete.Project previous: the previous project alphabetically.
+ :param subete.Project next: the next project alphabetically.
+ """
+ doc: snakemd.Document = snakemd.new_doc()
+ project_name: str = project.name()
+ times: list[datetime.datetime | None] = [project.doc_created(), project.doc_modified()]
+ for language in repo:
+ for program in language:
+ if program.project_name() == project_name:
+ times += get_program_datetimes(program)
+
+ generate_front_matter(
+ doc,
+ project.name(),
+ image=get_project_image(project),
+ times=times,
+ tags=[project.pathlike_name()],
+ )
+ generate_no_edit_note(doc, "projects", project.pathlike_name(), PROJECT_MD_FILENAMES)
+ doc.add_paragraph(
+ f"Welcome to the {project.name()} page! Here, you'll find a description "
+ f"of the project as well as a list of sample programs "
+ f"written in various languages.",
+ )
+ doc_authors: set[str] = project.doc_authors()
+ if doc_authors:
+ doc.add_paragraph("This article was written by:")
+ add_authors_to_doc(doc, doc_authors)
+
+ add_section(doc, "projects", project.pathlike_name(), "Description")
+ add_section(doc, "projects", project.pathlike_name(), "Requirements")
+ add_testing_section(doc, "projects", project.pathlike_name())
+ if not project.has_testing():
+ doc.add_block(
+ snakemd.Paragraph(
+ [
+ snakemd.Inline("Note:", bold=True),
+ f" {project.name()} is not currently tested by Glotter2. Consider contributing!",
+ ],
+ ),
+ )
+
+ add_project_article_section(doc, repo, project)
+ doc.add_horizontal_rule()
+ doc.add_paragraph('')
+ doc.add_paragraph('')
+ doc.add_block(
+ snakemd.Paragraph(
+ [
+ snakemd.Inline(
+ f"<-- Previous Project ({previous})",
+ link=previous.requirements_url(),
+ ),
+ ],
+ ),
+ )
+ doc.add_paragraph("
")
+ doc.add_paragraph('')
+ doc.add_block(
+ snakemd.Paragraph(
+ [snakemd.Inline(f"Next Project ({next}) -->", link=next.requirements_url())],
+ ),
+ )
+ doc.add_paragraph("
")
+ doc.add_paragraph(" ")
+ doc.dump("index", directory=f"docs/projects/{project.pathlike_name()}")
+
+
+def generate_projects_index(repo: subete.Repo) -> None:
+ """Generate index.md for file for Projects page
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ log.info("Generating projects index")
+ projects_index_path = Path("docs/projects")
+ projects_index: snakemd.Document = snakemd.new_doc()
+ times: list[datetime.datetime | None] = []
+ for language in repo:
+ language: subete.LanguageCollection
+ for program in language:
+ program: subete.SampleProgram
+ times += get_program_datetimes(program)
+
+ generate_front_matter(
+ projects_index,
+ "Programming Projects in Every Language",
+ times=times,
+ image=get_default_project_image(),
+ )
+ project_tests = sum(1 if project.has_testing() else 0 for project in repo.approved_projects())
+ projects_index.add_paragraph(
+ "Welcome to the Projects page! Here, you'll find a list of all of the projects represented in the collection. "
+ f"At this time, the repo supports {repo.total_approved_projects()} projects, of which {project_tests} are tested.",
+ )
+ projects_index.add_heading("Projects List", level=2)
+ projects_index.add_paragraph(
+ "To help you navigate the collection, the following projects are organized alphabetically.",
+ )
+ repo.approved_projects().sort(key=lambda x: x.name().casefold())
+ projects = [
+ snakemd.Inline(
+ project.name(),
+ link=project.requirements_url(),
+ )
+ for project in repo.approved_projects()
+ ]
+ projects_index.add_block(snakemd.MDList(projects))
+ projects_index.dump("index", directory=str(projects_index_path))
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
new file mode 100644
index 0000000000..4c82fbb62c
--- /dev/null
+++ b/scripts/generators/sample_programs.py
@@ -0,0 +1,152 @@
+import datetime
+import logging
+import shutil
+from pathlib import Path
+
+import snakemd
+import subete
+from assets.images import get_program_image
+from constants import PROGRAM_MD_FILENAMES
+from markdown.authors import add_authors_to_doc
+from markdown.front_matter import generate_front_matter
+from markdown.note import generate_no_edit_note
+from markdown.sections import add_section
+from repo.queries import get_program_datetimes
+from utils.text import markdown_escape
+
+log = logging.getLogger(__name__)
+
+
+def generate_sample_programs(repo: subete.Repo) -> None:
+ """Creates the language folders in each project directory.
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ for language in repo:
+ language: subete.LanguageCollection
+ for program in language:
+ log.info("Generate sample programs for %s", str(program))
+ program: subete.SampleProgram
+ path = Path(
+ f"docs/projects/{program.project_pathlike_name()}/{language.pathlike_name()}",
+ )
+ path.mkdir(exist_ok=True, parents=True)
+ generate_sample_program_index(program, path)
+
+
+def generate_sample_program_index(program: subete.SampleProgram, path: Path) -> None:
+ """Creates a sample program documentation file.
+
+ :param subete.SampleProgram program: the sample program to create the documentation for.
+ :param pathlib.Path path: the path to the documentation file.
+ """
+ doc: snakemd.Document = snakemd.new_doc()
+ root_path = Path(
+ f"programs/{program.project_pathlike_name()}/{program.language_pathlike_name()}",
+ )
+ authors: set[str] = program.authors()
+ doc_authors: set[str] = program.doc_authors()
+ generate_front_matter(
+ doc,
+ str(program),
+ times=get_program_datetimes(program),
+ image=get_program_image(program),
+ authors=authors | doc_authors,
+ tags=[program.language_pathlike_name(), program.project_pathlike_name()],
+ )
+ generate_no_edit_note(
+ doc,
+ str(root_path.parent),
+ program.language_pathlike_name(),
+ PROGRAM_MD_FILENAMES,
+ )
+
+ project_name = program.project_name()
+ language_escaped = markdown_escape(program.language_name())
+ language_docs_url = program.language_collection().lang_docs_url()
+ doc.add_block(
+ snakemd.Paragraph(
+ [
+ "Welcome to the ",
+ snakemd.Inline(project_name, link=program.project().requirements_url()),
+ " in ",
+ snakemd.Inline(language_escaped, link=language_docs_url),
+ " page! Here, you'll find the source code for this program as well as a description ",
+ "of how the program works.",
+ ],
+ ),
+ )
+ doc.add_heading("Current Solution", level=2)
+
+ if program.image_type():
+ image_dest = path / Path(program.project_path()).name
+ shutil.copy(program.project_path(), image_dest)
+ image_uri = "/" + "/".join(image_dest.parts[1:])
+ doc.add_block(
+ snakemd.Raw(
+ f""" """,
+ ),
+ )
+ else:
+ doc.add_paragraph("{% raw %}")
+ doc.add_code(program.code(), lang=language_escaped.lower().replace(" ", "_"))
+ doc.add_paragraph("{% endraw %}")
+
+ doc.add_block(
+ snakemd.Paragraph(
+ [
+ f"{project_name} in ",
+ snakemd.Inline(language_escaped, link=language_docs_url),
+ " was written by:",
+ ],
+ ),
+ )
+ add_authors_to_doc(doc, authors)
+
+ doc_authors: set[str] = program.doc_authors()
+ if doc_authors:
+ doc.add_paragraph("This article was written by:")
+ add_authors_to_doc(doc, doc_authors)
+
+ doc.add_paragraph(
+ "If you see anything you'd like to change or update, please consider contributing.",
+ ).insert_link(
+ "please consider contributing",
+ "https://github.com/TheRenegadeCoder/sample-programs",
+ )
+
+ created_at: datetime.datetime | None = program.created()
+ modified: datetime.datetime | None = program.modified()
+ doc_modified: datetime.datetime | None = program.doc_modified()
+ if (
+ created_at
+ and modified
+ and created_at != modified
+ and doc_modified
+ and doc_modified < modified
+ ):
+ datetime_format = "%b %d %Y %H:%M:%S"
+ doc.add_paragraph(
+ "**Note**: The solution shown above is the current solution in the Sample "
+ f"Programs repository as of {modified.strftime(datetime_format)}. "
+ f"The solution was first committed on {created_at.strftime(datetime_format)}. "
+ f"The documentation was last updated on {doc_modified.strftime(datetime_format)}. "
+ "As a result, documentation below may be outdated.",
+ )
+
+ add_section(
+ doc,
+ str(root_path.parent),
+ program.language_pathlike_name(),
+ "How to Implement the Solution",
+ )
+ add_section(
+ doc,
+ str(root_path.parent),
+ program.language_pathlike_name(),
+ "How to Run the Solution",
+ )
+ try:
+ doc.dump("index", directory=str(path))
+ except Exception:
+ log.exception(f"Failed to write {path}")
diff --git a/scripts/generators/tests.py b/scripts/generators/tests.py
new file mode 100644
index 0000000000..8ddaa68297
--- /dev/null
+++ b/scripts/generators/tests.py
@@ -0,0 +1,26 @@
+import logging
+import os
+from pathlib import Path
+
+import glotter
+import subete
+from constants import AUTO_GEN_TEST_DOC_DIR
+
+log = logging.getLogger(__name__)
+
+
+def generate_auto_gen_test_docs(repo: subete.Repo) -> None:
+ """Generate auto-generated test documentation
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ log.info("Generating test documentation")
+ curr_dir = os.getcwd()
+ doc_dir = Path(AUTO_GEN_TEST_DOC_DIR).absolute()
+ os.chdir(repo.sample_programs_repo_dir())
+ glotter.generate_test_docs(
+ doc_dir=doc_dir,
+ repo_name="Sample Programs",
+ repo_url="https://github.com/TheRenegadeCoder/sample-programs",
+ )
+ os.chdir(curr_dir)
diff --git a/scripts/logging_setup.py b/scripts/logging_setup.py
new file mode 100644
index 0000000000..3a63092f29
--- /dev/null
+++ b/scripts/logging_setup.py
@@ -0,0 +1,8 @@
+import logging
+
+import subete
+
+
+def setup_logging() -> None:
+ logging.basicConfig(format="%(name)-12s | %(levelname)-8s | %(message)s", level=logging.INFO)
+ subete.repo.logger.setLevel(logging.WARNING) # Reduce the noise of subete
diff --git a/scripts/markdown/__init__.py b/scripts/markdown/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/scripts/markdown/articles.py b/scripts/markdown/articles.py
new file mode 100644
index 0000000000..38c3cd4ed8
--- /dev/null
+++ b/scripts/markdown/articles.py
@@ -0,0 +1,75 @@
+import logging
+
+import snakemd
+import subete
+from utils.plural import pluralize
+from utils.text import markdown_escape
+
+log = logging.getLogger(__name__)
+
+
+def add_project_article_section(
+ doc: snakemd.Document,
+ repo: subete.Repo,
+ project: subete.Project,
+) -> None:
+ """Generates a list of articles for each project page.
+
+ :param snakemd.Document doc: the document to add the section to.
+ :param subete.Repo repo: the repo to pull from.
+ :param subete.Project project: the project to add to the document.
+ """
+ log.info(f"Generating article section of {project}")
+ doc.add_heading("Articles", level=2)
+ articles = []
+ for lang in repo:
+ try:
+ program: subete.SampleProgram = lang[project.name()]
+ except KeyError:
+ continue
+
+ program_escaped = markdown_escape(str(program))
+ link = snakemd.Inline(
+ program_escaped,
+ link=program.documentation_url(),
+ )
+ articles.append(link)
+
+ num_articles = len(articles)
+ if num_articles > 0:
+ verb = pluralize(num_articles, "is", "are")
+ word = pluralize(num_articles, "article")
+ doc.add_paragraph(f"There {verb} {num_articles} {word}:")
+ doc.add_block(snakemd.MDList(articles))
+ else:
+ log.warning(f"Failed to find any articles for {project}")
+ doc.add_paragraph(
+ "No articles available. Please consider contributing.",
+ ).insert_link(
+ "Please consider contributing",
+ "https://github.com/TheRenegadeCoder/sample-programs-website",
+ )
+
+
+def add_language_article_section(doc: snakemd.Document, repo: subete.Repo, language: str) -> None:
+ """Generates a list of articles for each language page.
+
+ :param snakemd.Document doc: the document to add the section to.
+ :param subete.Repo repo: the repo to pull from.
+ :param str language: the language to add to the document in its lookup form (e.g., Python).
+ """
+ doc.add_heading("Articles", level=2)
+ num_articles = len(list(repo[language]))
+ verb = pluralize(num_articles, "is", "are")
+ word = pluralize(num_articles, "article")
+ doc.add_paragraph(f"There {verb} {num_articles} {word}:")
+
+ articles = []
+ for program in repo[language]:
+ program_escaped = markdown_escape(str(program))
+ link = snakemd.Inline(
+ program_escaped,
+ link=program._sample_program_doc_url,
+ )
+ articles.append(link)
+ doc.add_block(snakemd.MDList(articles))
diff --git a/scripts/markdown/authors.py b/scripts/markdown/authors.py
new file mode 100644
index 0000000000..61a1c81982
--- /dev/null
+++ b/scripts/markdown/authors.py
@@ -0,0 +1,10 @@
+import snakemd
+
+
+def add_authors_to_doc(doc: snakemd.Document, authors: set[str]) -> None:
+ """Add a sorted list of authors to a document.
+
+ :param snakemd.Document doc: the document to add the list of authors to.
+ :param authors: List of authors
+ """
+ doc.add_block(snakemd.MDList(sorted(authors, key=str.casefold)))
diff --git a/scripts/markdown/front_matter.py b/scripts/markdown/front_matter.py
new file mode 100644
index 0000000000..ff5b71af22
--- /dev/null
+++ b/scripts/markdown/front_matter.py
@@ -0,0 +1,51 @@
+import datetime
+from collections.abc import Iterable
+
+import snakemd
+import yaml
+from utils.text import split_text
+
+
+def generate_front_matter(
+ doc: snakemd.Document,
+ title: str,
+ times: list[datetime.datetime | None] | None = None,
+ image: str | None = None,
+ authors: set[str] | None = None,
+ tags: Iterable[str] | None = None,
+) -> None:
+ """Generates front matter and adds it to the document.
+
+ :param snakemd.Document doc: the document to add the front matter to.
+ :param str title: the title of the document.
+ :param Optional[List[Optional[datetime.datetime]]] times: optional list of
+ date/times that may be `None`.
+ :param str image: optional filename of the image.
+ :param Set[str] authors: optional list of authors
+ :param Iterable[str] tags: optional list of tags
+ """
+ top_title, bottom_title = split_text(title)
+
+ front_matter: dict[str, object] = {
+ "title": title,
+ "title1": top_title,
+ "title2": bottom_title,
+ "layout": "default",
+ }
+
+ filtered_times = list(filter(None, times or []))
+ if filtered_times:
+ front_matter["date"] = min(filtered_times).date()
+ front_matter["last-modified"] = max(filtered_times).date()
+
+ if image:
+ front_matter["featured-image"] = image
+
+ if authors:
+ front_matter["authors"] = sorted(authors, key=str.casefold)
+
+ if tags:
+ front_matter["tags"] = sorted(tags, key=str.casefold)
+
+ yaml_block = yaml.safe_dump(front_matter, sort_keys=True, allow_unicode=True)
+ doc.add_raw(f"---\n{yaml_block}---")
diff --git a/scripts/markdown/note.py b/scripts/markdown/note.py
new file mode 100644
index 0000000000..7db1253c2d
--- /dev/null
+++ b/scripts/markdown/note.py
@@ -0,0 +1,31 @@
+import snakemd
+from constants import AUTO_GEN_NOTE, CONTRIBUTING_NOTE
+
+
+def generate_no_edit_note(
+ doc: snakemd.Document,
+ source: str,
+ source_instance: str,
+ filenames: list[str],
+) -> None:
+ """Generates "DO NOT EDIT" note
+
+ :param snakemd.Document doc: the document to add the note to.
+ :param str source: the specific source folder to pull from (e.g., languages).
+ :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
+ :param list[str] filenames: the markdown filenames
+ """
+ note_filenames = "\n".join(
+ f"- sources/{source}/{source_instance}/{filename}" for filename in filenames
+ )
+ note = f"""\
+"""
+ doc.add_raw(note)
diff --git a/scripts/markdown/sections.py b/scripts/markdown/sections.py
new file mode 100644
index 0000000000..d9882182bd
--- /dev/null
+++ b/scripts/markdown/sections.py
@@ -0,0 +1,65 @@
+import logging
+from pathlib import Path
+
+import snakemd
+from constants import *
+
+log = logging.getLogger(__name__)
+
+
+def add_section(
+ doc: snakemd.Document,
+ source: str,
+ source_instance: str,
+ section: str,
+ level: int = 2,
+) -> None:
+ """Adds a section to the document.
+
+ :param snakemd.Document doc: the document to add the section to.
+ :param str source: the specific source folder to pull from (e.g., languages).
+ :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
+ :param str section: the section to add to the document (e.g., Description).
+ """
+ doc.add_heading(section, level=level)
+ fp = Path(f"sources/{source}/{source_instance}/{section.lower().replace(' ', '-')}.md")
+ if fp.exists():
+ log.info(f"Adding {section} section to document from source, {fp}")
+ doc.add_raw(fp.read_text(encoding="utf-8"))
+ else:
+ log.warning(f"Failed to find {section} in {fp}")
+ doc.add_paragraph(
+ f"No '{section}' section available. Please consider contributing.",
+ ).insert_link(
+ "Please consider contributing",
+ "https://github.com/TheRenegadeCoder/sample-programs-website",
+ )
+
+
+def add_testing_section(doc: snakemd.Document, source: str, source_instance: str) -> None:
+ valid_path = Path(f"sources/{source}/{source_instance}/valid-tests.md")
+ invalid_path = Path(f"sources/{source}/{source_instance}/invalid-tests.md")
+ auto_gen_path = Path(f"{AUTO_GEN_TEST_DOC_DIR}/{source_instance}/testing.md")
+ if auto_gen_path.exists():
+ add_section(
+ doc,
+ Path(AUTO_GEN_TEST_DOC_DIR).name,
+ source_instance,
+ "Testing",
+ level=2,
+ )
+ elif valid_path.exists() and invalid_path.exists():
+ doc.add_heading("Testing", level=2)
+ doc.add_paragraph(
+ f"""
+ Every project in the Sample Programs repo should be tested. In this section,
+ we specify the set of tests specific to {" ".join(source_instance.split("-")).title()}.
+ To keep things simple, we split up testing into two subsets: valid and invalid.
+ Valid tests refer to tests that occur under correct input conditions. Invalid
+ tests refer to tests that occur on bad input (e.g., letters instead of numbers).
+ """,
+ )
+ add_section(doc, source, source_instance, "Valid Tests", level=3)
+ add_section(doc, source, source_instance, "Invalid Tests", level=3)
+ else:
+ add_section(doc, source, source_instance, "Testing", level=2)
diff --git a/scripts/repo/__init__.py b/scripts/repo/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/scripts/repo/queries.py b/scripts/repo/queries.py
new file mode 100644
index 0000000000..e73ecc0701
--- /dev/null
+++ b/scripts/repo/queries.py
@@ -0,0 +1,12 @@
+import datetime
+
+import subete
+
+
+def get_program_datetimes(program: subete.SampleProgram) -> list[datetime.datetime | None]:
+ """Get list of date/times for a sample program.
+
+ :param subete.SampleProgram program: Sample program to get date/times for.
+ :return: List of date/times for sample program
+ """
+ return [program.created(), program.modified(), program.doc_created(), program.doc_modified()]
diff --git a/scripts/utils/__init__.py b/scripts/utils/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/scripts/utils/files.py b/scripts/utils/files.py
new file mode 100644
index 0000000000..c0f3d6ac9c
--- /dev/null
+++ b/scripts/utils/files.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+
+
+def clean(folder: str | Path) -> None:
+ """Deletes the contents of the docs directory."""
+ path = Path(folder)
+ if path.exists():
+ for child in path.glob("*"):
+ if child.is_file():
+ child.unlink()
+ else:
+ clean(child)
+ path.rmdir()
diff --git a/scripts/utils/plural.py b/scripts/utils/plural.py
new file mode 100644
index 0000000000..3e6c51ba45
--- /dev/null
+++ b/scripts/utils/plural.py
@@ -0,0 +1,12 @@
+def pluralize(count: int, singular: str, plural: str | None = None) -> str:
+ """Pluralize an item
+
+ :param count: Count of number of items
+ :param singular: Singular form of item
+ :param plural: Plural form of item. If None, use singular plus an "s"
+ :return: Pluralized item
+ """
+ if plural is None:
+ plural = f"{singular}s"
+
+ return singular if count == 1 else plural
diff --git a/scripts/utils/text.py b/scripts/utils/text.py
new file mode 100644
index 0000000000..825c144972
--- /dev/null
+++ b/scripts/utils/text.py
@@ -0,0 +1,23 @@
+def markdown_escape(s: str) -> str:
+ return s.replace("*", r"\*")
+
+
+def split_text(text: str) -> tuple[str, str]:
+ mid = len(text) // 2
+ best_index = -1
+ min_dist = float("inf")
+
+ for i, char in enumerate(text):
+ if char.isspace():
+ dist = abs(i - mid)
+ if dist <= min_dist:
+ min_dist = dist
+ best_index = i
+ elif i > mid:
+ # Optimization: if we are past mid and distance is increasing, stop
+ break
+
+ if best_index == -1:
+ return text, ""
+
+ return text[:best_index], text[best_index + 1 :]
From b943a92e89baec341c9e7ba263fcd3d7476c35ec Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 01:26:48 +0300
Subject: [PATCH 04/26] Remove wildcard import from sections.py
---
scripts/markdown/sections.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/scripts/markdown/sections.py b/scripts/markdown/sections.py
index d9882182bd..cffed851ed 100644
--- a/scripts/markdown/sections.py
+++ b/scripts/markdown/sections.py
@@ -2,7 +2,7 @@
from pathlib import Path
import snakemd
-from constants import *
+from constants import AUTO_GEN_TEST_DOC_DIR
log = logging.getLogger(__name__)
From a665f10f41ba8d0c916e3487821ba9da6efc8e04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 01:27:05 +0300
Subject: [PATCH 05/26] Split images.py into multiple files
---
scripts/assets/image_build.py | 123 ++++++++++++
scripts/assets/image_copy.py | 71 +++++++
scripts/assets/image_lookup.py | 85 ++++++++
scripts/assets/images.py | 266 --------------------------
scripts/cli.py | 3 +-
scripts/generators/languages.py | 6 +-
scripts/generators/projects.py | 6 +-
scripts/generators/sample_programs.py | 4 +-
8 files changed, 289 insertions(+), 275 deletions(-)
create mode 100644 scripts/assets/image_build.py
create mode 100644 scripts/assets/image_copy.py
create mode 100644 scripts/assets/image_lookup.py
delete mode 100644 scripts/assets/images.py
diff --git a/scripts/assets/image_build.py b/scripts/assets/image_build.py
new file mode 100644
index 0000000000..f6ac551efe
--- /dev/null
+++ b/scripts/assets/image_build.py
@@ -0,0 +1,123 @@
+import logging
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+import subete
+from constants import (
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ DEFAULT_PROGRAM_IMAGE_NO_EXT,
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+)
+
+log = logging.getLogger(__name__)
+
+
+def generate_images(repo: subete.Repo) -> int:
+ """Use image-titler to resize and crop images and add logo
+
+ :param subete.Repo repo: the repo to pull from.
+ :return: 0 if no error, non-zero otherwise
+ """
+ with tempfile.TemporaryDirectory() as temp_dir:
+ status_code = 0
+ status_code = generate_language_images(repo, temp_dir, status_code)
+ status_code = generate_project_images(repo, temp_dir, status_code)
+ status_code = generate_program_images(repo, temp_dir, status_code)
+ return status_code
+
+
+def generate_language_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
+ status_code = generate_image(
+ temp_dir,
+ "sources/languages",
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ status_code,
+ )
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ status_code = generate_image(
+ temp_dir,
+ f"sources/languages/{language_path}",
+ f"the-{language_path}-programming-language",
+ status_code,
+ )
+
+ return status_code
+
+
+def generate_project_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
+ status_code = generate_image(
+ temp_dir,
+ "sources/projects",
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+ status_code,
+ )
+ for project in repo.approved_projects():
+ project_path = project.pathlike_name()
+ status_code = generate_image(
+ temp_dir,
+ f"sources/projects/{project_path}",
+ f"{project_path}-in-every-language",
+ status_code,
+ )
+
+ return status_code
+
+
+def generate_program_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
+ status_code = generate_image(
+ temp_dir,
+ "sources",
+ DEFAULT_PROGRAM_IMAGE_NO_EXT,
+ status_code,
+ )
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ program: subete.SampleProgram
+ for program in repo[str(language)]:
+ program_path = program.project_pathlike_name()
+ status_code = generate_image(
+ temp_dir,
+ f"sources/programs/{program_path}/{language_path}",
+ f"{program_path}-in-{language_path}",
+ status_code,
+ )
+
+ return status_code
+
+
+def generate_image(temp_dir: str, src: str, dest_filename_no_ext: str, status_code: int) -> int:
+ src_image_path = next(Path(src).glob("featured-image.*"), None)
+ if not src_image_path:
+ return status_code
+
+ dest = Path("docs/assets/images")
+ logo = str(dest / "icon-small.png")
+
+ dest_image_path = dest / f"{dest_filename_no_ext}{src_image_path.suffix}"
+ log.info("Processing %s -> %s", str(src_image_path), str(dest_image_path))
+ try:
+ subprocess.run(
+ [
+ "image-titler",
+ "--path",
+ str(src_image_path),
+ "--output",
+ temp_dir,
+ "--logo",
+ logo,
+ "--no_title",
+ ],
+ check=True,
+ )
+ temp_image_path = next(Path(temp_dir).iterdir())
+ shutil.move(temp_image_path, dest_image_path)
+ except subprocess.CalledProcessError as exc:
+ log.error("image-titler exited with %d status", exc.returncode)
+ status_code = 1
+
+ return status_code
diff --git a/scripts/assets/image_copy.py b/scripts/assets/image_copy.py
new file mode 100644
index 0000000000..33bf8f8f98
--- /dev/null
+++ b/scripts/assets/image_copy.py
@@ -0,0 +1,71 @@
+import logging
+import os
+import shutil
+from pathlib import Path
+
+import subete
+from subete import imghdr
+
+log = logging.getLogger(__name__)
+
+
+def copy_article_images(repo: subete.Repo) -> None:
+ """Copy article images to the appropriate directory
+
+ :param subete.Repo repo: the repo to pull from.
+ """
+ copy_language_images(repo)
+ copy_project_images(repo)
+ copy_program_images(repo)
+
+
+def copy_language_images(repo: subete.Repo) -> None:
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ copy_image(
+ f"sources/languages/{language_path}",
+ f"docs/assets/images/languages/{language_path}",
+ )
+
+
+def copy_project_images(repo: subete.Repo) -> None:
+ project: subete.Project
+ for project in repo.approved_projects():
+ project_path = project.pathlike_name()
+ copy_image(
+ f"sources/projects/{project_path}",
+ f"docs/assets/images/projects/{project_path}",
+ )
+
+
+def copy_program_images(repo: subete.Repo) -> None:
+ language: subete.LanguageCollection
+ for language in repo:
+ language_path = language.pathlike_name()
+ program: subete.SampleProgram
+ for program in repo[str(language)]:
+ project_path = program.project_pathlike_name()
+ copy_image(
+ f"sources/programs/{project_path}/{language_path}",
+ f"docs/assets/images/projects/{project_path}/{language_path}",
+ )
+
+
+def copy_image(src_dir: str, dest_dir: str) -> None:
+ src_dir_path = Path(src_dir)
+ dest_dir_path = Path(dest_dir)
+ if not src_dir_path.exists():
+ return
+
+ src_image_paths = [
+ path
+ for path in src_dir_path.iterdir()
+ if path.is_file() and path.stem != "featured-image" and imghdr.what(path)
+ ]
+ if src_image_paths:
+ os.makedirs(dest_dir, exist_ok=True)
+ for src_image_path in src_image_paths:
+ dest_image_path = dest_dir_path / src_image_path.name
+ log.info("Copying image %s -> %s", str(src_image_path), str(dest_image_path))
+ shutil.copy(src_image_path, dest_image_path)
diff --git a/scripts/assets/image_lookup.py b/scripts/assets/image_lookup.py
new file mode 100644
index 0000000000..0b51a8ca9d
--- /dev/null
+++ b/scripts/assets/image_lookup.py
@@ -0,0 +1,85 @@
+import functools
+from pathlib import Path
+
+import subete
+from constants import DEFAULT_LANGUAGE_IMAGE_NO_EXT, DEFAULT_PROJECT_IMAGE_NO_EXT
+
+
+def find_program_image(program: subete.SampleProgram) -> str | None:
+ """Gets the filename of the image for a sample program
+
+ :param subete.SampleProgram program: the sample program to get the image for.
+ :return: Filename of image if found, None otherwise.
+ """
+ project_path = program.project_pathlike_name()
+ language_path = program.language_pathlike_name()
+ image_path = Path(f"sources/programs/{project_path}/{language_path}")
+ return find_image(
+ image_path,
+ f"{project_path}-in-{language_path}",
+ find_project_image(program.project()),
+ )
+
+
+@functools.lru_cache
+def find_project_image(project: subete.Project) -> str | None:
+ """Gets the filename of the image for a project
+
+ :param subete.Project project: the project to create the index file
+ for in the normalized form (e.g., hello-world).
+ :return: Filename of image if found, None otherwise.
+ """
+ project_path = project.pathlike_name()
+ image_path = Path(f"sources/projects/{project_path}")
+ return find_image(
+ image_path,
+ f"{project_path}-in-every-language",
+ find_default_project_image(),
+ )
+
+
+@functools.lru_cache
+def find_default_project_image() -> str | None:
+ """Gets the filename of the default project image
+
+ :return: Filename of image if found, None otherwise
+ """
+ return find_image(Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)
+
+
+@functools.lru_cache
+def find_image(
+ image_path: Path,
+ filename_prefix_no_ext: str,
+ default_filename: str | None = None,
+) -> str | None:
+ if image_path.is_dir():
+ path = next(image_path.glob("featured-image.*"), None)
+ if path:
+ return f"{filename_prefix_no_ext}{path.suffix}"
+
+ return default_filename
+
+
+def find_language_image(language: subete.LanguageCollection) -> str | None:
+ """Get image filename for a language
+
+ :param subete.LanguageCollection language: the collection sample programs for a language.
+ :return: Filename of image if found, None otherwise.
+ """
+ language_path = language.pathlike_name()
+ image_path = Path(f"sources/languages/{language_path}")
+ return find_image(
+ image_path,
+ f"the-{language_path}-programming-language",
+ find_default_language_image(),
+ )
+
+
+@functools.lru_cache
+def find_default_language_image() -> str | None:
+ """Get default language image filename
+
+ :return: Filename of image if found, None otherwise.
+ """
+ return find_image(Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)
diff --git a/scripts/assets/images.py b/scripts/assets/images.py
deleted file mode 100644
index 35f87deb69..0000000000
--- a/scripts/assets/images.py
+++ /dev/null
@@ -1,266 +0,0 @@
-import functools
-import logging
-import os
-import pathlib
-import shutil
-import subprocess
-import tempfile
-
-import subete
-from constants import (
- DEFAULT_LANGUAGE_IMAGE_NO_EXT,
- DEFAULT_PROGRAM_IMAGE_NO_EXT,
- DEFAULT_PROJECT_IMAGE_NO_EXT,
-)
-from subete import imghdr
-
-log = logging.getLogger(__name__)
-
-
-def get_program_image(program: subete.SampleProgram) -> str | None:
- """Gets the filename of the image for a sample program
-
- :param subete.SampleProgram program: the sample program to get the image for.
- :return: Filename of image if found, None otherwise.
- """
- project_path = program.project_pathlike_name()
- language_path = program.language_pathlike_name()
- image_path = pathlib.Path(f"sources/programs/{project_path}/{language_path}")
- return get_image(
- image_path,
- f"{project_path}-in-{language_path}",
- get_project_image(program.project()),
- )
-
-
-@functools.lru_cache
-def get_project_image(project: subete.Project) -> str | None:
- """Gets the filename of the image for a project
-
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :return: Filename of image if found, None otherwise.
- """
- project_path = project.pathlike_name()
- image_path = pathlib.Path(f"sources/projects/{project_path}")
- return get_image(
- image_path,
- f"{project_path}-in-every-language",
- get_default_project_image(),
- )
-
-
-@functools.lru_cache
-def get_default_project_image() -> str | None:
- """Gets the filename of the default project image
-
- :return: Filename of image if found, None otherwise
- """
- return get_image(pathlib.Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)
-
-
-@functools.lru_cache
-def get_image(
- image_path: pathlib.Path,
- filename_prefix_no_ext: str,
- default_filename: str | None = None,
-) -> str | None:
- if image_path.is_dir():
- path = next(image_path.glob("featured-image.*"), None)
- if path:
- return f"{filename_prefix_no_ext}{path.suffix}"
-
- return default_filename
-
-
-def get_language_image(language: subete.LanguageCollection) -> str | None:
- """Get image filename for a language
-
- :param subete.LanguageCollection language: the collection sample programs for a language.
- :return: Filename of image if found, None otherwise.
- """
- language_path = language.pathlike_name()
- image_path = pathlib.Path(f"sources/languages/{language_path}")
- return get_image(
- image_path,
- f"the-{language_path}-programming-language",
- get_default_language_image(),
- )
-
-
-@functools.lru_cache
-def get_default_language_image() -> str | None:
- """Get default language image filename
-
- :return: Filename of image if found, None otherwise.
- """
- return get_image(pathlib.Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)
-
-
-def copy_article_images(repo: subete.Repo) -> None:
- """Copy article images to the appropriate directory
-
- :param subete.Repo repo: the repo to pull from.
- """
- copy_language_images(repo)
- copy_project_images(repo)
- copy_program_images(repo)
-
-
-def copy_language_images(repo: subete.Repo) -> None:
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- copy_image(
- f"sources/languages/{language_path}",
- f"docs/assets/images/languages/{language_path}",
- )
-
-
-def copy_project_images(repo: subete.Repo) -> None:
- project: subete.Project
- for project in repo.approved_projects():
- project_path = project.pathlike_name()
- copy_image(
- f"sources/projects/{project_path}",
- f"docs/assets/images/projects/{project_path}",
- )
-
-
-def copy_program_images(repo: subete.Repo) -> None:
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- project_path = program.project_pathlike_name()
- copy_image(
- f"sources/programs/{project_path}/{language_path}",
- f"docs/assets/images/projects/{project_path}/{language_path}",
- )
-
-
-def copy_image(src_dir: str, dest_dir: str) -> None:
- src_dir_path = pathlib.Path(src_dir)
- dest_dir_path = pathlib.Path(dest_dir)
- if not src_dir_path.exists():
- return
-
- src_image_paths = [
- path
- for path in src_dir_path.iterdir()
- if path.is_file() and path.stem != "featured-image" and imghdr.what(path)
- ]
- if src_image_paths:
- os.makedirs(dest_dir, exist_ok=True)
- for src_image_path in src_image_paths:
- dest_image_path = dest_dir_path / src_image_path.name
- log.info("Copying image %s -> %s", str(src_image_path), str(dest_image_path))
- shutil.copy(src_image_path, dest_image_path)
-
-
-def generate_images(repo: subete.Repo) -> int:
- """Use image-titler to resize and crop images and add logo
-
- :param subete.Repo repo: the repo to pull from.
- :return: 0 if no error, non-zero otherwise
- """
- with tempfile.TemporaryDirectory() as temp_dir:
- status_code = 0
- status_code = generate_language_images(repo, temp_dir, status_code)
- status_code = generate_project_images(repo, temp_dir, status_code)
- status_code = generate_program_images(repo, temp_dir, status_code)
- return status_code
-
-
-def generate_language_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = generate_image(
- temp_dir,
- "sources/languages",
- DEFAULT_LANGUAGE_IMAGE_NO_EXT,
- status_code,
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- status_code = generate_image(
- temp_dir,
- f"sources/languages/{language_path}",
- f"the-{language_path}-programming-language",
- status_code,
- )
-
- return status_code
-
-
-def generate_project_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = generate_image(
- temp_dir,
- "sources/projects",
- DEFAULT_PROJECT_IMAGE_NO_EXT,
- status_code,
- )
- for project in repo.approved_projects():
- project_path = project.pathlike_name()
- status_code = generate_image(
- temp_dir,
- f"sources/projects/{project_path}",
- f"{project_path}-in-every-language",
- status_code,
- )
-
-
-def generate_program_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = generate_image(
- temp_dir,
- "sources",
- DEFAULT_PROGRAM_IMAGE_NO_EXT,
- status_code,
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- program_path = program.project_pathlike_name()
- status_code = generate_image(
- temp_dir,
- f"sources/programs/{program_path}/{language_path}",
- f"{program_path}-in-{language_path}",
- status_code,
- )
-
- return status_code
-
-
-def generate_image(temp_dir: str, src: str, dest_filename_no_ext: str, status_code: int) -> int:
- src_image_path = next(pathlib.Path(src).glob("featured-image.*"), None)
- if not src_image_path:
- return status_code
-
- dest = pathlib.Path("docs/assets/images")
- logo = str(dest / "icon-small.png")
-
- dest_image_path = dest / f"{dest_filename_no_ext}{src_image_path.suffix}"
- log.info("Processing %s -> %s", str(src_image_path), str(dest_image_path))
- try:
- subprocess.run(
- [
- "image-titler",
- "--path",
- str(src_image_path),
- "--output",
- temp_dir,
- "--logo",
- logo,
- "--no_title",
- ],
- check=True,
- )
- temp_image_path = next(pathlib.Path(temp_dir).iterdir())
- shutil.move(temp_image_path, dest_image_path)
- except subprocess.CalledProcessError as exc:
- log.error("image-titler exited with %d status", exc.returncode)
- status_code = 1
-
- return status_code
diff --git a/scripts/cli.py b/scripts/cli.py
index a6cf97d6c1..9bd60665f9 100644
--- a/scripts/cli.py
+++ b/scripts/cli.py
@@ -3,7 +3,8 @@
import sys
import subete
-from assets.images import copy_article_images, generate_images
+from assets.image_build import generate_images
+from assets.image_copy import copy_article_images
from constants import AUTO_GEN_TEST_DOC_DIR
from generators.languages import generate_language_paths, generate_languages_index
from generators.main_page import generate_main_page
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index 266ca07cfb..eafdea237c 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -4,7 +4,7 @@
import snakemd
import subete
-from assets.images import get_default_language_image, get_language_image
+from assets.image_lookup import find_default_language_image, find_language_image
from constants import LANGUAGE_MD_FILENAMES
from markdown.articles import add_language_article_section
from markdown.authors import add_authors_to_doc
@@ -38,7 +38,7 @@ def generate_language_index(repo: subete.Repo, language: subete.LanguageCollecti
doc,
f"The {language} Programming Language",
times=times,
- image=get_language_image(language),
+ image=find_language_image(language),
authors=doc_authors,
tags=[language.pathlike_name()],
)
@@ -111,7 +111,7 @@ def generate_languages_index(repo: subete.Repo) -> None:
language_index,
"Programming Languages",
times=times,
- image=get_default_language_image(),
+ image=find_default_language_image(),
)
num_languages = len(list(repo))
verb = pluralize(num_languages, "is", "are")
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
index 24993a4479..4800f7898f 100644
--- a/scripts/generators/projects.py
+++ b/scripts/generators/projects.py
@@ -4,7 +4,7 @@
import snakemd
import subete
-from assets.images import get_default_project_image, get_project_image
+from assets.image_lookup import find_default_project_image, find_project_image
from constants import PROJECT_MD_FILENAMES
from markdown.articles import add_project_article_section
from markdown.authors import add_authors_to_doc
@@ -58,7 +58,7 @@ def generate_project_index(
generate_front_matter(
doc,
project.name(),
- image=get_project_image(project),
+ image=find_project_image(project),
times=times,
tags=[project.pathlike_name()],
)
@@ -131,7 +131,7 @@ def generate_projects_index(repo: subete.Repo) -> None:
projects_index,
"Programming Projects in Every Language",
times=times,
- image=get_default_project_image(),
+ image=find_default_project_image(),
)
project_tests = sum(1 if project.has_testing() else 0 for project in repo.approved_projects())
projects_index.add_paragraph(
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
index 4c82fbb62c..033eb3d427 100644
--- a/scripts/generators/sample_programs.py
+++ b/scripts/generators/sample_programs.py
@@ -5,7 +5,7 @@
import snakemd
import subete
-from assets.images import get_program_image
+from assets.image_lookup import find_program_image
from constants import PROGRAM_MD_FILENAMES
from markdown.authors import add_authors_to_doc
from markdown.front_matter import generate_front_matter
@@ -50,7 +50,7 @@ def generate_sample_program_index(program: subete.SampleProgram, path: Path) ->
doc,
str(program),
times=get_program_datetimes(program),
- image=get_program_image(program),
+ image=find_program_image(program),
authors=authors | doc_authors,
tags=[program.language_pathlike_name(), program.project_pathlike_name()],
)
From b6e02faf69bad05d2d8cac5a0dbdaa696b633ff8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 01:42:09 +0300
Subject: [PATCH 06/26] Improve logging
---
scripts/cli.py | 2 +-
scripts/logging_setup.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/cli.py b/scripts/cli.py
index 9bd60665f9..c075c11449 100644
--- a/scripts/cli.py
+++ b/scripts/cli.py
@@ -14,7 +14,7 @@
from logging_setup import setup_logging
from utils.files import clean
-log = logging.getLogger(__name__)
+log = logging.getLogger("automate")
def main() -> None:
diff --git a/scripts/logging_setup.py b/scripts/logging_setup.py
index 3a63092f29..93869838ff 100644
--- a/scripts/logging_setup.py
+++ b/scripts/logging_setup.py
@@ -4,5 +4,5 @@
def setup_logging() -> None:
- logging.basicConfig(format="%(name)-12s | %(levelname)-8s | %(message)s", level=logging.INFO)
+ logging.basicConfig(format="%(name)-20s | %(levelname)-8s | %(message)s", level=logging.INFO)
subete.repo.logger.setLevel(logging.WARNING) # Reduce the noise of subete
From d9c2363bbbfc9b7e4a6e7f991e136bfdffd9d65e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 01:49:26 +0300
Subject: [PATCH 07/26] Add more utils.plural helpers and better docstrings
---
scripts/generators/languages.py | 10 +++----
scripts/markdown/articles.py | 6 ++---
scripts/utils/plural.py | 48 +++++++++++++++++++++++++++------
3 files changed, 48 insertions(+), 16 deletions(-)
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index eafdea237c..f06e2bc105 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -12,7 +12,7 @@
from markdown.note import generate_no_edit_note
from markdown.sections import add_section
from repo.queries import get_program_datetimes
-from utils.plural import pluralize
+from utils.plural import is_are, pluralize
from utils.text import markdown_escape
log = logging.getLogger(__name__)
@@ -114,7 +114,7 @@ def generate_languages_index(repo: subete.Repo) -> None:
image=find_default_language_image(),
)
num_languages = len(list(repo))
- verb = pluralize(num_languages, "is", "are")
+ verb = is_are(num_languages)
singular = pluralize(num_languages, "language")
welcome_text = (
"Welcome to the Languages page! Here, you'll find a list of all of the languages represented in the collection. "
@@ -122,7 +122,7 @@ def generate_languages_index(repo: subete.Repo) -> None:
)
untestables = repo.total_untestables()
if untestables:
- verb_untestables = pluralize(untestables, "is", "are")
+ verb_untestables = is_are(untestables)
welcome_text += f", {untestables} {verb_untestables} untestable"
num_programs = repo.total_programs()
@@ -155,10 +155,10 @@ def generate_languages_index(repo: subete.Repo) -> None:
snippets = sum(language.total_programs() for language in languages)
tests = sum(1 if language.has_testinfo() else 0 for language in languages)
untestables = sum(1 if language.has_untestable_info() else 0 for language in languages)
- verb = pluralize(tests, "is", "are")
+ verb = is_are(tests)
num_languages = len(languages)
singular = pluralize(tests, "language")
- verb_untestables = pluralize(untestables, "is", "are")
+ verb_untestables = is_are(untestables)
language_statement = (
f"The '{letter.upper()}' collection contains {num_languages} {singular}, "
f"of which {tests} {verb} tested"
diff --git a/scripts/markdown/articles.py b/scripts/markdown/articles.py
index 38c3cd4ed8..45cf97a9ee 100644
--- a/scripts/markdown/articles.py
+++ b/scripts/markdown/articles.py
@@ -2,7 +2,7 @@
import snakemd
import subete
-from utils.plural import pluralize
+from utils.plural import is_are, pluralize
from utils.text import markdown_escape
log = logging.getLogger(__name__)
@@ -37,7 +37,7 @@ def add_project_article_section(
num_articles = len(articles)
if num_articles > 0:
- verb = pluralize(num_articles, "is", "are")
+ verb = is_are(num_articles)
word = pluralize(num_articles, "article")
doc.add_paragraph(f"There {verb} {num_articles} {word}:")
doc.add_block(snakemd.MDList(articles))
@@ -60,7 +60,7 @@ def add_language_article_section(doc: snakemd.Document, repo: subete.Repo, langu
"""
doc.add_heading("Articles", level=2)
num_articles = len(list(repo[language]))
- verb = pluralize(num_articles, "is", "are")
+ verb = is_are(num_articles)
word = pluralize(num_articles, "article")
doc.add_paragraph(f"There {verb} {num_articles} {word}:")
diff --git a/scripts/utils/plural.py b/scripts/utils/plural.py
index 3e6c51ba45..d8e47a3b77 100644
--- a/scripts/utils/plural.py
+++ b/scripts/utils/plural.py
@@ -1,12 +1,44 @@
+def select(count: int, singular: str, plural: str) -> str:
+ """Return the correct word form based on count.
+
+ Args:
+ count: Number used to determine grammatical number.
+ singular: Form used when count == 1.
+ plural: Form used when count != 1.
+
+ Returns:
+ `singular` if count == 1, otherwise `plural`.
+
+ """
+ return singular if count == 1 else plural
+
+
def pluralize(count: int, singular: str, plural: str | None = None) -> str:
- """Pluralize an item
+ """Return the appropriate singular or plural form based on a count.
+
+ If `plural` is not provided, it defaults to the singular form plus "s".
+
+ Args:
+ count: Number of items used to determine grammatical form.
+ singular: Singular form of the word.
+ plural: Optional explicit plural form of the word. If None,
+ defaults to `singular + "s"`.
+
+ Returns:
+ The correct form (singular or plural) based on `count`.
- :param count: Count of number of items
- :param singular: Singular form of item
- :param plural: Plural form of item. If None, use singular plus an "s"
- :return: Pluralized item
"""
- if plural is None:
- plural = f"{singular}s"
+ return select(count, singular, plural or f"{singular}s")
- return singular if count == 1 else plural
+
+def is_are(count: int) -> str:
+ """Return the correct form of the verb "to be" based on count.
+
+ Args:
+ count: Number used to determine grammatical number.
+
+ Returns:
+ "is" if count is 1, otherwise "are".
+
+ """
+ return select(count, "is", "are")
From aaed12ad4afaa8ae3d95e69c6bf99f8a0edac7de Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 02:00:50 +0300
Subject: [PATCH 08/26] Improve file helpers
---
scripts/generators/languages.py | 4 +--
scripts/generators/projects.py | 4 +--
scripts/generators/sample_programs.py | 4 +--
scripts/utils/files.py | 38 ++++++++++++++++++++++-----
4 files changed, 37 insertions(+), 13 deletions(-)
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index f06e2bc105..8906d0a98c 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -12,6 +12,7 @@
from markdown.note import generate_no_edit_note
from markdown.sections import add_section
from repo.queries import get_program_datetimes
+from utils.files import mkdir
from utils.plural import is_are, pluralize
from utils.text import markdown_escape
@@ -69,8 +70,7 @@ def generate_language_paths(repo: subete.Repo) -> None:
for language in repo:
log.info("Generating language paths for %s", str(language))
language: subete.LanguageCollection
- path = Path(f"docs/languages/{language.pathlike_name()}")
- path.mkdir(exist_ok=True, parents=True)
+ _ = mkdir(f"docs/languages/{language.pathlike_name()}")
generate_language_index(repo, language)
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
index 4800f7898f..7f1963794a 100644
--- a/scripts/generators/projects.py
+++ b/scripts/generators/projects.py
@@ -12,6 +12,7 @@
from markdown.note import generate_no_edit_note
from markdown.sections import add_section, add_testing_section
from repo.queries import get_program_datetimes
+from utils.files import mkdir
log = logging.getLogger(__name__)
@@ -27,8 +28,7 @@ def generate_project_paths(repo: subete.Repo) -> None:
for i, project in enumerate(projects):
project: subete.Project
log.info("Generating project paths for %s", str(project))
- path = Path(f"docs/projects/{project.pathlike_name()}")
- path.mkdir(exist_ok=True, parents=True)
+ _ = mkdir(f"docs/projects/{project.pathlike_name()}")
generate_project_index(repo, project, projects[i - 1], projects[(i + 1) % len(projects)])
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
index 033eb3d427..9ac466f11a 100644
--- a/scripts/generators/sample_programs.py
+++ b/scripts/generators/sample_programs.py
@@ -12,6 +12,7 @@
from markdown.note import generate_no_edit_note
from markdown.sections import add_section
from repo.queries import get_program_datetimes
+from utils.files import mkdir
from utils.text import markdown_escape
log = logging.getLogger(__name__)
@@ -27,10 +28,9 @@ def generate_sample_programs(repo: subete.Repo) -> None:
for program in language:
log.info("Generate sample programs for %s", str(program))
program: subete.SampleProgram
- path = Path(
+ path = mkdir(
f"docs/projects/{program.project_pathlike_name()}/{language.pathlike_name()}",
)
- path.mkdir(exist_ok=True, parents=True)
generate_sample_program_index(program, path)
diff --git a/scripts/utils/files.py b/scripts/utils/files.py
index c0f3d6ac9c..78f3900615 100644
--- a/scripts/utils/files.py
+++ b/scripts/utils/files.py
@@ -1,13 +1,37 @@
+import shutil
from pathlib import Path
+def mkdir(path: str | Path, *, exist_ok: bool = True) -> Path:
+ """Create a directory (including parents if needed).
+
+ Args:
+ path: Directory to create.
+ exist_ok: If True, ignores existing directories.
+
+ Returns:
+ Path object of the created directory.
+
+ """
+ p = Path(path)
+ p.mkdir(parents=True, exist_ok=exist_ok)
+ return p
+
+
def clean(folder: str | Path) -> None:
- """Deletes the contents of the docs directory."""
+ """Remove all contents of a folder recursively.
+
+ Args:
+ folder: Directory to clear.
+
+ Raises:
+ FileNotFoundError: If the folder does not exist.
+ PermissionError: If files cannot be removed.
+
+ """
path = Path(folder)
+
if path.exists():
- for child in path.glob("*"):
- if child.is_file():
- child.unlink()
- else:
- clean(child)
- path.rmdir()
+ shutil.rmtree(path)
+
+ mkdir(path)
From 9f77e156b3457999ef3548eedb7410335556aa94 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 02:03:14 +0300
Subject: [PATCH 09/26] Optimize split_text, improve markdown_escape and
docstrings
---
scripts/utils/text.py | 62 ++++++++++++++++++++++++++++++-------------
1 file changed, 43 insertions(+), 19 deletions(-)
diff --git a/scripts/utils/text.py b/scripts/utils/text.py
index 825c144972..491cc52615 100644
--- a/scripts/utils/text.py
+++ b/scripts/utils/text.py
@@ -1,23 +1,47 @@
-def markdown_escape(s: str) -> str:
- return s.replace("*", r"\*")
+def markdown_escape(text: str) -> str:
+ """Escape Markdown special characters in text.
+
+ Args:
+ text: Input string.
+
+ Returns:
+ Escaped string safe for Markdown rendering.
+
+ """
+ return text.replace("\\", "\\\\").replace("*", r"\*").replace("_", r"\_").replace("`", r"\`")
def split_text(text: str) -> tuple[str, str]:
+ """Split text into two parts at a space nearest the midpoint.
+
+ The algorithm searches outward from the center of the string and selects
+ the first whitespace character encountered. If two candidates are equally
+ distant from the midpoint, the right-side split is preferred.
+
+ Args:
+ text: Input string to split.
+
+ Returns:
+ A tuple (left, right) where:
+ - left is the substring before the split point
+ - right is the substring after the split point
+
+ If no whitespace is found, returns (text, "").
+
+ """
mid = len(text) // 2
- best_index = -1
- min_dist = float("inf")
-
- for i, char in enumerate(text):
- if char.isspace():
- dist = abs(i - mid)
- if dist <= min_dist:
- min_dist = dist
- best_index = i
- elif i > mid:
- # Optimization: if we are past mid and distance is increasing, stop
- break
-
- if best_index == -1:
- return text, ""
-
- return text[:best_index], text[best_index + 1 :]
+
+ # expand outward from middle
+ for offset in range(len(text)):
+ right = mid + offset
+ left = mid - offset
+
+ # check RIGHT first (tie preference)
+ if right < len(text) and text[right].isspace():
+ return text[:right], text[right + 1 :]
+
+ # then LEFT
+ if left >= 0 and text[left].isspace():
+ return text[:left], text[left + 1 :]
+
+ return text, ""
From 9324e4dc73bd56c50b93da932d795bcfb64f34e7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 02:16:43 +0300
Subject: [PATCH 10/26] Parallelize image generation
---
scripts/assets/image_build.py | 230 ++++++++++++++++++++--------------
1 file changed, 135 insertions(+), 95 deletions(-)
diff --git a/scripts/assets/image_build.py b/scripts/assets/image_build.py
index f6ac551efe..23835eb908 100644
--- a/scripts/assets/image_build.py
+++ b/scripts/assets/image_build.py
@@ -2,6 +2,8 @@
import shutil
import subprocess
import tempfile
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
from pathlib import Path
import subete
@@ -13,111 +15,149 @@
log = logging.getLogger(__name__)
+ASSETS_DIR = Path("docs/assets/images")
+LOGO_PATH = ASSETS_DIR / "icon-small.png"
+
+
+@dataclass(frozen=True)
+class ImageSpec:
+ src_dir: Path
+ dest_no_ext: str
+
def generate_images(repo: subete.Repo) -> int:
- """Use image-titler to resize and crop images and add logo
+ """Generate all processed images using image-titler.
+
+ Returns:
+ 0 if all succeeded, 1 if any failed.
- :param subete.Repo repo: the repo to pull from.
- :return: 0 if no error, non-zero otherwise
"""
- with tempfile.TemporaryDirectory() as temp_dir:
- status_code = 0
- status_code = generate_language_images(repo, temp_dir, status_code)
- status_code = generate_project_images(repo, temp_dir, status_code)
- status_code = generate_program_images(repo, temp_dir, status_code)
- return status_code
-
-
-def generate_language_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = generate_image(
- temp_dir,
- "sources/languages",
- DEFAULT_LANGUAGE_IMAGE_NO_EXT,
- status_code,
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- status_code = generate_image(
- temp_dir,
- f"sources/languages/{language_path}",
- f"the-{language_path}-programming-language",
- status_code,
+ specs = [
+ *_language_specs(repo),
+ *_project_specs(repo),
+ *_program_specs(repo),
+ ]
+
+ return 1 if _run_parallel(specs) else 0
+
+
+def _run_parallel(specs: list[ImageSpec], workers: int = 8) -> list[ImageSpec]:
+ failures: list[ImageSpec] = []
+
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ futures = {pool.submit(_process_spec, spec): spec for spec in specs}
+
+ for future in as_completed(futures):
+ spec = futures[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: %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 # treat missing as OK
+
+ dest_path = ASSETS_DIR / f"{spec.dest_no_ext}{src_image.suffix}"
+
+ 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,
+ )
+
+ produced_files = list(tmp_dir.iterdir())
+ if not produced_files:
+ log.error("No output generated for %s", src_image)
+ return False
+
+ shutil.move(produced_files[0], dest_path)
+ return True
+
+ except subprocess.CalledProcessError:
+ log.exception("image-titler failed for %s", src_image)
+ return False
+
+
+def _find_featured_image(dir_path: Path) -> Path | None:
+ if not dir_path.exists():
+ return None
+ return next(dir_path.glob("featured-image.*"), None)
+
+
+def _language_specs(repo: subete.Repo) -> list[ImageSpec]:
+ specs = [
+ ImageSpec(Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT),
+ ]
+
+ for lang in repo:
+ name = lang.pathlike_name()
+ specs.append(
+ ImageSpec(
+ Path("sources/languages") / name,
+ f"the-{name}-programming-language",
+ ),
)
- return status_code
+ return specs
-def generate_project_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = generate_image(
- temp_dir,
- "sources/projects",
- DEFAULT_PROJECT_IMAGE_NO_EXT,
- status_code,
- )
+def _project_specs(repo: subete.Repo) -> list[ImageSpec]:
+ specs = [
+ ImageSpec(Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT),
+ ]
+
for project in repo.approved_projects():
- project_path = project.pathlike_name()
- status_code = generate_image(
- temp_dir,
- f"sources/projects/{project_path}",
- f"{project_path}-in-every-language",
- status_code,
+ name = project.pathlike_name()
+ specs.append(
+ ImageSpec(
+ Path("sources/projects") / name,
+ f"{name}-in-every-language",
+ ),
)
- return status_code
-
-
-def generate_program_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = generate_image(
- temp_dir,
- "sources",
- DEFAULT_PROGRAM_IMAGE_NO_EXT,
- status_code,
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- program_path = program.project_pathlike_name()
- status_code = generate_image(
- temp_dir,
- f"sources/programs/{program_path}/{language_path}",
- f"{program_path}-in-{language_path}",
- status_code,
- )
+ return specs
- return status_code
-
-
-def generate_image(temp_dir: str, src: str, dest_filename_no_ext: str, status_code: int) -> int:
- src_image_path = next(Path(src).glob("featured-image.*"), None)
- if not src_image_path:
- return status_code
-
- dest = Path("docs/assets/images")
- logo = str(dest / "icon-small.png")
-
- dest_image_path = dest / f"{dest_filename_no_ext}{src_image_path.suffix}"
- log.info("Processing %s -> %s", str(src_image_path), str(dest_image_path))
- try:
- subprocess.run(
- [
- "image-titler",
- "--path",
- str(src_image_path),
- "--output",
- temp_dir,
- "--logo",
- logo,
- "--no_title",
- ],
- check=True,
- )
- temp_image_path = next(Path(temp_dir).iterdir())
- shutil.move(temp_image_path, dest_image_path)
- except subprocess.CalledProcessError as exc:
- log.error("image-titler exited with %d status", exc.returncode)
- status_code = 1
- return status_code
+def _program_specs(repo: subete.Repo) -> list[ImageSpec]:
+ specs = [
+ ImageSpec(Path("sources"), 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(
+ Path("sources/programs") / proj / lang_name,
+ f"{proj}-in-{lang_name}",
+ ),
+ )
+
+ return specs
From d62b1227847739ed31f8a574bbf419df7708080d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 02:24:20 +0300
Subject: [PATCH 11/26] Optimize and refactor image_copy
---
scripts/assets/image_copy.py | 121 +++++++++++++++++++++--------------
1 file changed, 74 insertions(+), 47 deletions(-)
diff --git a/scripts/assets/image_copy.py b/scripts/assets/image_copy.py
index 33bf8f8f98..68111546ae 100644
--- a/scripts/assets/image_copy.py
+++ b/scripts/assets/image_copy.py
@@ -1,71 +1,98 @@
import logging
-import os
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 subete import imghdr
+from utils.files import mkdir
log = logging.getLogger(__name__)
+SOURCE_DIR = Path("sources")
+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 article images to the appropriate directory
-
- :param subete.Repo repo: the repo to pull from.
- """
- copy_language_images(repo)
- copy_project_images(repo)
- copy_program_images(repo)
-
-
-def copy_language_images(repo: subete.Repo) -> None:
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- copy_image(
- f"sources/languages/{language_path}",
- f"docs/assets/images/languages/{language_path}",
+ """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(
+ SOURCE_DIR / "languages" / name,
+ ASSETS_ROOT / "languages" / name,
)
-def copy_project_images(repo: subete.Repo) -> None:
- project: subete.Project
+def _project_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for project in repo.approved_projects():
- project_path = project.pathlike_name()
- copy_image(
- f"sources/projects/{project_path}",
- f"docs/assets/images/projects/{project_path}",
+ name = project.pathlike_name()
+ yield CopySpec(
+ SOURCE_DIR / "projects" / name,
+ ASSETS_ROOT / "projects" / name,
)
-def copy_program_images(repo: subete.Repo) -> None:
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- project_path = program.project_pathlike_name()
- copy_image(
- f"sources/programs/{project_path}/{language_path}",
- f"docs/assets/images/projects/{project_path}/{language_path}",
+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(
+ SOURCE_DIR / "programs" / proj / lang_name,
+ ASSETS_ROOT / "projects" / proj / lang_name,
)
-def copy_image(src_dir: str, dest_dir: str) -> None:
- src_dir_path = Path(src_dir)
- dest_dir_path = Path(dest_dir)
- if not src_dir_path.exists():
+def _copy_images(spec: CopySpec) -> None:
+ if not spec.src_dir.is_dir():
+ return
+
+ images = _list_images(spec.src_dir)
+ if not images:
return
- src_image_paths = [
- path
- for path in src_dir_path.iterdir()
- if path.is_file() and path.stem != "featured-image" and imghdr.what(path)
+ _ = 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)
]
- if src_image_paths:
- os.makedirs(dest_dir, exist_ok=True)
- for src_image_path in src_image_paths:
- dest_image_path = dest_dir_path / src_image_path.name
- log.info("Copying image %s -> %s", str(src_image_path), str(dest_image_path))
- shutil.copy(src_image_path, dest_image_path)
From b992e635b7a3622afc2f89eb666dfb72b244279f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 02:59:22 +0300
Subject: [PATCH 12/26] Improve image_lookup.py docstrings and code
---
scripts/assets/image_lookup.py | 144 ++++++++++++++++++++-------------
1 file changed, 88 insertions(+), 56 deletions(-)
diff --git a/scripts/assets/image_lookup.py b/scripts/assets/image_lookup.py
index 0b51a8ca9d..b8c1badcc0 100644
--- a/scripts/assets/image_lookup.py
+++ b/scripts/assets/image_lookup.py
@@ -1,85 +1,117 @@
-import functools
+from functools import cache
from pathlib import Path
import subete
from constants import DEFAULT_LANGUAGE_IMAGE_NO_EXT, DEFAULT_PROJECT_IMAGE_NO_EXT
+BASE_DIR = Path("sources")
+PROJECTS_DIR = BASE_DIR / "projects"
+LANGUAGES_DIR = BASE_DIR / "languages"
+PROGRAMS_DIR = BASE_DIR / "programs"
-def find_program_image(program: subete.SampleProgram) -> str | None:
- """Gets the filename of the image for a sample program
- :param subete.SampleProgram program: the sample program to get the image for.
- :return: Filename of image if found, None otherwise.
+@cache
+def _resolve_image(
+ directory: Path,
+ target_name: str,
+ fallback: str | None = None,
+) -> str | None:
+ """Locates a 'featured-image' and formats its output name.
+
+ Args:
+ directory: The Path directory to search within.
+ target_name: The base filename string to return if an image is found.
+ fallback: The filename to return if no image is found in the directory.
+
+ Returns:
+ The target name combined with the original file extension if found,
+ otherwise the fallback value.
+
"""
- project_path = program.project_pathlike_name()
- language_path = program.language_pathlike_name()
- image_path = Path(f"sources/programs/{project_path}/{language_path}")
- return find_image(
- image_path,
- f"{project_path}-in-{language_path}",
- find_project_image(program.project()),
- )
+ if directory.is_dir():
+ if img_file := next(directory.glob("featured-image.*"), None):
+ return f"{target_name}{img_file.suffix}"
+ return fallback
-@functools.lru_cache
-def find_project_image(project: subete.Project) -> str | None:
- """Gets the filename of the image for a project
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :return: Filename of image if found, None otherwise.
+@cache
+def get_default_project_image() -> str | None:
+ """Retrieves the default project image filename.
+
+ Returns:
+ The filename of the default project image if found, otherwise None.
+
"""
- project_path = project.pathlike_name()
- image_path = Path(f"sources/projects/{project_path}")
- return find_image(
- image_path,
- f"{project_path}-in-every-language",
- find_default_project_image(),
- )
+ return _resolve_image(PROJECTS_DIR, DEFAULT_PROJECT_IMAGE_NO_EXT)
+
+@cache
+def get_default_language_image() -> str | None:
+ """Retrieves the default language image filename.
-@functools.lru_cache
-def find_default_project_image() -> str | None:
- """Gets the filename of the default project image
+ Returns:
+ The filename of the default language image if found, otherwise None.
- :return: Filename of image if found, None otherwise
"""
- return find_image(Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)
+ return _resolve_image(LANGUAGES_DIR, DEFAULT_LANGUAGE_IMAGE_NO_EXT)
-@functools.lru_cache
-def find_image(
- image_path: Path,
- filename_prefix_no_ext: str,
- default_filename: str | None = None,
-) -> str | None:
- if image_path.is_dir():
- path = next(image_path.glob("featured-image.*"), None)
- if path:
- return f"{filename_prefix_no_ext}{path.suffix}"
+@cache
+def find_project_image(project: subete.Project) -> str | None:
+ """Gets the image filename for a specific project.
+
+ Args:
+ project: The subete Project instance to look up.
+
+ Returns:
+ The formatted project image filename, or the default project image
+ if a specific one isn't found.
- return default_filename
+ """
+ name = project.pathlike_name()
+ return _resolve_image(
+ directory=PROJECTS_DIR / name,
+ target_name=f"{name}-in-every-language",
+ fallback=get_default_project_image(),
+ )
def find_language_image(language: subete.LanguageCollection) -> str | None:
- """Get image filename for a language
+ """Gets the image filename for a specific language.
+
+ Args:
+ language: The subete LanguageCollection instance to look up.
+
+ Returns:
+ The formatted language image filename, or the default language image
+ if a specific one isn't found.
- :param subete.LanguageCollection language: the collection sample programs for a language.
- :return: Filename of image if found, None otherwise.
"""
- language_path = language.pathlike_name()
- image_path = Path(f"sources/languages/{language_path}")
- return find_image(
- image_path,
- f"the-{language_path}-programming-language",
- find_default_language_image(),
+ name = language.pathlike_name()
+ return _resolve_image(
+ directory=LANGUAGES_DIR / name,
+ target_name=f"the-{name}-programming-language",
+ fallback=get_default_language_image(),
)
-@functools.lru_cache
-def find_default_language_image() -> str | None:
- """Get default language image filename
+def find_program_image(program: subete.SampleProgram) -> str | None:
+ """Gets the image filename for a specific sample program.
+
+ Args:
+ program: The subete SampleProgram instance to look up.
+
+ Returns:
+ The formatted sample program image filename, falling back to its
+ associated project image if not found locally.
- :return: Filename of image if found, None otherwise.
"""
- return find_image(Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)
+ proj_name = program.project_pathlike_name()
+ lang_name = program.language_pathlike_name()
+
+ return _resolve_image(
+ directory=PROGRAMS_DIR / proj_name / lang_name,
+ target_name=f"{proj_name}-in-{lang_name}",
+ fallback=find_project_image(program.project()),
+ )
From dfbe5552e74da7410e181b1f89511fffa941d987 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:15:41 +0300
Subject: [PATCH 13/26] Make image building cleaner
---
scripts/assets/image_build.py | 73 ++++++++++++++++++---------------
scripts/generators/languages.py | 4 +-
scripts/generators/projects.py | 4 +-
3 files changed, 45 insertions(+), 36 deletions(-)
diff --git a/scripts/assets/image_build.py b/scripts/assets/image_build.py
index 23835eb908..7e5e9bfb2e 100644
--- a/scripts/assets/image_build.py
+++ b/scripts/assets/image_build.py
@@ -12,12 +12,15 @@
DEFAULT_PROGRAM_IMAGE_NO_EXT,
DEFAULT_PROJECT_IMAGE_NO_EXT,
)
+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:
@@ -25,7 +28,7 @@ class ImageSpec:
dest_no_ext: str
-def generate_images(repo: subete.Repo) -> int:
+def generate_images(repo: subete.Repo, workers: int = 8) -> int:
"""Generate all processed images using image-titler.
Returns:
@@ -38,17 +41,18 @@ def generate_images(repo: subete.Repo) -> int:
*_program_specs(repo),
]
- return 1 if _run_parallel(specs) else 0
+ failures = _run_parallel(specs, workers=workers)
+ return 1 if failures else 0
-def _run_parallel(specs: list[ImageSpec], workers: int = 8) -> list[ImageSpec]:
+def _run_parallel(specs: list[ImageSpec], workers: int) -> list[ImageSpec]:
failures: list[ImageSpec] = []
with ThreadPoolExecutor(max_workers=workers) as pool:
- futures = {pool.submit(_process_spec, spec): spec for spec in specs}
+ future_map = {pool.submit(_process_spec, spec): spec for spec in specs}
- for future in as_completed(futures):
- spec = futures[future]
+ for future in as_completed(future_map):
+ spec = future_map[future]
try:
if not future.result():
failures.append(spec)
@@ -57,7 +61,7 @@ def _run_parallel(specs: list[ImageSpec], workers: int = 8) -> list[ImageSpec]:
failures.append(spec)
for spec in failures:
- log.error("Failed image: %s", spec)
+ log.error("Failed image spec: %s", spec)
return failures
@@ -65,9 +69,10 @@ def _run_parallel(specs: list[ImageSpec], workers: int = 8) -> list[ImageSpec]:
def _process_spec(spec: ImageSpec) -> bool:
src_image = _find_featured_image(spec.src_dir)
if src_image is None:
- return True # treat missing as OK
+ 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)
@@ -87,14 +92,16 @@ def _process_spec(spec: ImageSpec) -> bool:
"--no_title",
],
check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
)
- produced_files = list(tmp_dir.iterdir())
- if not produced_files:
- log.error("No output generated for %s", src_image)
+ produced = _pick_output(tmp_dir)
+ if produced is None:
+ log.error("No output produced for %s", src_image)
return False
- shutil.move(produced_files[0], dest_path)
+ _safe_move(produced, dest_path)
return True
except subprocess.CalledProcessError:
@@ -102,57 +109,59 @@ def _process_spec(spec: ImageSpec) -> bool:
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.exists():
+ if not dir_path.is_dir():
return None
- return next(dir_path.glob("featured-image.*"), None)
+ return next(dir_path.glob(FEATURED_GLOB), None)
def _language_specs(repo: subete.Repo) -> list[ImageSpec]:
- specs = [
- ImageSpec(Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT),
- ]
+ specs = [ImageSpec(Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)]
for lang in repo:
name = lang.pathlike_name()
specs.append(
- ImageSpec(
- Path("sources/languages") / name,
- f"the-{name}-programming-language",
- ),
+ ImageSpec(Path("sources/languages") / name, f"the-{name}-programming-language"),
)
return specs
def _project_specs(repo: subete.Repo) -> list[ImageSpec]:
- specs = [
- ImageSpec(Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT),
- ]
+ specs = [ImageSpec(Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)]
for project in repo.approved_projects():
name = project.pathlike_name()
specs.append(
- ImageSpec(
- Path("sources/projects") / name,
- f"{name}-in-every-language",
- ),
+ ImageSpec(Path("sources/projects") / name, f"{name}-in-every-language"),
)
return specs
def _program_specs(repo: subete.Repo) -> list[ImageSpec]:
- specs = [
- ImageSpec(Path("sources"), DEFAULT_PROGRAM_IMAGE_NO_EXT),
- ]
+ specs = [ImageSpec(Path("sources"), 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(
Path("sources/programs") / proj / lang_name,
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index 8906d0a98c..c3848aee85 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -4,7 +4,7 @@
import snakemd
import subete
-from assets.image_lookup import find_default_language_image, find_language_image
+from assets.image_lookup import get_default_language_image, find_language_image
from constants import LANGUAGE_MD_FILENAMES
from markdown.articles import add_language_article_section
from markdown.authors import add_authors_to_doc
@@ -111,7 +111,7 @@ def generate_languages_index(repo: subete.Repo) -> None:
language_index,
"Programming Languages",
times=times,
- image=find_default_language_image(),
+ image=get_default_language_image(),
)
num_languages = len(list(repo))
verb = is_are(num_languages)
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
index 7f1963794a..59d8897848 100644
--- a/scripts/generators/projects.py
+++ b/scripts/generators/projects.py
@@ -4,7 +4,7 @@
import snakemd
import subete
-from assets.image_lookup import find_default_project_image, find_project_image
+from assets.image_lookup import get_default_project_image, find_project_image
from constants import PROJECT_MD_FILENAMES
from markdown.articles import add_project_article_section
from markdown.authors import add_authors_to_doc
@@ -131,7 +131,7 @@ def generate_projects_index(repo: subete.Repo) -> None:
projects_index,
"Programming Projects in Every Language",
times=times,
- image=find_default_project_image(),
+ image=get_default_project_image(),
)
project_tests = sum(1 if project.has_testing() else 0 for project in repo.approved_projects())
projects_index.add_paragraph(
From 8c8cd68b89e0bb27ab8c2f4a8b3af393e81a1bf5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:26:08 +0300
Subject: [PATCH 14/26] Split generate_sample_program_index into multiple
functions
---
scripts/generators/languages.py | 2 +-
scripts/generators/projects.py | 2 +-
scripts/generators/sample_programs.py | 155 +++++++++++++++++---------
3 files changed, 104 insertions(+), 55 deletions(-)
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index c3848aee85..69505f8b64 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -4,7 +4,7 @@
import snakemd
import subete
-from assets.image_lookup import get_default_language_image, find_language_image
+from assets.image_lookup import find_language_image, get_default_language_image
from constants import LANGUAGE_MD_FILENAMES
from markdown.articles import add_language_article_section
from markdown.authors import add_authors_to_doc
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
index 59d8897848..ef89390f20 100644
--- a/scripts/generators/projects.py
+++ b/scripts/generators/projects.py
@@ -4,7 +4,7 @@
import snakemd
import subete
-from assets.image_lookup import get_default_project_image, find_project_image
+from assets.image_lookup import find_project_image, get_default_project_image
from constants import PROJECT_MD_FILENAMES
from markdown.articles import add_project_article_section
from markdown.authors import add_authors_to_doc
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
index 9ac466f11a..34df1511e4 100644
--- a/scripts/generators/sample_programs.py
+++ b/scripts/generators/sample_programs.py
@@ -1,4 +1,3 @@
-import datetime
import logging
import shutil
from pathlib import Path
@@ -17,53 +16,100 @@
log = logging.getLogger(__name__)
+DOCS_PROJECTS_DIR = Path("docs/projects")
+PROGRAMS_BASE_DIR = Path("programs")
+DATETIME_FORMAT = "%b %d %Y %H:%M:%S"
+
def generate_sample_programs(repo: subete.Repo) -> None:
"""Creates the language folders in each project directory.
- :param subete.Repo repo: the repo to pull from.
+ Args:
+ repo: The subete Repository instance to pull data from.
+
"""
for language in repo:
- language: subete.LanguageCollection
for program in language:
- log.info("Generate sample programs for %s", str(program))
- program: subete.SampleProgram
- path = mkdir(
- f"docs/projects/{program.project_pathlike_name()}/{language.pathlike_name()}",
+ log.info("Generate sample programs for %s", program)
+
+ project_dir = (
+ DOCS_PROJECTS_DIR / program.project_pathlike_name() / language.pathlike_name()
)
- generate_sample_program_index(program, path)
+ target_path = mkdir(project_dir)
+
+ generate_sample_program_index(program, target_path)
def generate_sample_program_index(program: subete.SampleProgram, path: Path) -> None:
"""Creates a sample program documentation file.
- :param subete.SampleProgram program: the sample program to create the documentation for.
- :param pathlib.Path path: the path to the documentation file.
+ Args:
+ program: The sample program instance to document.
+ path: The directory Path where the documentation index will be saved.
+
"""
- doc: snakemd.Document = snakemd.new_doc()
- root_path = Path(
- f"programs/{program.project_pathlike_name()}/{program.language_pathlike_name()}",
+ proj_path_name = program.project_pathlike_name()
+ lang_path_name = program.language_pathlike_name()
+ language_escaped = markdown_escape(program.language_name())
+ language_docs_url = program.language_collection().lang_docs_url()
+
+ program_root_str = str(PROGRAMS_BASE_DIR / proj_path_name)
+ doc = snakemd.new_doc()
+
+ _add_front_matter_and_notes(doc, program, proj_path_name, lang_path_name, program_root_str)
+ _add_welcome_paragraph(
+ doc,
+ program.project_name(),
+ language_escaped,
+ language_docs_url,
+ program,
)
- authors: set[str] = program.authors()
- doc_authors: set[str] = program.doc_authors()
+ _add_solution_block(doc, program, path, language_escaped)
+ _add_author_credits(doc, program, language_escaped, language_docs_url)
+ _add_outdated_warning_if_needed(doc, program)
+
+ add_section(doc, program_root_str, lang_path_name, "How to Implement the Solution")
+ add_section(doc, program_root_str, lang_path_name, "How to Run the Solution")
+
+ try:
+ doc.dump("index", directory=str(path))
+ except Exception:
+ log.exception("Failed to write %s", path)
+
+
+def _add_front_matter_and_notes(
+ doc: snakemd.Document,
+ program: subete.SampleProgram,
+ proj_path_name: str,
+ lang_path_name: str,
+ program_root_str: str,
+) -> None:
+ """Generates the metadata front matter block and the non-editable note warning."""
generate_front_matter(
doc,
str(program),
times=get_program_datetimes(program),
image=find_program_image(program),
- authors=authors | doc_authors,
- tags=[program.language_pathlike_name(), program.project_pathlike_name()],
+ authors=program.authors() | program.doc_authors(),
+ tags=[lang_path_name, proj_path_name],
)
+
generate_no_edit_note(
doc,
- str(root_path.parent),
- program.language_pathlike_name(),
+ program_root_str,
+ lang_path_name,
PROGRAM_MD_FILENAMES,
)
- project_name = program.project_name()
- language_escaped = markdown_escape(program.language_name())
- language_docs_url = program.language_collection().lang_docs_url()
+
+def _add_welcome_paragraph(
+ doc: snakemd.Document,
+ project_name: str,
+ language_escaped: str,
+ language_docs_url: str,
+ program: subete.SampleProgram,
+) -> None:
+ """Appends the standard introductory paragraph and main header."""
doc.add_block(
snakemd.Paragraph(
[
@@ -78,24 +124,42 @@ def generate_sample_program_index(program: subete.SampleProgram, path: Path) ->
)
doc.add_heading("Current Solution", level=2)
+
+def _add_solution_block(
+ doc: snakemd.Document,
+ program: subete.SampleProgram,
+ path: Path,
+ language_escaped: str,
+) -> None:
+ """Renders either the embedded image asset or raw source code logic block."""
if program.image_type():
image_dest = path / Path(program.project_path()).name
shutil.copy(program.project_path(), image_dest)
+
image_uri = "/" + "/".join(image_dest.parts[1:])
doc.add_block(
- snakemd.Raw(
- f""" """,
- ),
+ snakemd.Raw(f' '),
)
else:
doc.add_paragraph("{% raw %}")
doc.add_code(program.code(), lang=language_escaped.lower().replace(" ", "_"))
doc.add_paragraph("{% endraw %}")
+
+def _add_author_credits(
+ doc: snakemd.Document,
+ program: subete.SampleProgram,
+ language_escaped: str,
+ language_docs_url: str,
+) -> None:
+ """Builds lists detailing code implementation authors vs documentation contributors."""
+ authors = program.authors()
+ doc_authors = program.doc_authors()
+
doc.add_block(
snakemd.Paragraph(
[
- f"{project_name} in ",
+ f"{program.project_name()} in ",
snakemd.Inline(language_escaped, link=language_docs_url),
" was written by:",
],
@@ -103,7 +167,6 @@ def generate_sample_program_index(program: subete.SampleProgram, path: Path) ->
)
add_authors_to_doc(doc, authors)
- doc_authors: set[str] = program.doc_authors()
if doc_authors:
doc.add_paragraph("This article was written by:")
add_authors_to_doc(doc, doc_authors)
@@ -115,38 +178,24 @@ def generate_sample_program_index(program: subete.SampleProgram, path: Path) ->
"https://github.com/TheRenegadeCoder/sample-programs",
)
- created_at: datetime.datetime | None = program.created()
- modified: datetime.datetime | None = program.modified()
- doc_modified: datetime.datetime | None = program.doc_modified()
+
+def _add_outdated_warning_if_needed(doc: snakemd.Document, program: subete.SampleProgram) -> None:
+ """Appends an outdated documentation warning block if chronological mismatches exist."""
+ created_at = program.created()
+ modified = program.modified()
+ doc_modified = program.doc_modified()
+
if (
created_at
and modified
- and created_at != modified
and doc_modified
- and doc_modified < modified
+ and (created_at != modified)
+ and (doc_modified < modified)
):
- datetime_format = "%b %d %Y %H:%M:%S"
doc.add_paragraph(
"**Note**: The solution shown above is the current solution in the Sample "
- f"Programs repository as of {modified.strftime(datetime_format)}. "
- f"The solution was first committed on {created_at.strftime(datetime_format)}. "
- f"The documentation was last updated on {doc_modified.strftime(datetime_format)}. "
+ f"Programs repository as of {modified.strftime(DATETIME_FORMAT)}. "
+ f"The solution was first committed on {created_at.strftime(DATETIME_FORMAT)}. "
+ f"The documentation was last updated on {doc_modified.strftime(DATETIME_FORMAT)}. "
"As a result, documentation below may be outdated.",
)
-
- add_section(
- doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- "How to Implement the Solution",
- )
- add_section(
- doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- "How to Run the Solution",
- )
- try:
- doc.dump("index", directory=str(path))
- except Exception:
- log.exception(f"Failed to write {path}")
From c8ab4dd396dc27040664131ab09790383b1a5ce0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:30:43 +0300
Subject: [PATCH 15/26] Make generate_auto_gen_test_docs more robust using
contextlib.chdir
---
scripts/generators/tests.py | 29 +++++++++++++++++------------
1 file changed, 17 insertions(+), 12 deletions(-)
diff --git a/scripts/generators/tests.py b/scripts/generators/tests.py
index 8ddaa68297..2585e0e086 100644
--- a/scripts/generators/tests.py
+++ b/scripts/generators/tests.py
@@ -1,5 +1,5 @@
+import contextlib
import logging
-import os
from pathlib import Path
import glotter
@@ -10,17 +10,22 @@
def generate_auto_gen_test_docs(repo: subete.Repo) -> None:
- """Generate auto-generated test documentation
+ """Generates automated test documentation using Glotter.
+
+ Args:
+ repo: The subete Repository instance to pull information from.
- :param subete.Repo repo: the repo to pull from.
"""
log.info("Generating test documentation")
- curr_dir = os.getcwd()
- doc_dir = Path(AUTO_GEN_TEST_DOC_DIR).absolute()
- os.chdir(repo.sample_programs_repo_dir())
- glotter.generate_test_docs(
- doc_dir=doc_dir,
- repo_name="Sample Programs",
- repo_url="https://github.com/TheRenegadeCoder/sample-programs",
- )
- os.chdir(curr_dir)
+
+ doc_dir = Path(AUTO_GEN_TEST_DOC_DIR).resolve()
+ repo_dir = Path(repo.sample_programs_repo_dir())
+
+ # Safely switch directories. The context manager guarantees the original
+ # working directory is restored even if an exception occurs inside the block.
+ with contextlib.chdir(repo_dir):
+ glotter.generate_test_docs(
+ doc_dir=doc_dir,
+ repo_name="Sample Programs",
+ repo_url="https://github.com/TheRenegadeCoder/sample-programs",
+ )
From bb2e55e1f43b5fca6e10b716424969f3d1e3611f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:46:23 +0300
Subject: [PATCH 16/26] pluralize now returns count + correct word form
---
scripts/generators/languages.py | 18 +++++++++---------
scripts/markdown/articles.py | 6 ++----
scripts/utils/plural.py | 8 +++++---
3 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index 69505f8b64..3d0377b98e 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -115,10 +115,10 @@ def generate_languages_index(repo: subete.Repo) -> None:
)
num_languages = len(list(repo))
verb = is_are(num_languages)
- singular = pluralize(num_languages, "language")
+ languages_str = pluralize(num_languages, "language")
welcome_text = (
"Welcome to the Languages page! Here, you'll find a list of all of the languages represented in the collection. "
- f"At this time, there {verb} {num_languages} {singular}, of which {repo.total_tests()} are tested"
+ f"At this time, there {verb} {languages_str}, of which {repo.total_tests()} are tested"
)
untestables = repo.total_untestables()
if untestables:
@@ -126,8 +126,8 @@ def generate_languages_index(repo: subete.Repo) -> None:
welcome_text += f", {untestables} {verb_untestables} untestable"
num_programs = repo.total_programs()
- singular = pluralize(num_programs, "snippet")
- welcome_text += f", and {num_programs} code {singular}."
+ snippets_str = pluralize(num_programs, "code snippet")
+ welcome_text += f", and {snippets_str}."
language_index.add_paragraph(welcome_text)
language_index.add_heading("Language Breakdown", level=2)
@@ -157,16 +157,17 @@ def generate_languages_index(repo: subete.Repo) -> None:
untestables = sum(1 if language.has_untestable_info() else 0 for language in languages)
verb = is_are(tests)
num_languages = len(languages)
- singular = pluralize(tests, "language")
+ languages_str = pluralize(num_languages, "language", plural="languages")
verb_untestables = is_are(untestables)
language_statement = (
- f"The '{letter.upper()}' collection contains {num_languages} {singular}, "
+ f"The '{letter.upper()}' collection contains {languages_str}, "
f"of which {tests} {verb} tested"
)
if untestables:
language_statement += f", {untestables} {verb_untestables} untestable"
- language_index.add_paragraph(f"{language_statement}, and {snippets} code snippets.")
+ snippets_str = pluralize(snippets, "code snippet")
+ language_index.add_paragraph(f"{language_statement}, and {snippets_str}.")
languages.sort(key=lambda x: x.name().casefold())
languages_list = [get_language_link_and_testability(x) for x in languages]
language_index.add_block(snakemd.MDList(languages_list))
@@ -181,8 +182,7 @@ def get_language_link_and_testability(
language_escaped = markdown_escape(language.name())
language_link = snakemd.Inline(language_escaped, link=language.lang_docs_url())
num_programs = language.total_programs()
- singular = pluralize(num_programs, "code snippet")
- phrase = f"{num_programs} {singular}"
+ phrase = pluralize(num_programs, "code snippet")
if language.has_testinfo():
return snakemd.Paragraph([language_link, f" ({phrase})"])
diff --git a/scripts/markdown/articles.py b/scripts/markdown/articles.py
index 45cf97a9ee..3b5f80a33e 100644
--- a/scripts/markdown/articles.py
+++ b/scripts/markdown/articles.py
@@ -38,8 +38,7 @@ def add_project_article_section(
num_articles = len(articles)
if num_articles > 0:
verb = is_are(num_articles)
- word = pluralize(num_articles, "article")
- doc.add_paragraph(f"There {verb} {num_articles} {word}:")
+ doc.add_paragraph(f"There {verb} {pluralize(num_articles, 'article')}:")
doc.add_block(snakemd.MDList(articles))
else:
log.warning(f"Failed to find any articles for {project}")
@@ -61,8 +60,7 @@ def add_language_article_section(doc: snakemd.Document, repo: subete.Repo, langu
doc.add_heading("Articles", level=2)
num_articles = len(list(repo[language]))
verb = is_are(num_articles)
- word = pluralize(num_articles, "article")
- doc.add_paragraph(f"There {verb} {num_articles} {word}:")
+ doc.add_paragraph(f"There {verb} {pluralize(num_articles, 'article')}:")
articles = []
for program in repo[language]:
diff --git a/scripts/utils/plural.py b/scripts/utils/plural.py
index d8e47a3b77..bc5b02c212 100644
--- a/scripts/utils/plural.py
+++ b/scripts/utils/plural.py
@@ -14,7 +14,7 @@ def select(count: int, singular: str, plural: str) -> str:
def pluralize(count: int, singular: str, plural: str | None = None) -> str:
- """Return the appropriate singular or plural form based on a count.
+ """Return the count and appropriate word form based on a count.
If `plural` is not provided, it defaults to the singular form plus "s".
@@ -25,10 +25,12 @@ def pluralize(count: int, singular: str, plural: str | None = None) -> str:
defaults to `singular + "s"`.
Returns:
- The correct form (singular or plural) based on `count`.
+ A string combining the count and the correct word form (e.g.,
+ "1 project", "5 projects").
"""
- return select(count, singular, plural or f"{singular}s")
+ word = select(count, singular, plural or f"{singular}s")
+ return f"{count} {word}"
def is_are(count: int) -> str:
From 3f0a068dae2587e909ced2232851cbd330b6dce0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:47:10 +0300
Subject: [PATCH 17/26] Refactor projects.py
---
scripts/generators/projects.py | 199 ++++++++++++++++++++-------------
1 file changed, 120 insertions(+), 79 deletions(-)
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
index ef89390f20..7224d066ff 100644
--- a/scripts/generators/projects.py
+++ b/scripts/generators/projects.py
@@ -1,5 +1,5 @@
-import datetime
import logging
+from collections import defaultdict
from pathlib import Path
import snakemd
@@ -13,69 +13,89 @@
from markdown.sections import add_section, add_testing_section
from repo.queries import get_program_datetimes
from utils.files import mkdir
+from utils.plural import is_are, pluralize
log = logging.getLogger(__name__)
+DOCS_PROJECTS_DIR = Path("docs/projects")
+
def generate_project_paths(repo: subete.Repo) -> None:
- """Creates the project directory which contains all of the project folders
- and index.md files.
+ """Creates the project directories and triggers individual index generation.
+
+ Args:
+ repo: The subete Repository instance to pull data from.
- :param subete.Repo repo: the repo to pull from.
"""
- projects = repo.approved_projects()
- projects.sort(key=lambda x: x.name().casefold())
+ projects = sorted(repo.approved_projects(), key=lambda x: x.name().casefold())
+ project_datetime_map = _map_program_datetimes_by_project(repo)
+ num_projects = len(projects)
+
for i, project in enumerate(projects):
- project: subete.Project
- log.info("Generating project paths for %s", str(project))
- _ = mkdir(f"docs/projects/{project.pathlike_name()}")
- generate_project_index(repo, project, projects[i - 1], projects[(i + 1) % len(projects)])
+ log.info("Generating project paths for %s", project)
+
+ target_dir = DOCS_PROJECTS_DIR / project.pathlike_name()
+ mkdir(target_dir)
+
+ prev_project = projects[i - 1]
+ next_project = projects[(i + 1) % num_projects]
+
+ generate_project_index(
+ repo=repo,
+ project=project,
+ prev_project=prev_project,
+ next_project=next_project,
+ target_dir=target_dir,
+ program_times=project_datetime_map.get(project.name(), []),
+ )
def generate_project_index(
repo: subete.Repo,
project: subete.Project,
- previous: subete.Project,
- next: subete.Project,
+ prev_project: subete.Project,
+ next_project: subete.Project,
+ target_dir: Path,
+ program_times: list,
) -> None:
- """Creates an index file for a single project. The path is assumed
- to be `projects/project/index.md`.
-
- :param subete.Repo repo: the repo to pull from.
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :param subete.Project previous: the previous project alphabetically.
- :param subete.Project next: the next project alphabetically.
+ """Creates a markdown index file for a single project.
+
+ Args:
+ repo: The subete Repository instance.
+ project: The target subete Project instance to document.
+ prev_project: The alphabetically previous project instance.
+ next_project: The alphabetically next project instance.
+ target_dir: The resolve Path destination directory for the index.
+ program_times: Pre-collected program datetimes matching this project.
+
"""
- doc: snakemd.Document = snakemd.new_doc()
- project_name: str = project.name()
- times: list[datetime.datetime | None] = [project.doc_created(), project.doc_modified()]
- for language in repo:
- for program in language:
- if program.project_name() == project_name:
- times += get_program_datetimes(program)
+ path_name = project.pathlike_name()
+ doc = snakemd.new_doc()
+
+ times = [project.doc_created(), project.doc_modified()] + program_times
generate_front_matter(
doc,
project.name(),
image=find_project_image(project),
times=times,
- tags=[project.pathlike_name()],
+ tags=[path_name],
)
- generate_no_edit_note(doc, "projects", project.pathlike_name(), PROJECT_MD_FILENAMES)
+ generate_no_edit_note(doc, "projects", path_name, PROJECT_MD_FILENAMES)
+
doc.add_paragraph(
f"Welcome to the {project.name()} page! Here, you'll find a description "
- f"of the project as well as a list of sample programs "
- f"written in various languages.",
+ f"of the project as well as a list of sample programs written in various languages.",
)
- doc_authors: set[str] = project.doc_authors()
- if doc_authors:
+
+ if doc_authors := project.doc_authors():
doc.add_paragraph("This article was written by:")
add_authors_to_doc(doc, doc_authors)
- add_section(doc, "projects", project.pathlike_name(), "Description")
- add_section(doc, "projects", project.pathlike_name(), "Requirements")
- add_testing_section(doc, "projects", project.pathlike_name())
+ add_section(doc, "projects", path_name, "Description")
+ add_section(doc, "projects", path_name, "Requirements")
+ add_testing_section(doc, "projects", path_name)
+
if not project.has_testing():
doc.add_block(
snakemd.Paragraph(
@@ -88,44 +108,25 @@ def generate_project_index(
add_project_article_section(doc, repo, project)
doc.add_horizontal_rule()
- doc.add_paragraph('')
- doc.add_paragraph('')
- doc.add_block(
- snakemd.Paragraph(
- [
- snakemd.Inline(
- f"<-- Previous Project ({previous})",
- link=previous.requirements_url(),
- ),
- ],
- ),
- )
- doc.add_paragraph("
")
- doc.add_paragraph('')
- doc.add_block(
- snakemd.Paragraph(
- [snakemd.Inline(f"Next Project ({next}) -->", link=next.requirements_url())],
- ),
- )
- doc.add_paragraph("
")
- doc.add_paragraph(" ")
- doc.dump("index", directory=f"docs/projects/{project.pathlike_name()}")
+
+ _add_navigation_footer(doc, prev_project, next_project)
+
+ doc.dump("index", directory=str(target_dir))
def generate_projects_index(repo: subete.Repo) -> None:
- """Generate index.md for file for Projects page
+ """Generates the comprehensive master index.md for the main Projects page.
+
+ Args:
+ repo: The subete Repository instance to pull data from.
- :param subete.Repo repo: the repo to pull from.
"""
log.info("Generating projects index")
- projects_index_path = Path("docs/projects")
- projects_index: snakemd.Document = snakemd.new_doc()
- times: list[datetime.datetime | None] = []
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- program: subete.SampleProgram
- times += get_program_datetimes(program)
+ projects_index = snakemd.new_doc()
+
+ times = [
+ dt for language in repo for program in language for dt in get_program_datetimes(program)
+ ]
generate_front_matter(
projects_index,
@@ -133,22 +134,62 @@ def generate_projects_index(repo: subete.Repo) -> None:
times=times,
image=get_default_project_image(),
)
- project_tests = sum(1 if project.has_testing() else 0 for project in repo.approved_projects())
+
+ sorted_projects = sorted(repo.approved_projects(), key=lambda x: x.name().casefold())
+ num_projects = len(sorted_projects)
+ project_tests = sum(1 for project in sorted_projects if project.has_testing())
+
projects_index.add_paragraph(
"Welcome to the Projects page! Here, you'll find a list of all of the projects represented in the collection. "
- f"At this time, the repo supports {repo.total_approved_projects()} projects, of which {project_tests} are tested.",
+ f"At this time, the repo supports {pluralize(num_projects, 'project')}, of which "
+ f"{project_tests} {is_are(project_tests)} tested.",
)
projects_index.add_heading("Projects List", level=2)
projects_index.add_paragraph(
"To help you navigate the collection, the following projects are organized alphabetically.",
)
- repo.approved_projects().sort(key=lambda x: x.name().casefold())
- projects = [
- snakemd.Inline(
- project.name(),
- link=project.requirements_url(),
- )
- for project in repo.approved_projects()
+
+ project_links = [
+ snakemd.Inline(project.name(), link=project.requirements_url())
+ for project in sorted_projects
]
- projects_index.add_block(snakemd.MDList(projects))
- projects_index.dump("index", directory=str(projects_index_path))
+ projects_index.add_block(snakemd.MDList(project_links))
+ projects_index.dump("index", directory=str(DOCS_PROJECTS_DIR))
+
+
+def _map_program_datetimes_by_project(repo: subete.Repo) -> dict[str, list]:
+ """Helper to group program datetimes by project name in a single pass."""
+ mapping = defaultdict(list)
+ for language in repo:
+ for program in language:
+ mapping[program.project_name()].extend(get_program_datetimes(program))
+ return mapping
+
+
+def _add_navigation_footer(
+ doc: snakemd.Document,
+ prev_proj: subete.Project,
+ next_proj: subete.Project,
+) -> None:
+ """Appends the custom HTML/Markdown pagination elements to the bottom of the document."""
+ doc.add_paragraph('')
+ doc.add_paragraph('')
+ doc.add_block(
+ snakemd.Paragraph(
+ [
+ snakemd.Inline(
+ f"<-- Previous Project ({prev_proj})",
+ link=prev_proj.requirements_url(),
+ ),
+ ],
+ ),
+ )
+ doc.add_paragraph("
")
+ doc.add_paragraph('')
+ doc.add_block(
+ snakemd.Paragraph(
+ [snakemd.Inline(f"Next Project ({next_proj}) -->", link=next_proj.requirements_url())],
+ ),
+ )
+ doc.add_paragraph("
")
+ doc.add_paragraph(" ")
From 16b3c3b3e9fee21920e7114c5867f978f575950f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:51:20 +0300
Subject: [PATCH 18/26] Split generate_main_page into multiple helpers
---
scripts/generators/main_page.py | 94 +++++++++++++++++++++------------
1 file changed, 61 insertions(+), 33 deletions(-)
diff --git a/scripts/generators/main_page.py b/scripts/generators/main_page.py
index b5259cb882..370af7fd91 100644
--- a/scripts/generators/main_page.py
+++ b/scripts/generators/main_page.py
@@ -1,53 +1,87 @@
import datetime
import logging
+from typing import NamedTuple
import snakemd
import subete
from markdown.front_matter import generate_front_matter
from repo.queries import get_program_datetimes
+from utils.plural import pluralize
log = logging.getLogger(__name__)
+class RepoMetrics(NamedTuple):
+ """Container for aggregated repository metadata metrics."""
+
+ authors: set[str]
+ times: list[datetime.datetime | None]
+ num_articles: int
+
+
def generate_main_page(repo: subete.Repo) -> None:
- """Generate the main page.
+ """Orchestrates the generation and export of the master documentation main page.
+
+ Args:
+ repo: The subete Repository instance to aggregate documentation metrics from.
- :param subete.Repo repo: the repo to pull from.
"""
+ log.info("Generating main page")
+
+ metrics = _collect_repo_metrics(repo)
+
+ main_page = snakemd.new_doc()
+ generate_front_matter(
+ main_page,
+ "Sample Programs in Every Language",
+ times=metrics.times,
+ )
+
+ articles_str = pluralize(metrics.num_articles, "article")
+ authors_str = pluralize(len(metrics.authors), "author")
+
+ main_page.add_paragraph(
+ "Welcome to Sample Programs in Every Language, a collection of code snippets "
+ "in as many languages as possible. Thanks for taking an interest in our collection "
+ f"which currently contains {articles_str} written by {authors_str}.",
+ )
+
+ welcome_links_paragraph = _build_welcome_paragraph()
+ main_page.add_paragraph(str(welcome_links_paragraph))
+
+ try:
+ main_page.dump("index", "docs")
+ except Exception:
+ log.exception("Failed to write docs/index")
+
+
+def _collect_repo_metrics(repo: subete.Repo) -> RepoMetrics:
+ """Aggregates times, unique author counts, and structural article metrics in a single pass."""
authors: set[str] = set()
times: list[datetime.datetime | None] = []
num_articles = 0
+
for language in repo:
- language: subete.LanguageCollection
- num_articles += 1 # 1 article per language
+ num_articles += 1
authors |= language.doc_authors()
- times += [language.doc_created(), language.doc_modified()]
- program: subete.SampleProgram
+ times.extend([language.doc_created(), language.doc_modified()])
+
for program in language:
+ num_articles += 1
authors |= program.authors() | program.doc_authors()
- times += get_program_datetimes(program)
-
- num_articles += 1 # 1 article per sample program
+ times.extend(get_program_datetimes(program))
for project in repo.approved_projects():
- num_articles += 1 # 1 article per approved project
- project: subete.Project
+ num_articles += 1
authors |= project.doc_authors()
- times += [project.doc_created(), project.doc_modified()]
+ times.extend([project.doc_created(), project.doc_modified()])
- log.info("Generating main page")
- main_page: snakemd.Document = snakemd.new_doc()
- generate_front_matter(
- main_page,
- "Sample Programs in Every Language",
- times=times,
- )
- main_page.add_paragraph(
- "Welcome to Sample Programs in Every Language, a collection of code snippets "
- "in as many languages as possible. Thanks for taking an interest in our collection "
- f"which currently contains {num_articles} articles written by {len(authors)} authors.",
- )
- paragraph = snakemd.Paragraph(
+ return RepoMetrics(authors=authors, times=times, num_articles=num_articles)
+
+
+def _build_welcome_paragraph() -> snakemd.Paragraph:
+ """Constructs and structures the complex collection of inline documentation links."""
+ return snakemd.Paragraph(
[
snakemd.Inline(
"If you'd like to contribute to this growing collection, check out our ",
@@ -63,12 +97,11 @@ def generate_main_page(repo: subete.Repo) -> None:
snakemd.Inline(" and by "),
snakemd.Inline("language", link="/languages"),
snakemd.Inline(
- ". If you don't find what you're look for, check out our list of related ",
+ ". If you don't find what you're looking for, check out our list of related ",
),
snakemd.Inline("open-source projects", link="/related"),
snakemd.Inline(
- ". Finally, if code isn't your thing but you'd still like to help, there are plenty "
- "of other ways to ",
+ ". Finally, if code isn't your thing but you'd still like to help, there are plenty of other ways to ",
),
snakemd.Inline(
"support the project",
@@ -77,8 +110,3 @@ def generate_main_page(repo: subete.Repo) -> None:
snakemd.Inline("."),
],
)
- main_page.add_paragraph(str(paragraph))
- try:
- main_page.dump("index", "docs")
- except Exception:
- log.exception("Failed to write docs/index")
From 87621b62e4202067ee637f57a6ee86d30286eb06 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 03:59:55 +0300
Subject: [PATCH 19/26] Optimize and refactor languages.py
---
scripts/generators/languages.py | 208 +++++++++++++++-----------------
scripts/utils/plural.py | 2 +-
2 files changed, 100 insertions(+), 110 deletions(-)
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index 3d0377b98e..bf267772d2 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -1,4 +1,3 @@
-import datetime
import logging
from pathlib import Path
@@ -18,23 +17,44 @@
log = logging.getLogger(__name__)
+DOCS_LANGUAGES_DIR = Path("docs/languages")
-def generate_language_index(repo: subete.Repo, language: subete.LanguageCollection) -> None:
- """Creates a language file for a single language. The path is assumed
- to be `languages/language/index.md`.
- :param subete.LanguageCollection language: the collection sample programs for a language.
+def generate_language_paths(repo: subete.Repo) -> None:
+ """Creates the individual language directories and indexes.
+
+ Args:
+ repo: The subete Repository instance to pull data from.
+
+ """
+ for language in repo:
+ log.info("Generating language paths for %s", language)
+ target_dir = DOCS_LANGUAGES_DIR / language.pathlike_name()
+ _ = mkdir(target_dir)
+ generate_language_index(repo, language, target_dir)
+
+
+def generate_language_index(
+ repo: subete.Repo,
+ language: subete.LanguageCollection,
+ target_dir: Path,
+) -> None:
+ """Creates a markdown language documentation index file for a single language.
+
+ Args:
+ repo: The subete Repository instance.
+ language: The targeted subete LanguageCollection.
+ target_dir: The target Path directory to dump files into.
+
"""
- doc: snakemd.Document = snakemd.new_doc()
- times: list[datetime.datetime | None] = []
- for program in language:
- program: subete.SampleProgram
- times += get_program_datetimes(program)
+ doc = snakemd.new_doc()
- times += [language.doc_created(), language.doc_modified()]
+ times = [dt for program in language for dt in get_program_datetimes(program)]
+ times.extend([language.doc_created(), language.doc_modified()])
- doc_authors: set[str] = language.doc_authors()
+ doc_authors = language.doc_authors()
language_escaped = markdown_escape(language.name())
+
generate_front_matter(
doc,
f"The {language} Programming Language",
@@ -44,67 +64,37 @@ def generate_language_index(repo: subete.Repo, language: subete.LanguageCollecti
tags=[language.pathlike_name()],
)
generate_no_edit_note(doc, "languages", language.pathlike_name(), LANGUAGE_MD_FILENAMES)
+
doc.add_paragraph(
f"Welcome to the {language_escaped} page! Here, you'll find a description "
- f"of the language as well as a list of sample programs "
- f"in that language.",
+ f"of the language as well as a list of sample programs in that language.",
)
+
if doc_authors:
doc.add_paragraph("This article was written by:")
add_authors_to_doc(doc, doc_authors)
add_section(doc, "languages", language.pathlike_name(), "Description")
add_language_article_section(doc, repo, str(language))
+
try:
- doc.dump("index", directory=f"docs/languages/{language.pathlike_name()}")
+ doc.dump("index", directory=str(target_dir))
except Exception:
- log.exception(f"Failed to write {language.pathlike_name()}")
-
-
-def generate_language_paths(repo: subete.Repo) -> None:
- """Creates the language directory which contains all of the language folders
- and index.md files.
-
- :param subete.Repo repo: the repo to pull from.
- """
- for language in repo:
- log.info("Generating language paths for %s", str(language))
- language: subete.LanguageCollection
- _ = mkdir(f"docs/languages/{language.pathlike_name()}")
- generate_language_index(repo, language)
-
-
-def get_language_letter_links(repo: subete.Repo) -> str:
- # Have to use raw HTML for this since there is no way to add a class attribute
- # in Markdown
- language_letter_links = (
- [
- '',
- ]
- + [
- f' {letter.upper()} '
- for letter in repo.sorted_language_letters()
- ]
- + [
- " ",
- ]
- )
- return "\n".join(language_letter_links)
+ log.exception("Failed to write %s", language.pathlike_name())
def generate_languages_index(repo: subete.Repo) -> None:
- """Creates the index.md files for the root directories.
+ """Creates the central main index.md landing page for all Programming Languages.
+
+ Args:
+ repo: The subete Repository instance.
- :param subete.Repo repo: the repo to pull from.
"""
log.info("Generating language index")
- language_index_path = Path("docs/languages")
- times: list[datetime.datetime | None] = []
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- program: subete.SampleProgram
- times += get_program_datetimes(program)
+
+ times = [
+ dt for language in repo for program in language for dt in get_program_datetimes(program)
+ ]
language_index = snakemd.new_doc()
generate_front_matter(
@@ -113,21 +103,19 @@ def generate_languages_index(repo: subete.Repo) -> None:
times=times,
image=get_default_language_image(),
)
- num_languages = len(list(repo))
- verb = is_are(num_languages)
- languages_str = pluralize(num_languages, "language")
+
+ total_languages = len(list(repo))
welcome_text = (
"Welcome to the Languages page! Here, you'll find a list of all of the languages represented in the collection. "
- f"At this time, there {verb} {languages_str}, of which {repo.total_tests()} are tested"
+ f"At this time, there {is_are(total_languages)} {pluralize(total_languages, 'language')}, "
+ f"of which {repo.total_tests()} are tested"
)
- untestables = repo.total_untestables()
- if untestables:
- verb_untestables = is_are(untestables)
- welcome_text += f", {untestables} {verb_untestables} untestable"
-
- num_programs = repo.total_programs()
- snippets_str = pluralize(num_programs, "code snippet")
- welcome_text += f", and {snippets_str}."
+
+ if untestables := repo.total_untestables():
+ welcome_text += f", {untestables} {is_are(untestables)} untestable"
+
+ total_programs = repo.total_programs()
+ welcome_text += f", and {pluralize(total_programs, 'code snippet')}."
language_index.add_paragraph(welcome_text)
language_index.add_heading("Language Breakdown", level=2)
@@ -151,38 +139,48 @@ def generate_languages_index(repo: subete.Repo) -> None:
for letter in repo.sorted_language_letters():
language_index.add_heading(letter.upper(), level=3)
- languages: list[subete.LanguageCollection] = repo.languages_by_letter(letter)
- snippets = sum(language.total_programs() for language in languages)
- tests = sum(1 if language.has_testinfo() else 0 for language in languages)
- untestables = sum(1 if language.has_untestable_info() else 0 for language in languages)
- verb = is_are(tests)
+ languages = repo.languages_by_letter(letter)
+
+ snippets = sum(lang.total_programs() for lang in languages)
+ tests = sum(1 for lang in languages if lang.has_testinfo())
+ letter_untestables = sum(1 for lang in languages if lang.has_untestable_info())
+
num_languages = len(languages)
- languages_str = pluralize(num_languages, "language", plural="languages")
- verb_untestables = is_are(untestables)
language_statement = (
- f"The '{letter.upper()}' collection contains {languages_str}, "
- f"of which {tests} {verb} tested"
+ f"The '{letter.upper()}' collection contains {pluralize(num_languages, 'language')}, "
+ f"of which {tests} {is_are(tests)} tested"
+ )
+ if letter_untestables:
+ language_statement += f", {letter_untestables} {is_are(letter_untestables)} untestable"
+
+ language_index.add_paragraph(
+ f"{language_statement}, and {pluralize(snippets, 'code snippet')}.",
)
- if untestables:
- language_statement += f", {untestables} {verb_untestables} untestable"
- snippets_str = pluralize(snippets, "code snippet")
- language_index.add_paragraph(f"{language_statement}, and {snippets_str}.")
languages.sort(key=lambda x: x.name().casefold())
languages_list = [get_language_link_and_testability(x) for x in languages]
language_index.add_block(snakemd.MDList(languages_list))
language_index.add_block(snakemd.Paragraph(return_to_top))
- language_index.dump("index", directory=str(language_index_path))
+ language_index.dump("index", directory=str(DOCS_LANGUAGES_DIR))
-def get_language_link_and_testability(
- language: subete.LanguageCollection,
-) -> snakemd.Paragraph:
+def get_language_letter_links(repo: subete.Repo) -> str:
+ """Generates the sticky HTML fast-navigation link grid categorized by letter."""
+ links = [
+ f' {l.upper()} '
+ for l in repo.sorted_language_letters()
+ ]
+ return "\n".join(['"])
+
+
+def get_language_link_and_testability(language: subete.LanguageCollection) -> snakemd.Paragraph:
+ """Generates a dynamic markdown paragraph item containing metadata markers for a language link."""
language_escaped = markdown_escape(language.name())
language_link = snakemd.Inline(language_escaped, link=language.lang_docs_url())
- num_programs = language.total_programs()
- phrase = pluralize(num_programs, "code snippet")
+
+ phrase = pluralize(language.total_programs(), "code snippet")
+
if language.has_testinfo():
return snakemd.Paragraph([language_link, f" ({phrase})"])
@@ -193,40 +191,32 @@ def get_language_link_and_testability(
")",
]
else:
- testability = [snakemd.Inline(f" {phrase}, (untested)")]
+ testability = [snakemd.Inline(f" ({phrase}, untested)")]
return snakemd.Paragraph([language_link] + testability)
-def generate_language_breakdown_percentage(repo: subete.Repo, doc: snakemd.Document):
+def generate_language_breakdown_percentage(repo: subete.Repo, doc: snakemd.Document) -> None:
+ """Renders the stylized, expanded HTML table displaying language layout distributions."""
language_info = sorted(
- ((language.name(), language.percentage(), language.color()) for language in repo),
+ ((lang.name(), lang.percentage(), lang.color()) for lang in repo),
key=lambda x: (-x[1], x[0]),
)
- max_language_percentage = language_info[0][1]
+ max_percentage = language_info[0][1] if language_info else 1.0
doc.add_paragraph("Here are the percentages for each language in the collection:")
doc.add_raw(
- """\
-
-Click here to expand or collapse...
-""",
+ '\nClick here to expand or collapse... \n',
)
- for language_name, percentage, color in language_info:
- bar_graph_width = 100.0 * percentage / max_language_percentage
- bar_graph_style = f"width: {bar_graph_width:.2f}%; background-color: {color};"
+ for name, percentage, color in language_info:
+ bar_width = 100.0 * percentage / max_percentage
doc.add_raw(
- f"""\
-
- {language_name}
- {percentage:.2f}%
-
- """,
+ f" \n"
+ f' {name} \n'
+ f' {percentage:.2f}% \n'
+ f'
\n'
+ f" ",
)
- doc.add_raw(
- """\
-
- """,
- )
+ doc.add_raw("
\n ")
diff --git a/scripts/utils/plural.py b/scripts/utils/plural.py
index bc5b02c212..5d8ed613a4 100644
--- a/scripts/utils/plural.py
+++ b/scripts/utils/plural.py
@@ -25,7 +25,7 @@ def pluralize(count: int, singular: str, plural: str | None = None) -> str:
defaults to `singular + "s"`.
Returns:
- A string combining the count and the correct word form (e.g.,
+ A string combining the count and the correct word form (e.g.,
"1 project", "5 projects").
"""
From c94a8b4aa6bbff526fb92c27ab815d5a9052d91b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 04:05:49 +0300
Subject: [PATCH 20/26] Optimize article generation
---
scripts/markdown/articles.py | 63 ++++++++++++++++++++----------------
1 file changed, 35 insertions(+), 28 deletions(-)
diff --git a/scripts/markdown/articles.py b/scripts/markdown/articles.py
index 3b5f80a33e..15ff95335a 100644
--- a/scripts/markdown/articles.py
+++ b/scripts/markdown/articles.py
@@ -15,36 +15,40 @@ def add_project_article_section(
) -> None:
"""Generates a list of articles for each project page.
- :param snakemd.Document doc: the document to add the section to.
- :param subete.Repo repo: the repo to pull from.
- :param subete.Project project: the project to add to the document.
+ Args:
+ doc: The document to add the section to.
+ repo: The subete Repo instance to pull from.
+ project: The target subete Project to find associated language articles for.
+
"""
- log.info(f"Generating article section of {project}")
+ log.info("Generating article section of %s", project)
doc.add_heading("Articles", level=2)
+
articles = []
+ project_name = project.name()
+
for lang in repo:
try:
- program: subete.SampleProgram = lang[project.name()]
+ program = lang[project_name]
except KeyError:
continue
program_escaped = markdown_escape(str(program))
- link = snakemd.Inline(
- program_escaped,
- link=program.documentation_url(),
+ articles.append(
+ snakemd.Inline(
+ program_escaped,
+ link=program.documentation_url(),
+ ),
)
- articles.append(link)
- num_articles = len(articles)
- if num_articles > 0:
+ if num_articles := len(articles):
verb = is_are(num_articles)
doc.add_paragraph(f"There {verb} {pluralize(num_articles, 'article')}:")
doc.add_block(snakemd.MDList(articles))
else:
- log.warning(f"Failed to find any articles for {project}")
- doc.add_paragraph(
- "No articles available. Please consider contributing.",
- ).insert_link(
+ log.warning("Failed to find any articles for %s", project)
+ paragraph = doc.add_paragraph("No articles available. Please consider contributing.")
+ paragraph.insert_link(
"Please consider contributing",
"https://github.com/TheRenegadeCoder/sample-programs-website",
)
@@ -53,21 +57,24 @@ def add_project_article_section(
def add_language_article_section(doc: snakemd.Document, repo: subete.Repo, language: str) -> None:
"""Generates a list of articles for each language page.
- :param snakemd.Document doc: the document to add the section to.
- :param subete.Repo repo: the repo to pull from.
- :param str language: the language to add to the document in its lookup form (e.g., Python).
+ Args:
+ doc: The document to add the section to.
+ repo: The subete Repo instance to pull from.
+ language: The exact lookup string representation of the targeted language (e.g., "Python").
+
"""
doc.add_heading("Articles", level=2)
- num_articles = len(list(repo[language]))
- verb = is_are(num_articles)
- doc.add_paragraph(f"There {verb} {pluralize(num_articles, 'article')}:")
- articles = []
- for program in repo[language]:
- program_escaped = markdown_escape(str(program))
- link = snakemd.Inline(
- program_escaped,
- link=program._sample_program_doc_url,
+ articles = [
+ snakemd.Inline(
+ markdown_escape(str(program)),
+ link=program.documentation_url(),
)
- articles.append(link)
+ for program in repo[language]
+ ]
+
+ num_articles = len(articles)
+ verb = is_are(num_articles)
+
+ doc.add_paragraph(f"There {verb} {pluralize(num_articles, 'article')}:")
doc.add_block(snakemd.MDList(articles))
From 55a3a67714cc31d980164ecccc1b2e1cbdc64415 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 04:07:29 +0300
Subject: [PATCH 21/26] Make front matter generation more robust
---
scripts/markdown/front_matter.py | 47 ++++++++++++++++++++------------
1 file changed, 30 insertions(+), 17 deletions(-)
diff --git a/scripts/markdown/front_matter.py b/scripts/markdown/front_matter.py
index ff5b71af22..36e13dd94e 100644
--- a/scripts/markdown/front_matter.py
+++ b/scripts/markdown/front_matter.py
@@ -1,28 +1,33 @@
import datetime
+import logging
from collections.abc import Iterable
import snakemd
import yaml
from utils.text import split_text
+log = logging.getLogger(__name__)
+
def generate_front_matter(
doc: snakemd.Document,
title: str,
- times: list[datetime.datetime | None] | None = None,
+ times: Iterable[datetime.datetime | None] | None = None,
image: str | None = None,
- authors: set[str] | None = None,
+ authors: Iterable[str] | None = None,
tags: Iterable[str] | None = None,
) -> None:
- """Generates front matter and adds it to the document.
-
- :param snakemd.Document doc: the document to add the front matter to.
- :param str title: the title of the document.
- :param Optional[List[Optional[datetime.datetime]]] times: optional list of
- date/times that may be `None`.
- :param str image: optional filename of the image.
- :param Set[str] authors: optional list of authors
- :param Iterable[str] tags: optional list of tags
+ """Generates YAML front matter block and appends it to the SnakeMD document.
+
+ Args:
+ doc: The snakemd Document instance to add the front matter to.
+ title: The master title text string of the document page.
+ times: An optional sequence of datetime markers used to derive
+ creation and modification thresholds.
+ image: Optional asset path filename for the page's featured banner image.
+ authors: An optional collection of authors to sort and embed.
+ tags: An optional collection of category tags to sort and embed.
+
"""
top_title, bottom_title = split_text(title)
@@ -33,10 +38,10 @@ def generate_front_matter(
"layout": "default",
}
- filtered_times = list(filter(None, times or []))
- if filtered_times:
- front_matter["date"] = min(filtered_times).date()
- front_matter["last-modified"] = max(filtered_times).date()
+ if times and (filtered_times := [t for t in times if t is not None]):
+ sorted_times = sorted(filtered_times)
+ front_matter["date"] = sorted_times[0].date()
+ front_matter["last-modified"] = sorted_times[-1].date()
if image:
front_matter["featured-image"] = image
@@ -47,5 +52,13 @@ def generate_front_matter(
if tags:
front_matter["tags"] = sorted(tags, key=str.casefold)
- yaml_block = yaml.safe_dump(front_matter, sort_keys=True, allow_unicode=True)
- doc.add_raw(f"---\n{yaml_block}---")
+ try:
+ yaml_block = yaml.safe_dump(
+ front_matter,
+ sort_keys=True,
+ allow_unicode=True,
+ default_flow_style=False,
+ )
+ doc.add_raw(f"---\n{yaml_block}---")
+ except Exception:
+ log.exception("Failed to safely serialize or dump Markdown front matter metadata block.")
From 80cf42dfbf73e66223cd91d6d6321eaa977a0c57 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 04:09:09 +0300
Subject: [PATCH 22/26] Generate "DO NOT EDIT" in a cleaner way
---
scripts/markdown/note.py | 28 +++++++++++++++-------------
1 file changed, 15 insertions(+), 13 deletions(-)
diff --git a/scripts/markdown/note.py b/scripts/markdown/note.py
index 7db1253c2d..4219c35fde 100644
--- a/scripts/markdown/note.py
+++ b/scripts/markdown/note.py
@@ -8,24 +8,26 @@ def generate_no_edit_note(
source_instance: str,
filenames: list[str],
) -> None:
- """Generates "DO NOT EDIT" note
+ """Generates an embedded raw HTML comment warning against manual edits.
+
+ Args:
+ doc: The snakemd Document instance to add the note to.
+ source: The specific source folder category (e.g., "languages").
+ source_instance: The specific folder sub-instance (e.g., "c-plus-plus").
+ filenames: A list of structural markdown filenames to track.
- :param snakemd.Document doc: the document to add the note to.
- :param str source: the specific source folder to pull from (e.g., languages).
- :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
- :param list[str] filenames: the markdown filenames
"""
note_filenames = "\n".join(
f"- sources/{source}/{source_instance}/{filename}" for filename in filenames
)
- note = f"""\
-"
+ )
-{CONTRIBUTING_NOTE}
--->"""
doc.add_raw(note)
From f55b5e177a4a6d8cc257d447990c52ca63ee2f3f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 04:12:46 +0300
Subject: [PATCH 23/26] Add better docstrings and clean up section generation
---
scripts/markdown/sections.py | 72 +++++++++++++++++++++++-------------
1 file changed, 47 insertions(+), 25 deletions(-)
diff --git a/scripts/markdown/sections.py b/scripts/markdown/sections.py
index cffed851ed..5c1957f50e 100644
--- a/scripts/markdown/sections.py
+++ b/scripts/markdown/sections.py
@@ -6,6 +6,8 @@
log = logging.getLogger(__name__)
+SOURCES_DIR = Path("sources")
+
def add_section(
doc: snakemd.Document,
@@ -14,50 +16,70 @@ def add_section(
section: str,
level: int = 2,
) -> None:
- """Adds a section to the document.
+ """Adds a heading and file contents (or a fallback message) to the document.
+
+ Args:
+ doc: The snakemd Document instance to modify.
+ source: The top-level source folder name (e.g., "languages").
+ source_instance: The sub-folder instance name (e.g., "c-plus-plus").
+ section: The section title name to append (e.g., "Description").
+ level: The Markdown heading depth level. Defaults to 2.
- :param snakemd.Document doc: the document to add the section to.
- :param str source: the specific source folder to pull from (e.g., languages).
- :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
- :param str section: the section to add to the document (e.g., Description).
"""
doc.add_heading(section, level=level)
- fp = Path(f"sources/{source}/{source_instance}/{section.lower().replace(' ', '-')}.md")
- if fp.exists():
- log.info(f"Adding {section} section to document from source, {fp}")
- doc.add_raw(fp.read_text(encoding="utf-8"))
+
+ filename = f"{section.lower().replace(' ', '-')}.md"
+ file_path = SOURCES_DIR / source / source_instance / filename
+
+ if file_path.exists():
+ log.info("Adding %s section to document from source: %s", section, file_path)
+ doc.add_raw(file_path.read_text(encoding="utf-8"))
else:
- log.warning(f"Failed to find {section} in {fp}")
- doc.add_paragraph(
+ log.warning("Failed to find %s in %s", section, file_path)
+ paragraph = doc.add_paragraph(
f"No '{section}' section available. Please consider contributing.",
- ).insert_link(
+ )
+ paragraph.insert_link(
"Please consider contributing",
"https://github.com/TheRenegadeCoder/sample-programs-website",
)
def add_testing_section(doc: snakemd.Document, source: str, source_instance: str) -> None:
- valid_path = Path(f"sources/{source}/{source_instance}/valid-tests.md")
- invalid_path = Path(f"sources/{source}/{source_instance}/invalid-tests.md")
- auto_gen_path = Path(f"{AUTO_GEN_TEST_DOC_DIR}/{source_instance}/testing.md")
+ """Appends a contextual testing section based on file availability.
+
+ Args:
+ doc: The snakemd Document instance to modify.
+ source: The source directory context (e.g., "projects").
+ source_instance: The specific project/language target (e.g., "hello-world").
+
+ """
+ instance_path = SOURCES_DIR / source / source_instance
+ valid_path = instance_path / "valid-tests.md"
+ invalid_path = instance_path / "invalid-tests.md"
+
+ auto_gen_base = Path(AUTO_GEN_TEST_DOC_DIR)
+ auto_gen_path = auto_gen_base / source_instance / "testing.md"
+
if auto_gen_path.exists():
add_section(
doc,
- Path(AUTO_GEN_TEST_DOC_DIR).name,
- source_instance,
- "Testing",
+ source=auto_gen_base.name,
+ source_instance=source_instance,
+ section="Testing",
level=2,
)
elif valid_path.exists() and invalid_path.exists():
doc.add_heading("Testing", level=2)
+
+ display_name = source_instance.replace("-", " ").title()
+
doc.add_paragraph(
- f"""
- Every project in the Sample Programs repo should be tested. In this section,
- we specify the set of tests specific to {" ".join(source_instance.split("-")).title()}.
- To keep things simple, we split up testing into two subsets: valid and invalid.
- Valid tests refer to tests that occur under correct input conditions. Invalid
- tests refer to tests that occur on bad input (e.g., letters instead of numbers).
- """,
+ "Every project in the Sample Programs repo should be tested. In this section, "
+ f"we specify the set of tests specific to {display_name}. "
+ "To keep things simple, we split up testing into two subsets: valid and invalid. "
+ "Valid tests refer to tests that occur under correct input conditions. Invalid "
+ "tests refer to tests that occur on bad input (e.g., letters instead of numbers).",
)
add_section(doc, source, source_instance, "Valid Tests", level=3)
add_section(doc, source, source_instance, "Invalid Tests", level=3)
From 94b23e2fb673f51495e58b575184f11d966ac9f1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 04:30:21 +0300
Subject: [PATCH 24/26] Consolidate more constants
---
scripts/assets/image_build.py | 16 +++++----
scripts/assets/image_copy.py | 8 ++---
scripts/assets/image_lookup.py | 13 +++----
scripts/cli.py | 18 ++++++----
scripts/constants.py | 49 ++++++++++++++++++++++-----
scripts/generators/languages.py | 6 ++--
scripts/generators/projects.py | 6 ++--
scripts/generators/sample_programs.py | 8 ++---
scripts/generators/tests.py | 4 +--
scripts/markdown/note.py | 6 ++--
scripts/markdown/sections.py | 11 +++---
11 files changed, 89 insertions(+), 56 deletions(-)
diff --git a/scripts/assets/image_build.py b/scripts/assets/image_build.py
index 7e5e9bfb2e..49df3871ff 100644
--- a/scripts/assets/image_build.py
+++ b/scripts/assets/image_build.py
@@ -11,6 +11,10 @@
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
@@ -131,31 +135,31 @@ def _find_featured_image(dir_path: Path) -> Path | None:
def _language_specs(repo: subete.Repo) -> list[ImageSpec]:
- specs = [ImageSpec(Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)]
+ specs = [ImageSpec(LANGUAGES_DIR, DEFAULT_LANGUAGE_IMAGE_NO_EXT)]
for lang in repo:
name = lang.pathlike_name()
specs.append(
- ImageSpec(Path("sources/languages") / name, f"the-{name}-programming-language"),
+ ImageSpec(LANGUAGES_DIR / name, f"the-{name}-programming-language"),
)
return specs
def _project_specs(repo: subete.Repo) -> list[ImageSpec]:
- specs = [ImageSpec(Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)]
+ specs = [ImageSpec(PROJECTS_DIR, DEFAULT_PROJECT_IMAGE_NO_EXT)]
for project in repo.approved_projects():
name = project.pathlike_name()
specs.append(
- ImageSpec(Path("sources/projects") / name, f"{name}-in-every-language"),
+ ImageSpec(PROJECTS_DIR / name, f"{name}-in-every-language"),
)
return specs
def _program_specs(repo: subete.Repo) -> list[ImageSpec]:
- specs = [ImageSpec(Path("sources"), DEFAULT_PROGRAM_IMAGE_NO_EXT)]
+ specs = [ImageSpec(SOURCE_DIR, DEFAULT_PROGRAM_IMAGE_NO_EXT)]
for lang in repo:
lang_name = lang.pathlike_name()
@@ -164,7 +168,7 @@ def _program_specs(repo: subete.Repo) -> list[ImageSpec]:
proj = program.project_pathlike_name()
specs.append(
ImageSpec(
- Path("sources/programs") / proj / lang_name,
+ PROGRAMS_DIR / proj / lang_name,
f"{proj}-in-{lang_name}",
),
)
diff --git a/scripts/assets/image_copy.py b/scripts/assets/image_copy.py
index 68111546ae..75baaa4175 100644
--- a/scripts/assets/image_copy.py
+++ b/scripts/assets/image_copy.py
@@ -6,12 +6,12 @@
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__)
-SOURCE_DIR = Path("sources")
ASSETS_ROOT = Path("docs/assets/images")
@@ -45,7 +45,7 @@ def _language_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for lang in repo:
name = lang.pathlike_name()
yield CopySpec(
- SOURCE_DIR / "languages" / name,
+ LANGUAGES_DIR / name,
ASSETS_ROOT / "languages" / name,
)
@@ -54,7 +54,7 @@ def _project_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for project in repo.approved_projects():
name = project.pathlike_name()
yield CopySpec(
- SOURCE_DIR / "projects" / name,
+ PROJECTS_DIR / name,
ASSETS_ROOT / "projects" / name,
)
@@ -66,7 +66,7 @@ def _program_specs(repo: subete.Repo) -> Iterable[CopySpec]:
for program in repo[str(lang)]:
proj = program.project_pathlike_name()
yield CopySpec(
- SOURCE_DIR / "programs" / proj / lang_name,
+ PROGRAMS_DIR / proj / lang_name,
ASSETS_ROOT / "projects" / proj / lang_name,
)
diff --git a/scripts/assets/image_lookup.py b/scripts/assets/image_lookup.py
index b8c1badcc0..df55f1f4a9 100644
--- a/scripts/assets/image_lookup.py
+++ b/scripts/assets/image_lookup.py
@@ -2,12 +2,13 @@
from pathlib import Path
import subete
-from constants import DEFAULT_LANGUAGE_IMAGE_NO_EXT, DEFAULT_PROJECT_IMAGE_NO_EXT
-
-BASE_DIR = Path("sources")
-PROJECTS_DIR = BASE_DIR / "projects"
-LANGUAGES_DIR = BASE_DIR / "languages"
-PROGRAMS_DIR = BASE_DIR / "programs"
+from constants import (
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+ LANGUAGES_DIR,
+ PROGRAMS_DIR,
+ PROJECTS_DIR,
+)
@cache
diff --git a/scripts/cli.py b/scripts/cli.py
index c075c11449..c912d55d51 100644
--- a/scripts/cli.py
+++ b/scripts/cli.py
@@ -5,10 +5,16 @@
import subete
from assets.image_build import generate_images
from assets.image_copy import copy_article_images
-from constants import AUTO_GEN_TEST_DOC_DIR
-from generators.languages import generate_language_paths, generate_languages_index
+from constants import DOCS_LANGUAGES_DIR, DOCS_PROJECTS_DIR, GENERATED_DIR
+from generators.languages import (
+ generate_language_paths,
+ generate_languages_index,
+)
from generators.main_page import generate_main_page
-from generators.projects import generate_project_paths, generate_projects_index
+from generators.projects import (
+ generate_project_paths,
+ generate_projects_index,
+)
from generators.sample_programs import generate_sample_programs
from generators.tests import generate_auto_gen_test_docs
from logging_setup import setup_logging
@@ -23,9 +29,9 @@ def main() -> None:
parser.add_argument("--local", "-l", action="store_true", help="Use local contents of website")
parsed_args = parser.parse_args()
- clean("docs/projects")
- clean("docs/languages")
- clean(AUTO_GEN_TEST_DOC_DIR)
+ clean(DOCS_PROJECTS_DIR)
+ clean(DOCS_LANGUAGES_DIR)
+ clean(GENERATED_DIR)
log.info("Loading repos (this may take several minutes)")
website_repo_dir = "." if parsed_args.local else None
diff --git a/scripts/constants.py b/scripts/constants.py
index cd76a86b01..b30b5a0c05 100644
--- a/scripts/constants.py
+++ b/scripts/constants.py
@@ -1,9 +1,40 @@
-AUTO_GEN_TEST_DOC_DIR = "sources/generated"
-DEFAULT_PROGRAM_IMAGE_NO_EXT = "sample-programs-in-every-language"
-DEFAULT_PROJECT_IMAGE_NO_EXT = "programming-projects-in-every-language"
-DEFAULT_LANGUAGE_IMAGE_NO_EXT = "programming-languages"
-AUTO_GEN_NOTE = "AUTO-GENERATED -- PLEASE DO NOT EDIT!"
-CONTRIBUTING_NOTE = "See .github/CONTRIBUTING.md for further details."
-PROGRAM_MD_FILENAMES = ["how-to-implement-the-solution.md", "how-to-run-the-solution.md"]
-PROJECT_MD_FILENAMES = ["description.md", "requirements.md"]
-LANGUAGE_MD_FILENAMES = ["description.md"]
+from pathlib import Path
+
+SOURCE_DIR = Path("sources")
+DOCS_DIR = Path("docs")
+
+ASSETS_DIR = DOCS_DIR / "assets" / "images"
+
+GENERATED_DIR = SOURCE_DIR / "generated"
+LANGUAGES_DIR = SOURCE_DIR / "languages"
+PROGRAMS_DIR = SOURCE_DIR / "programs"
+PROJECTS_DIR = SOURCE_DIR / "projects"
+
+DOCS_LANGUAGES_DIR = DOCS_DIR / "languages"
+DOCS_PROGRAMS_DIR = DOCS_DIR / "projects"
+DOCS_PROJECTS_DIR = DOCS_DIR / "projects"
+
+DEFAULT_IMAGES = {
+ "program": "sample-programs-in-every-language",
+ "project": "programming-projects-in-every-language",
+ "language": "programming-languages",
+}
+
+DEFAULT_PROGRAM_IMAGE_NO_EXT = DEFAULT_IMAGES["program"]
+DEFAULT_PROJECT_IMAGE_NO_EXT = DEFAULT_IMAGES["project"]
+DEFAULT_LANGUAGE_IMAGE_NO_EXT = DEFAULT_IMAGES["language"]
+
+AUTO_GENERATED_NOTICE = "AUTO-GENERATED -- DO NOT EDIT"
+CONTRIBUTING_NOTICE = "See .github/CONTRIBUTING.md for contribution guidelines."
+
+PROGRAM_MD_FILES = (
+ "how-to-implement-the-solution.md",
+ "how-to-run-the-solution.md",
+)
+
+PROJECT_MD_FILES = (
+ "description.md",
+ "requirements.md",
+)
+
+LANGUAGE_MD_FILES = ("description.md",)
diff --git a/scripts/generators/languages.py b/scripts/generators/languages.py
index bf267772d2..8e7f00ffc6 100644
--- a/scripts/generators/languages.py
+++ b/scripts/generators/languages.py
@@ -4,7 +4,7 @@
import snakemd
import subete
from assets.image_lookup import find_language_image, get_default_language_image
-from constants import LANGUAGE_MD_FILENAMES
+from constants import DOCS_LANGUAGES_DIR, LANGUAGE_MD_FILES
from markdown.articles import add_language_article_section
from markdown.authors import add_authors_to_doc
from markdown.front_matter import generate_front_matter
@@ -17,8 +17,6 @@
log = logging.getLogger(__name__)
-DOCS_LANGUAGES_DIR = Path("docs/languages")
-
def generate_language_paths(repo: subete.Repo) -> None:
"""Creates the individual language directories and indexes.
@@ -63,7 +61,7 @@ def generate_language_index(
authors=doc_authors,
tags=[language.pathlike_name()],
)
- generate_no_edit_note(doc, "languages", language.pathlike_name(), LANGUAGE_MD_FILENAMES)
+ generate_no_edit_note(doc, "languages", language.pathlike_name(), list(LANGUAGE_MD_FILES))
doc.add_paragraph(
f"Welcome to the {language_escaped} page! Here, you'll find a description "
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
index 7224d066ff..caf3b8fd01 100644
--- a/scripts/generators/projects.py
+++ b/scripts/generators/projects.py
@@ -5,7 +5,7 @@
import snakemd
import subete
from assets.image_lookup import find_project_image, get_default_project_image
-from constants import PROJECT_MD_FILENAMES
+from constants import DOCS_PROJECTS_DIR, PROJECT_MD_FILES
from markdown.articles import add_project_article_section
from markdown.authors import add_authors_to_doc
from markdown.front_matter import generate_front_matter
@@ -17,8 +17,6 @@
log = logging.getLogger(__name__)
-DOCS_PROJECTS_DIR = Path("docs/projects")
-
def generate_project_paths(repo: subete.Repo) -> None:
"""Creates the project directories and triggers individual index generation.
@@ -81,7 +79,7 @@ def generate_project_index(
times=times,
tags=[path_name],
)
- generate_no_edit_note(doc, "projects", path_name, PROJECT_MD_FILENAMES)
+ generate_no_edit_note(doc, "projects", path_name, list(PROJECT_MD_FILES))
doc.add_paragraph(
f"Welcome to the {project.name()} page! Here, you'll find a description "
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
index 34df1511e4..9c3577149d 100644
--- a/scripts/generators/sample_programs.py
+++ b/scripts/generators/sample_programs.py
@@ -5,7 +5,7 @@
import snakemd
import subete
from assets.image_lookup import find_program_image
-from constants import PROGRAM_MD_FILENAMES
+from constants import DOCS_PROJECTS_DIR, PROGRAM_MD_FILES
from markdown.authors import add_authors_to_doc
from markdown.front_matter import generate_front_matter
from markdown.note import generate_no_edit_note
@@ -16,8 +16,6 @@
log = logging.getLogger(__name__)
-DOCS_PROJECTS_DIR = Path("docs/projects")
-PROGRAMS_BASE_DIR = Path("programs")
DATETIME_FORMAT = "%b %d %Y %H:%M:%S"
@@ -53,7 +51,7 @@ def generate_sample_program_index(program: subete.SampleProgram, path: Path) ->
language_escaped = markdown_escape(program.language_name())
language_docs_url = program.language_collection().lang_docs_url()
- program_root_str = str(PROGRAMS_BASE_DIR / proj_path_name)
+ program_root_str = str(Path("programs") / proj_path_name)
doc = snakemd.new_doc()
_add_front_matter_and_notes(doc, program, proj_path_name, lang_path_name, program_root_str)
@@ -98,7 +96,7 @@ def _add_front_matter_and_notes(
doc,
program_root_str,
lang_path_name,
- PROGRAM_MD_FILENAMES,
+ list(PROGRAM_MD_FILES),
)
diff --git a/scripts/generators/tests.py b/scripts/generators/tests.py
index 2585e0e086..053125ee57 100644
--- a/scripts/generators/tests.py
+++ b/scripts/generators/tests.py
@@ -4,7 +4,7 @@
import glotter
import subete
-from constants import AUTO_GEN_TEST_DOC_DIR
+from constants import GENERATED_DIR
log = logging.getLogger(__name__)
@@ -18,7 +18,7 @@ def generate_auto_gen_test_docs(repo: subete.Repo) -> None:
"""
log.info("Generating test documentation")
- doc_dir = Path(AUTO_GEN_TEST_DOC_DIR).resolve()
+ doc_dir = GENERATED_DIR.resolve()
repo_dir = Path(repo.sample_programs_repo_dir())
# Safely switch directories. The context manager guarantees the original
diff --git a/scripts/markdown/note.py b/scripts/markdown/note.py
index 4219c35fde..e2a6f2a096 100644
--- a/scripts/markdown/note.py
+++ b/scripts/markdown/note.py
@@ -1,5 +1,5 @@
import snakemd
-from constants import AUTO_GEN_NOTE, CONTRIBUTING_NOTE
+from constants import AUTO_GENERATED_NOTICE, CONTRIBUTING_NOTICE
def generate_no_edit_note(
@@ -23,10 +23,10 @@ def generate_no_edit_note(
note = (
f""
)
diff --git a/scripts/markdown/sections.py b/scripts/markdown/sections.py
index 5c1957f50e..9fcb8516c5 100644
--- a/scripts/markdown/sections.py
+++ b/scripts/markdown/sections.py
@@ -1,13 +1,10 @@
import logging
-from pathlib import Path
import snakemd
-from constants import AUTO_GEN_TEST_DOC_DIR
+from constants import GENERATED_DIR, SOURCE_DIR
log = logging.getLogger(__name__)
-SOURCES_DIR = Path("sources")
-
def add_section(
doc: snakemd.Document,
@@ -29,7 +26,7 @@ def add_section(
doc.add_heading(section, level=level)
filename = f"{section.lower().replace(' ', '-')}.md"
- file_path = SOURCES_DIR / source / source_instance / filename
+ file_path = SOURCE_DIR / source / source_instance / filename
if file_path.exists():
log.info("Adding %s section to document from source: %s", section, file_path)
@@ -54,11 +51,11 @@ def add_testing_section(doc: snakemd.Document, source: str, source_instance: str
source_instance: The specific project/language target (e.g., "hello-world").
"""
- instance_path = SOURCES_DIR / source / source_instance
+ instance_path = SOURCE_DIR / source / source_instance
valid_path = instance_path / "valid-tests.md"
invalid_path = instance_path / "invalid-tests.md"
- auto_gen_base = Path(AUTO_GEN_TEST_DOC_DIR)
+ auto_gen_base = GENERATED_DIR
auto_gen_path = auto_gen_base / source_instance / "testing.md"
if auto_gen_path.exists():
From 83026f1f21aec446efa57a2d200f4eac79ac0dfe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 04:42:06 +0300
Subject: [PATCH 25/26] Fix _add_solution_block sometimes treating code blocks
as images
---
scripts/generators/sample_programs.py | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
index 9c3577149d..6927da67db 100644
--- a/scripts/generators/sample_programs.py
+++ b/scripts/generators/sample_programs.py
@@ -129,10 +129,14 @@ def _add_solution_block(
path: Path,
language_escaped: str,
) -> None:
- """Renders either the embedded image asset or raw source code logic block."""
- if program.image_type():
- image_dest = path / Path(program.project_path()).name
- shutil.copy(program.project_path(), image_dest)
+ """Renders either an embedded image asset or a raw source code logic block safely."""
+ VALID_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
+
+ project_path = Path(program.project_path())
+
+ if program.image_type() and project_path.suffix.lower() in VALID_IMAGE_EXTENSIONS:
+ image_dest = path / project_path.name
+ shutil.copy(project_path, image_dest)
image_uri = "/" + "/".join(image_dest.parts[1:])
doc.add_block(
@@ -140,7 +144,10 @@ def _add_solution_block(
)
else:
doc.add_paragraph("{% raw %}")
- doc.add_code(program.code(), lang=language_escaped.lower().replace(" ", "_"))
+ doc.add_code(
+ program.code(),
+ lang=language_escaped.lower().replace(" ", "_"),
+ )
doc.add_paragraph("{% endraw %}")
From 3ecf5bae6244a059d1bcd3b6d311138906608614 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C8=98tefan-Iulian=20Alecu?=
<165364995+pascalecu@users.noreply.github.com>
Date: Mon, 18 May 2026 06:28:43 +0300
Subject: [PATCH 26/26] Backport contextlib.chdir to Python 3.10
---
scripts/generators/tests.py | 23 +++++++++++++++++++----
1 file changed, 19 insertions(+), 4 deletions(-)
diff --git a/scripts/generators/tests.py b/scripts/generators/tests.py
index 053125ee57..5fb3269f28 100644
--- a/scripts/generators/tests.py
+++ b/scripts/generators/tests.py
@@ -1,5 +1,5 @@
-import contextlib
import logging
+import os
from pathlib import Path
import glotter
@@ -8,6 +8,23 @@
log = logging.getLogger(__name__)
+from contextlib import AbstractContextManager
+
+
+class chdir(AbstractContextManager):
+ """Non-thread-safe context manager to change the current working directory."""
+
+ def __init__(self, path) -> None:
+ self.path = path
+ self._old_cwd = []
+
+ def __enter__(self) -> None:
+ self._old_cwd.append(os.getcwd())
+ os.chdir(self.path)
+
+ def __exit__(self, *excinfo) -> None:
+ os.chdir(self._old_cwd.pop())
+
def generate_auto_gen_test_docs(repo: subete.Repo) -> None:
"""Generates automated test documentation using Glotter.
@@ -21,9 +38,7 @@ def generate_auto_gen_test_docs(repo: subete.Repo) -> None:
doc_dir = GENERATED_DIR.resolve()
repo_dir = Path(repo.sample_programs_repo_dir())
- # Safely switch directories. The context manager guarantees the original
- # working directory is restored even if an exception occurs inside the block.
- with contextlib.chdir(repo_dir):
+ with chdir(repo_dir):
glotter.generate_test_docs(
doc_dir=doc_dir,
repo_name="Sample Programs",