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'''{program}''' - ) - ) - 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.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 = [ - '" - ] - 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"""\ - - - - - """ - ) - - doc.add_raw("""\ -
{language_name}{percentage:.2f}%
-
""" - ) - - -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 = ( + [ + '", + ] + ) + 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"""\ + + + + + """, + ) + + doc.add_raw( + """\ +
{language_name}{percentage:.2f}%
+
""", + ) 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.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"""{program}""", + ), + ) + 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"""{program}""", - ), + snakemd.Raw(f'{program}'), ) 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.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('") 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 = ( - [ - '", - ] - ) - 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"""\ - - - - - """, + f" \n" + f' \n' + f' \n' + f' \n' + f" ", ) - doc.add_raw( - """\ -
    {language_name}{percentage:.2f}%
    {name}{percentage:.2f}%
    -
    """, - ) + 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",