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
diff --git a/scripts/assets/image_build.py b/scripts/assets/image_build.py
new file mode 100644
index 0000000000..49df3871ff
--- /dev/null
+++ b/scripts/assets/image_build.py
@@ -0,0 +1,176 @@
+import logging
+import shutil
+import subprocess
+import tempfile
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
+from pathlib import Path
+
+import subete
+from constants import (
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ DEFAULT_PROGRAM_IMAGE_NO_EXT,
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+ LANGUAGES_DIR,
+ PROGRAMS_DIR,
+ PROJECTS_DIR,
+ SOURCE_DIR,
+)
+from utils.files import mkdir
+
+log = logging.getLogger(__name__)
+
+ASSETS_DIR = Path("docs/assets/images")
+LOGO_PATH = ASSETS_DIR / "icon-small.png"
+
+FEATURED_GLOB = "featured-image.*"
+
+
+@dataclass(frozen=True)
+class ImageSpec:
+ src_dir: Path
+ dest_no_ext: str
+
+
+def generate_images(repo: subete.Repo, workers: int = 8) -> int:
+ """Generate all processed images using image-titler.
+
+ Returns:
+ 0 if all succeeded, 1 if any failed.
+
+ """
+ specs = [
+ *_language_specs(repo),
+ *_project_specs(repo),
+ *_program_specs(repo),
+ ]
+
+ failures = _run_parallel(specs, workers=workers)
+ return 1 if failures else 0
+
+
+def _run_parallel(specs: list[ImageSpec], workers: int) -> list[ImageSpec]:
+ failures: list[ImageSpec] = []
+
+ with ThreadPoolExecutor(max_workers=workers) as pool:
+ future_map = {pool.submit(_process_spec, spec): spec for spec in specs}
+
+ for future in as_completed(future_map):
+ spec = future_map[future]
+ try:
+ if not future.result():
+ failures.append(spec)
+ except Exception:
+ log.exception("Unexpected failure: %s", spec)
+ failures.append(spec)
+
+ for spec in failures:
+ log.error("Failed image spec: %s", spec)
+
+ return failures
+
+
+def _process_spec(spec: ImageSpec) -> bool:
+ src_image = _find_featured_image(spec.src_dir)
+ if src_image is None:
+ return True
+
+ dest_path = ASSETS_DIR / f"{spec.dest_no_ext}{src_image.suffix}"
+ _ = mkdir(dest_path.parent)
+
+ log.info("Processing %s -> %s", src_image, dest_path)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_dir = Path(tmp)
+
+ try:
+ subprocess.run(
+ [
+ "image-titler",
+ "--path",
+ str(src_image),
+ "--output",
+ str(tmp_dir),
+ "--logo",
+ str(LOGO_PATH),
+ "--no_title",
+ ],
+ check=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+
+ produced = _pick_output(tmp_dir)
+ if produced is None:
+ log.error("No output produced for %s", src_image)
+ return False
+
+ _safe_move(produced, dest_path)
+ return True
+
+ except subprocess.CalledProcessError:
+ log.exception("image-titler failed for %s", src_image)
+ return False
+
+
+def _pick_output(tmp_dir: Path) -> Path | None:
+ """Pick deterministic output file."""
+ files = sorted(p for p in tmp_dir.iterdir() if p.is_file())
+ return files[0] if files else None
+
+
+def _safe_move(src: Path, dest: Path) -> None:
+ """Move file without crashing on existing destination."""
+ if dest.exists():
+ log.warning("Overwriting existing file: %s", dest)
+ dest.unlink()
+
+ shutil.move(str(src), str(dest))
+
+
+def _find_featured_image(dir_path: Path) -> Path | None:
+ if not dir_path.is_dir():
+ return None
+ return next(dir_path.glob(FEATURED_GLOB), None)
+
+
+def _language_specs(repo: subete.Repo) -> list[ImageSpec]:
+ specs = [ImageSpec(LANGUAGES_DIR, DEFAULT_LANGUAGE_IMAGE_NO_EXT)]
+
+ for lang in repo:
+ name = lang.pathlike_name()
+ specs.append(
+ ImageSpec(LANGUAGES_DIR / name, f"the-{name}-programming-language"),
+ )
+
+ return specs
+
+
+def _project_specs(repo: subete.Repo) -> list[ImageSpec]:
+ specs = [ImageSpec(PROJECTS_DIR, DEFAULT_PROJECT_IMAGE_NO_EXT)]
+
+ for project in repo.approved_projects():
+ name = project.pathlike_name()
+ specs.append(
+ ImageSpec(PROJECTS_DIR / name, f"{name}-in-every-language"),
+ )
+
+ return specs
+
+
+def _program_specs(repo: subete.Repo) -> list[ImageSpec]:
+ specs = [ImageSpec(SOURCE_DIR, DEFAULT_PROGRAM_IMAGE_NO_EXT)]
+
+ for lang in repo:
+ lang_name = lang.pathlike_name()
+
+ for program in repo[str(lang)]:
+ proj = program.project_pathlike_name()
+ specs.append(
+ ImageSpec(
+ PROGRAMS_DIR / proj / lang_name,
+ f"{proj}-in-{lang_name}",
+ ),
+ )
+
+ return specs
diff --git a/scripts/assets/image_copy.py b/scripts/assets/image_copy.py
new file mode 100644
index 0000000000..75baaa4175
--- /dev/null
+++ b/scripts/assets/image_copy.py
@@ -0,0 +1,98 @@
+import logging
+import shutil
+from collections.abc import Iterable
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import dataclass
+from pathlib import Path
+
+import subete
+from constants import LANGUAGES_DIR, PROGRAMS_DIR, PROJECTS_DIR
+from subete import imghdr
+from utils.files import mkdir
+
+log = logging.getLogger(__name__)
+
+ASSETS_ROOT = Path("docs/assets/images")
+
+
+@dataclass(frozen=True)
+class CopySpec:
+ src_dir: Path
+ dest_dir: Path
+
+
+def copy_article_images(repo: subete.Repo) -> None:
+ """Copy all article images (languages, projects, programs)."""
+ specs = _build_specs(repo)
+
+ with ThreadPoolExecutor(max_workers=8) as ex:
+ futures = [ex.submit(_copy_images, spec) for spec in specs]
+
+ for f in as_completed(futures):
+ try:
+ f.result()
+ except Exception:
+ log.exception("Failed copying images")
+
+
+def _build_specs(repo: subete.Repo) -> Iterable[CopySpec]:
+ yield from _language_specs(repo)
+ yield from _project_specs(repo)
+ yield from _program_specs(repo)
+
+
+def _language_specs(repo: subete.Repo) -> Iterable[CopySpec]:
+ for lang in repo:
+ name = lang.pathlike_name()
+ yield CopySpec(
+ LANGUAGES_DIR / name,
+ ASSETS_ROOT / "languages" / name,
+ )
+
+
+def _project_specs(repo: subete.Repo) -> Iterable[CopySpec]:
+ for project in repo.approved_projects():
+ name = project.pathlike_name()
+ yield CopySpec(
+ PROJECTS_DIR / name,
+ ASSETS_ROOT / "projects" / name,
+ )
+
+
+def _program_specs(repo: subete.Repo) -> Iterable[CopySpec]:
+ for lang in repo:
+ lang_name = lang.pathlike_name()
+
+ for program in repo[str(lang)]:
+ proj = program.project_pathlike_name()
+ yield CopySpec(
+ PROGRAMS_DIR / proj / lang_name,
+ ASSETS_ROOT / "projects" / proj / lang_name,
+ )
+
+
+def _copy_images(spec: CopySpec) -> None:
+ if not spec.src_dir.is_dir():
+ return
+
+ images = _list_images(spec.src_dir)
+ if not images:
+ return
+
+ _ = mkdir(spec.dest_dir)
+
+ for src in images:
+ dest = spec.dest_dir / src.name
+ log.info("Copying %s -> %s", src, dest)
+ shutil.copy2(src, dest)
+
+
+def _list_images(src_dir: Path) -> list[Path]:
+ if not src_dir.is_dir():
+ return []
+
+ return [
+ p
+ for p in src_dir.iterdir()
+ if p.is_file() and p.stem != "featured-image" and imghdr.what(p)
+ ]
diff --git a/scripts/assets/image_lookup.py b/scripts/assets/image_lookup.py
new file mode 100644
index 0000000000..df55f1f4a9
--- /dev/null
+++ b/scripts/assets/image_lookup.py
@@ -0,0 +1,118 @@
+from functools import cache
+from pathlib import Path
+
+import subete
+from constants import (
+ DEFAULT_LANGUAGE_IMAGE_NO_EXT,
+ DEFAULT_PROJECT_IMAGE_NO_EXT,
+ LANGUAGES_DIR,
+ PROGRAMS_DIR,
+ PROJECTS_DIR,
+)
+
+
+@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.
+
+ """
+ if directory.is_dir():
+ if img_file := next(directory.glob("featured-image.*"), None):
+ return f"{target_name}{img_file.suffix}"
+
+ return fallback
+
+
+@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.
+
+ """
+ return _resolve_image(PROJECTS_DIR, DEFAULT_PROJECT_IMAGE_NO_EXT)
+
+
+@cache
+def get_default_language_image() -> str | None:
+ """Retrieves the default language image filename.
+
+ Returns:
+ The filename of the default language image if found, otherwise None.
+
+ """
+ return _resolve_image(LANGUAGES_DIR, DEFAULT_LANGUAGE_IMAGE_NO_EXT)
+
+
+@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.
+
+ """
+ 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:
+ """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.
+
+ """
+ name = language.pathlike_name()
+ return _resolve_image(
+ directory=LANGUAGES_DIR / name,
+ target_name=f"the-{name}-programming-language",
+ fallback=get_default_language_image(),
+ )
+
+
+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.
+
+ """
+ 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()),
+ )
diff --git a/scripts/automate.py b/scripts/automate.py
index 1311dd2aa5..2aa71401cd 100644
--- a/scripts/automate.py
+++ b/scripts/automate.py
@@ -1,1119 +1,4 @@
-from typing import Optional, Iterable, List, Set
-import argparse
-import datetime
-import functools
-import logging
-import os
-import pathlib
-import shutil
-import subprocess
-import tempfile
-import sys
-
-import snakemd
-import subete
-import glotter
-import yaml
-from subete import imghdr
-
-log = logging.getLogger("automate")
-AUTO_GEN_TEST_DOC_DIR = "sources/generated"
-
-DEFAULT_PROGRAM_IMAGE_NO_EXT = "sample-programs-in-every-language"
-DEFAULT_PROJECT_IMAGE_NO_EXT = "programming-projects-in-every-language"
-DEFAULT_LANGUAGE_IMAGE_NO_EXT = "programming-languages"
-
-AUTO_GEN_NOTE = "AUTO-GENERATED -- PLEASE DO NOT EDIT!"
-CONTRIBUTING_NOTE = "See .github/CONTRIBUTING.md for further details."
-PROGRAM_MD_FILENAMES = ["how-to-implement-the-solution.md", "how-to-run-the-solution.md"]
-PROJECT_MD_FILENAMES = ["description.md", "requirements.md"]
-LANGUAGE_MD_FILENAMES = ["description.md"]
-
-
-def _add_section(doc: snakemd.Document, source: str, source_instance: str, section: str, level: int = 2):
- """
- Adds a section to the document.
-
- :param snakemd.Document doc: the document to add the section to.
- :param str source: the specific source folder to pull from (e.g., languages).
- :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
- :param str section: the section to add to the document (e.g., Description).
- """
- doc.add_heading(section, level=level)
- fp = pathlib.Path(
- f"sources/{source}/{source_instance}/{section.lower().replace(' ', '-')}.md")
- if fp.exists():
- log.info(f"Adding {section} section to document from source, {fp}")
- doc.add_raw(fp.read_text(encoding="utf-8"))
- else:
- log.warning(f"Failed to find {section} in {fp}")
- doc.add_paragraph(
- f"No '{section}' section available. Please consider contributing."
- ).insert_link("Please consider contributing", "https://github.com/TheRenegadeCoder/sample-programs-website")
-
-
-def _add_testing_section(doc: snakemd.Document, source: str, source_instance: str):
- valid_path = pathlib.Path(f"sources/{source}/{source_instance}/valid-tests.md")
- invalid_path = pathlib.Path(f"sources/{source}/{source_instance}/invalid-tests.md")
- auto_gen_path = pathlib.Path(f"{AUTO_GEN_TEST_DOC_DIR}/{source_instance}/testing.md")
- if auto_gen_path.exists():
- _add_section(
- doc, pathlib.Path(AUTO_GEN_TEST_DOC_DIR).name, source_instance, "Testing", level=2
- )
- elif valid_path.exists() and invalid_path.exists():
- doc.add_heading("Testing", level=2)
- doc.add_paragraph(
- f"""
- Every project in the Sample Programs repo should be tested. In this section,
- we specify the set of tests specific to {" ".join(source_instance.split('-')).title()}.
- To keep things simple, we split up testing into two subsets: valid and invalid.
- Valid tests refer to tests that occur under correct input conditions. Invalid
- tests refer to tests that occur on bad input (e.g., letters instead of numbers).
- """
- )
- _add_section(doc, source, source_instance, "Valid Tests", level=3)
- _add_section(doc, source, source_instance, "Invalid Tests", level=3)
- else:
- _add_section(doc, source, source_instance, "Testing", level=2)
-
-
-def _add_project_article_section(doc: snakemd.Document, repo: subete.Repo, project: subete.Project):
- """
- Generates a list of articles for each project page.
-
- :param snakemd.Document doc: the document to add the section to.
- :param subete.Repo repo: the repo to pull from.
- :param subete.Project project: the project to add to the document.
- """
- log.info(f"Generating article section of {project}")
- doc.add_heading("Articles", level=2)
- articles = []
- for lang in repo:
- try:
- program: subete.SampleProgram = lang[project.name()]
- except KeyError:
- continue
-
- program_escaped = _markdown_escape(str(program))
- link = snakemd.Inline(
- program_escaped,
- link=program.documentation_url()
- )
- articles.append(link)
-
- num_articles = len(articles)
- if num_articles > 0:
- verb = pluralize(num_articles, "is", "are")
- word = pluralize(num_articles, "article")
- doc.add_paragraph(f"There {verb} {num_articles} {word}:")
- doc.add_block(snakemd.MDList(articles))
- else:
- log.warning(f"Failed to find any articles for {project}")
- doc.add_paragraph(
- f"No articles available. Please consider contributing."
- ).insert_link("Please consider contributing", "https://github.com/TheRenegadeCoder/sample-programs-website")
-
-
-def _add_language_article_section(doc: snakemd.Document, repo: subete.Repo, language: str):
- """
- Generates a list of articles for each language page.
-
- :param snakemd.Document doc: the document to add the section to.
- :param subete.Repo repo: the repo to pull from.
- :param str language: the language to add to the document in its lookup form (e.g., Python).
- """
- doc.add_heading("Articles", level=2)
- num_articles = len(list(repo[language]))
- verb = pluralize(num_articles, "is", "are")
- word = pluralize(num_articles, "article")
- doc.add_paragraph(f"There {verb} {num_articles} {word}:")
-
- articles = []
- for program in repo[language]:
- program_escaped = _markdown_escape(str(program))
- link = snakemd.Inline(
- program_escaped,
- link=program._sample_program_doc_url
- )
- articles.append(link)
- doc.add_block(snakemd.MDList(articles))
-
-
-def _split_text(text: str) -> tuple[str, str]:
- mid = len(text) // 2
- best_index = -1
- min_dist = float('inf')
-
- for i, char in enumerate(text):
- if char.isspace():
- dist = abs(i - mid)
- if dist <= min_dist:
- min_dist = dist
- best_index = i
- elif i > mid:
- # Optimization: if we are past mid and distance is increasing, stop
- break
-
- if best_index == -1:
- return text, ""
-
- return text[:best_index], text[best_index + 1:]
-
-def _generate_front_matter(
- doc: snakemd.Document,
- title: str,
- times: Optional[List[Optional[datetime.datetime]]] = None,
- image: Optional[str] = None,
- authors: Optional[Set[str]] = None,
- tags: Optional[Iterable[str]] = None
-):
- """
- Generates front matter and adds it to the document.
-
- :param snakemd.Document doc: the document to add the front matter to.
- :param str title: the title of the document.
- :param Optional[List[Optional[datetime.datetime]]] times: optional list of
- date/times that may be `None`.
- :param str image: optional filename of the image.
- :param Set[str] authors: optional list of authors
- :param Iterable[str] tags: optional list of tags
- """
-
- top_title, bottom_title = _split_text(title)
-
- front_matter = {
- "title": title,
- "title1": top_title,
- "title2": bottom_title,
- "layout": "default"
- }
-
- filtered_times = list(filter(None, times or []))
- if filtered_times:
- front_matter["date"] = min(filtered_times).date()
- front_matter["last-modified"] = max(filtered_times).date()
-
- if image:
- front_matter["featured-image"] = image
-
- if authors:
- front_matter["authors"] = sorted(authors, key=str.casefold)
-
- if tags:
- front_matter["tags"] = sorted(tags, key=str.casefold)
-
- yaml_block = yaml.safe_dump(front_matter, sort_keys=True, allow_unicode=True)
- doc.add_raw(f"---\n{yaml_block}---")
-
-
-def _generate_no_edit_note(
- doc: snakemd.Document, source: str, source_instance: str, filenames: List[str]
-):
- """
- Generates "DO NOT EDIT" note
-
- :param snakemd.Document doc: the document to add the note to.
- :param str source: the specific source folder to pull from (e.g., languages).
- :param str source_instance: the specific source instance to pull from (e.g., c-plus-plus).
- :param list[str] filenames: the markdown filenames
- """
-
- note_filenames = "\n".join(
- f"- sources/{source}/{source_instance}/{filename}" for filename in filenames
- )
- note = f"""\
-"""
- doc.add_raw(note)
-
-
-def _generate_sample_program_index(program: subete.SampleProgram, path: pathlib.Path):
- """
- Creates a sample program documentation file.
-
- :param subete.SampleProgram program: the sample program to create the documentation for.
- :param pathlib.Path path: the path to the documentation file.
- """
- doc: snakemd.Document = snakemd.new_doc()
- root_path = pathlib.Path(
- f"programs/{program.project_pathlike_name()}/{program.language_pathlike_name()}"
- )
- authors: Set[str] = program.authors()
- doc_authors: Set[str] = program.doc_authors()
- _generate_front_matter(
- doc,
- str(program),
- times=_get_program_datetimes(program),
- image=_get_program_image(program),
- authors=authors | doc_authors,
- tags=[program.language_pathlike_name(), program.project_pathlike_name()]
- )
- _generate_no_edit_note(doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- PROGRAM_MD_FILENAMES,
- )
-
- project_name = program.project_name()
- language_escaped = _markdown_escape(program.language_name())
- language_docs_url = program.language_collection().lang_docs_url()
- doc.add_block(
- snakemd.Paragraph(
- [
- "Welcome to the ",
- snakemd.Inline(project_name, link=program.project().requirements_url()),
- " in ",
- snakemd.Inline(language_escaped, link=language_docs_url),
- " page! Here, you'll find the source code for this program as well as a description ",
- "of how the program works."
- ]
- )
- )
- doc.add_heading("Current Solution", level=2)
-
- if program.image_type():
- image_dest = path / pathlib.Path(program.project_path()).name
- shutil.copy(program.project_path(), image_dest)
- image_uri = "/" + "/".join(image_dest.parts[1:])
- doc.add_block(
- snakemd.Raw(
- f'''
'''
- )
- )
- else:
- doc.add_paragraph("{% raw %}")
- doc.add_code(program.code(), lang=language_escaped.lower().replace(" ", "_"))
- doc.add_paragraph("{% endraw %}")
-
- doc.add_block(
- snakemd.Paragraph(
- [
- f"{project_name} in ",
- snakemd.Inline(language_escaped, link=language_docs_url),
- " was written by:",
- ]
- )
- )
- _add_authors_to_doc(doc, authors)
-
- doc_authors: Set[str] = program.doc_authors()
- if doc_authors:
- doc.add_paragraph("This article was written by:")
- _add_authors_to_doc(doc, doc_authors)
-
- doc.add_paragraph("If you see anything you'd like to change or update, please consider contributing.") \
- .insert_link("please consider contributing", "https://github.com/TheRenegadeCoder/sample-programs")
-
- created_at: datetime.datetime = program.created()
- modified: datetime.datetime = program.modified()
- doc_modified: Optional[datetime.datetime] = program.doc_modified()
- if created_at != modified and doc_modified and doc_modified < modified:
- datetime_format = "%b %d %Y %H:%M:%S"
- doc.add_paragraph(
- "**Note**: The solution shown above is the current solution in the Sample "
- f"Programs repository as of {modified.strftime(datetime_format)}. "
- f"The solution was first committed on {created_at.strftime(datetime_format)}. "
- f"The documentation was last updated on {doc_modified.strftime(datetime_format)}. "
- "As a result, documentation below may be outdated."
- )
-
- _add_section(
- doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- "How to Implement the Solution"
- )
- _add_section(
- doc,
- str(root_path.parent),
- program.language_pathlike_name(),
- "How to Run the Solution"
- )
- try:
- doc.dump("index", directory=str(path))
- except Exception:
- log.exception(f"Failed to write {path}")
-
-
-def _get_program_datetimes(program: subete.SampleProgram) -> List[Optional[datetime.datetime]]:
- """
- Get list of date/times for a sample program.
-
- :param subete.SampleProgram program: Sample program to get date/times for.
- :return: List of date/times for sample program
- """
-
- return [program.created(), program.modified(), program.doc_created(), program.doc_modified()]
-
-
-def _add_authors_to_doc(doc: snakemd.Document, authors: Set[str]):
- """
- Add a sorted list of authors to a document.
-
- :param snakemd.Document doc: the document to add the list of authors to.
- :param authors: List of authors
- """
- doc.add_block(snakemd.MDList(sorted(authors, key=lambda x: x.casefold())))
-
-
-def _get_program_image(program: subete.SampleProgram) -> Optional[str]:
- """
- Gets the filename of the image for a sample program
-
- :param subete.SampleProgram program: the sample program to get the image for.
- :return: Filename of image if found, None otherwise.
- """
- project_path = program.project_pathlike_name()
- language_path = program.language_pathlike_name()
- image_path = pathlib.Path(f"sources/programs/{project_path}/{language_path}")
- return _get_image(
- image_path,
- f"{project_path}-in-{language_path}",
- _get_project_image(program.project())
- )
-
-
-@functools.lru_cache()
-def _get_project_image(project: subete.Project) -> Optional[str]:
- """
- Gets the filename of the image for a project
-
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :return: Filename of image if found, None otherwise.
- """
-
- project_path = project.pathlike_name()
- image_path = pathlib.Path(f"sources/projects/{project_path}")
- return _get_image(
- image_path,
- f"{project_path}-in-every-language",
- _get_default_project_image()
- )
-
-
-@functools.lru_cache()
-def _get_default_project_image() -> Optional[str]:
- """
- Gets the filename of the default project image
-
- :return: Filename of image if found, None otherwise
- """
- return _get_image(pathlib.Path("sources/projects"), DEFAULT_PROJECT_IMAGE_NO_EXT)
-
-
-@functools.lru_cache()
-def _get_image(
- image_path: pathlib.Path, filename_prefix_no_ext: str, default_filename: Optional[str] = None
-) -> str:
- if image_path.is_dir():
- path = next(image_path.glob("featured-image.*"), None)
- if path:
- return f"{filename_prefix_no_ext}{path.suffix}"
-
- return default_filename
-
-
-def _generate_project_index(
- repo: subete.Repo, project: subete.Project, previous: subete.Project, next: subete.Project
-):
- """
- Creates an index file for a single project. The path is assumed
- to be `projects/project/index.md`.
-
- :param subete.Repo repo: the repo to pull from.
- :param subete.Project project: the project to create the index file
- for in the normalized form (e.g., hello-world).
- :param subete.Project previous: the previous project alphabetically.
- :param subete.Project next: the next project alphabetically.
- """
- doc: snakemd.Document = snakemd.new_doc()
- project_name: str = project.name()
- times: List[Optional[datetime.datetime]] = [project.doc_created(), project.doc_modified()]
- for language in repo:
- language: subete.Language
- for program in language:
- program: subete.SamplePrograms
- if program.project_name() == project_name:
- times += _get_program_datetimes(program)
-
- _generate_front_matter(
- doc,
- project.name(),
- image=_get_project_image(project),
- times=times,
- tags=[project.pathlike_name()]
- )
- _generate_no_edit_note(doc, "projects", project.pathlike_name(), PROJECT_MD_FILENAMES)
- doc.add_paragraph(
- f"Welcome to the {project.name()} page! Here, you'll find a description "
- f"of the project as well as a list of sample programs "
- f"written in various languages."
- )
- doc_authors: Set[str] = project.doc_authors()
- if doc_authors:
- doc.add_paragraph("This article was written by:")
- _add_authors_to_doc(doc, doc_authors)
-
- _add_section(doc, "projects", project.pathlike_name(), "Description")
- _add_section(doc, "projects", project.pathlike_name(), "Requirements")
- _add_testing_section(doc, "projects", project.pathlike_name())
- if not project.has_testing():
- doc.add_block(snakemd.Paragraph([
- snakemd.Inline("Note:", bold=True),
- f" {project.name()} is not currently tested by Glotter2. Consider contributing!"
- ]))
-
- _add_project_article_section(doc, repo, project)
- doc.add_horizontal_rule()
- doc.add_paragraph("")
- doc.dump("index", directory=f"docs/projects/{project.pathlike_name()}")
-
-
-def _generate_language_index(language: subete.LanguageCollection):
- """
- Creates a language file for a single language. The path is assumed
- to be `languages/language/index.md`.
-
- :param subete.LanguageCollection language: the collection sample programs for a language.
- """
- doc: snakemd.Document = snakemd.new_doc()
- times: List[Optional[datetime.datetime]] = []
- for program in language:
- program: subete.SampleProgram
- times += _get_program_datetimes(program)
-
- times += [language.doc_created(), language.doc_modified()]
-
- doc_authors: Set[str] = language.doc_authors()
- language_escaped = _markdown_escape(language.name())
- _generate_front_matter(
- doc,
- f"The {language} Programming Language",
- times=times,
- image=_get_language_image(language),
- authors=doc_authors,
- tags=[language.pathlike_name()]
- )
- _generate_no_edit_note(doc, "languages", language.pathlike_name(), LANGUAGE_MD_FILENAMES)
- doc.add_paragraph(
- f"Welcome to the {language_escaped} page! Here, you'll find a description "
- f"of the language as well as a list of sample programs "
- f"in that language."
- )
- if doc_authors:
- doc.add_paragraph("This article was written by:")
- _add_authors_to_doc(doc, doc_authors)
-
- _add_section(doc, "languages", language.pathlike_name(), "Description")
- _add_language_article_section(doc, repo, str(language))
- try:
- doc.dump("index", directory=f"docs/languages/{language.pathlike_name()}")
- except Exception:
- log.exception(f"Failed to write {language.pathlike_name()}")
-
-
-def _get_language_image(language: subete.LanguageCollection) -> Optional[str]:
- """
- Get image filename for a language
-
- :param subete.LanguageCollection language: the collection sample programs for a language.
- :return: Filename of image if found, None otherwise.
- """
- language_path = language.pathlike_name()
- image_path = pathlib.Path(f"sources/languages/{language_path}")
- return _get_image(
- image_path,
- f"the-{language_path}-programming-language",
- _get_default_language_image()
- )
-
-
-@functools.lru_cache()
-def _get_default_language_image() -> Optional[str]:
- """
- Get default language image filename
-
- :return: Filename of image if found, None otherwise.
- """
-
- return _get_image(pathlib.Path("sources/languages"), DEFAULT_LANGUAGE_IMAGE_NO_EXT)
-
-
-def generate_main_page(repo: subete.Repo):
- """
- Generate the main page.
-
- :param subete.Repo repo: the repo to pull from.
- """
- authors: Set[str] = set()
- times: List[Optional[datetime.datetime]] = []
- num_articles = 0
- for language in repo:
- language: subete.LanguageCollection
- num_articles += 1 # 1 article per language
- authors |= language.doc_authors()
- times += [language.doc_created(), language.doc_modified()]
- program: subete.SampleProgram
- for program in language:
- authors |= program.authors() | program.doc_authors()
- times += _get_program_datetimes(program)
-
- num_articles += 1 # 1 article per sample program
-
- for project in repo.approved_projects():
- num_articles += 1 # 1 article per approved project
- project: subete.Project
- authors |= project.doc_authors()
- times += [project.doc_created(), project.doc_modified()]
-
- log.info("Generating main page")
- main_page: snakemd.Document = snakemd.new_doc()
- _generate_front_matter(
- main_page,
- "Sample Programs in Every Language",
- times=times,
- )
- main_page.add_paragraph(
- "Welcome to Sample Programs in Every Language, a collection of code snippets "
- "in as many languages as possible. Thanks for taking an interest in our collection "
- f"which currently contains {num_articles} articles written by {len(authors)} authors."
- )
- paragraph = snakemd.Paragraph(
- [
- snakemd.Inline(
- "If you'd like to contribute to this growing collection, check out our "
- ),
- snakemd.Inline(
- "contributing document",
- link="https://github.com/TheRenegadeCoder/sample-programs/blob/master/.github/CONTRIBUTING.md"
- ),
- snakemd.Inline(
- " for more information. In addition, you can explore our documentation which is organized by "
- ),
- snakemd.Inline("project", link="/projects"),
- snakemd.Inline(" and by "),
- snakemd.Inline("language", link="/languages"),
- snakemd.Inline(". If you don't find what you're look for, check out our list of related "),
- snakemd.Inline("open-source projects", link="/related"),
- snakemd.Inline(
- ". Finally, if code isn't your thing but you'd still like to help, there are plenty "
- "of other ways to "
- ),
- snakemd.Inline(
- "support the project",
- link="https://therenegadecoder.com/updates/5-ways-you-can-support-the-renegade-coder/"
- ),
- snakemd.Inline(".")
- ]
- )
- main_page.add_paragraph(str(paragraph))
- try:
- main_page.dump("index", "docs")
- except Exception:
- log.exception("Failed to write docs/index")
-
-
-def generate_project_paths(repo: subete.Repo):
- """
- Creates the project directory which contains all of the project folders
- and index.md files.
-
- :param subete.Repo repo: the repo to pull from.
- """
- projects = repo.approved_projects()
- projects.sort(key=lambda x: x.name().casefold())
- for i, project in enumerate(projects):
- project: subete.Project
- log.info("Generating project paths for %s", str(project))
- path = pathlib.Path(f"docs/projects/{project.pathlike_name()}")
- path.mkdir(exist_ok=True, parents=True)
- _generate_project_index(repo, project, projects[i - 1], projects[(i + 1) % len(projects)])
-
-
-def generate_sample_programs(repo: subete.Repo):
- """
- Creates the language folders in each project directory.
-
- :param subete.Repo repo: the repo to pull from.
- """
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- log.info("Generate sample programs for %s", str(program))
- program: subete.SampleProgram
- path = pathlib.Path(
- f"docs/projects/{program.project_pathlike_name()}/{language.pathlike_name()}"
- )
- path.mkdir(exist_ok=True, parents=True)
- _generate_sample_program_index(program, path)
-
-
-def generate_language_paths(repo: subete.Repo):
- """
- Creates the language directory which contains all of the language folders
- and index.md files.
-
- :param subete.Repo repo: the repo to pull from.
- """
- for language in repo:
- log.info("Generating language paths for %s", str(language))
- language: subete.LanguageCollection
- path = pathlib.Path(f"docs/languages/{language.pathlike_name()}")
- path.mkdir(exist_ok=True, parents=True)
- _generate_language_index(language)
-
-
-def generate_auto_gen_test_docs(repo: subete.Repo):
- """
- Generate auto-generated test documentation
-
- :param subete.Repo repo: the repo to pull from.
- """
- log.info("Generating test documentation")
- curr_dir = os.getcwd()
- doc_dir = pathlib.Path(AUTO_GEN_TEST_DOC_DIR).absolute()
- os.chdir(repo.sample_programs_repo_dir())
- glotter.generate_test_docs(
- doc_dir=doc_dir,
- repo_name="Sample Programs",
- repo_url="https://github.com/TheRenegadeCoder/sample-programs"
- )
- os.chdir(curr_dir)
-
-
-def generate_languages_index(repo: subete.Repo):
- """
- Creates the index.md files for the root directories.
-
- :param subete.Repo repo: the repo to pull from.
- """
- log.info("Generating language index")
- language_index_path = pathlib.Path("docs/languages")
- times: List[Optional[datetime.datetime]] = []
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- program: subete.SampleProgram
- times += _get_program_datetimes(program)
-
- language_index = snakemd.new_doc()
- _generate_front_matter(
- language_index,
- "Programming Languages",
- times=times,
- image=_get_default_language_image()
- )
- num_languages = len(list(repo))
- verb = pluralize(num_languages, "is", "are")
- singular = pluralize(num_languages, "language")
- welcome_text = (
- "Welcome to the Languages page! Here, you'll find a list of all of the languages represented in the collection. "
- f"At this time, there {verb} {num_languages} {singular}, of which {repo.total_tests()} are tested"
- )
- untestables = repo.total_untestables()
- if untestables:
- verb_untestables = pluralize(untestables, "is", "are")
- welcome_text += f", {untestables} {verb_untestables} untestable"
-
- num_programs = repo.total_programs()
- singular = pluralize(num_programs, "snippet")
- welcome_text += f", and {num_programs} code {singular}."
- language_index.add_paragraph(welcome_text)
-
- language_index.add_heading("Language Breakdown", level=2)
- _generate_language_breakdown_percentage(repo, language_index)
-
- language_index.add_heading("Language Collections by Letter", level=2)
- language_index.add_paragraph(
- "To help you navigate the collection, the following languages are organized alphabetically and grouped by first letter. "
- "To go to a particular letter, just click one of the links below."
- )
- language_index.add_raw(_get_language_letter_links(repo))
-
- return_to_top = [
- "« ",
- snakemd.Inline("Return to Top", link="#language-collections-by-letter"),
- " »"
- ]
- language_index.add_block(
- snakemd.Paragraph(["To return here, just click the "] + return_to_top + [" link."])
- )
-
- for letter in repo.sorted_language_letters():
- language_index.add_heading(letter.upper(), level=3)
- languages: list[subete.LanguageCollection] = repo.languages_by_letter(letter)
- snippets = sum(language.total_programs() for language in languages)
- tests = sum(1 if language.has_testinfo()
- else 0 for language in languages)
- untestables = sum(1 if language.has_untestable_info()
- else 0 for language in languages)
- verb = pluralize(tests, "is", "are")
- num_languages = len(languages)
- singular = pluralize(tests, "language")
- verb_untestables = pluralize(untestables, "is", "are")
- language_statement = (
- f"The '{letter.upper()}' collection contains {num_languages} {singular}, "
- f"of which {tests} {verb} tested"
- )
- if untestables:
- language_statement += f", {untestables} {verb_untestables} untestable"
-
- language_index.add_paragraph(f"{language_statement}, and {snippets} code snippets.")
- languages.sort(key=lambda x: x.name().casefold())
- languages_list = [
- _get_language_link_and_testability(x)
- for x in languages
- ]
- language_index.add_block(snakemd.MDList(languages_list))
- language_index.add_block(snakemd.Paragraph(return_to_top))
-
- language_index.dump("index", directory=str(language_index_path))
-
-
-def _get_language_letter_links(repo: subete.Repo) -> str:
- # Have to use raw HTML for this since there is no way to add a class attribute
- # in Markdown
- language_letter_links = [
- '
'
- ] + [
- f' - {letter.upper()}
'
- for letter in repo.sorted_language_letters()
- ] + [
- "
"
- ]
- return "\n".join(language_letter_links)
-
-
-def _get_language_link_and_testability(
- language: subete.LanguageCollection
-) -> snakemd.Paragraph:
- language_escaped = _markdown_escape(language.name())
- language_link = snakemd.Inline(language_escaped, link=language.lang_docs_url())
- num_programs = language.total_programs()
- singular = pluralize(num_programs, "code snippet")
- phrase = f"{num_programs} {singular}"
- if language.has_testinfo():
- return snakemd.Paragraph([language_link, f" ({phrase})"])
-
- if language.has_untestable_info():
- testability = [
- f" ({phrase}, ",
- snakemd.Inline("untestabled", link=language.untestable_info_url()),
- ")"
- ]
- else:
- testability = [snakemd.Inline(f" {phrase}, (untested)")]
-
- return snakemd.Paragraph([language_link] + testability)
-
-
-def _generate_language_breakdown_percentage(repo: subete.Repo, doc: snakemd.Document):
- language_info = sorted(
- ((language.name(), language.percentage(), language.color()) for language in repo),
- key=lambda x: (-x[1], x[0])
- )
- max_language_percentage = language_info[0][1]
-
- doc.add_paragraph("Here are the percentages for each language in the collection:")
- doc.add_raw("""\
-
-Click here to expand or collapse...
-"""
- )
-
- for language_name, percentage, color in language_info:
- bar_graph_width = 100.0 * percentage / max_language_percentage
- bar_graph_style = f"width: {bar_graph_width:.2f}%; background-color: {color};"
- doc.add_raw(f"""\
-
- | {language_name} |
- {percentage:.2f}% |
- |
-
"""
- )
-
- doc.add_raw("""\
-
- """
- )
-
-
-def generate_projects_index(repo: subete.Repo):
- """
- Generate index.md for file for Projects page
-
- :param subete.Repo repo: the repo to pull from.
- """
- log.info("Generating projects index")
- projects_index_path = pathlib.Path("docs/projects")
- projects_index: snakemd.Document = snakemd.new_doc()
- times: List[Optional[datetime.datetime]] = []
- for language in repo:
- language: subete.LanguageCollection
- for program in language:
- program: subete.SampleProgram
- times += _get_program_datetimes(program)
-
- _generate_front_matter(
- projects_index,
- "Programming Projects in Every Language",
- times=times,
- image=_get_default_project_image()
- )
- project_tests = sum(
- 1 if project.has_testing() else 0
- for project in repo.approved_projects()
- )
- projects_index.add_paragraph(
- "Welcome to the Projects page! Here, you'll find a list of all of the projects represented in the collection. "
- f"At this time, the repo supports {repo.total_approved_projects()} projects, of which {project_tests} are tested."
- )
- projects_index.add_heading("Projects List", level=2)
- projects_index.add_paragraph(
- "To help you navigate the collection, the following projects are organized alphabetically."
- )
- repo.approved_projects().sort(key=lambda x: x.name().casefold())
- projects = [
- snakemd.Inline(
- project.name(),
- link=project.requirements_url()
- )
- for project in repo.approved_projects()
- ]
- projects_index.add_block(snakemd.MDList(projects))
- projects_index.dump("index", directory=str(projects_index_path))
-
-
-def copy_article_images(repo: subete.Repo):
- """
- Copy article images to the appropriate directory
-
- :param subete.Repo repo: the repo to pull from.
- """
- _copy_language_images(repo)
- _copy_project_images(repo)
- _copy_program_images(repo)
-
-
-def _copy_language_images(repo: subete.Repo):
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- _copy_image(
- f"sources/languages/{language_path}",
- f"docs/assets/images/languages/{language_path}"
- )
-
-
-def _copy_project_images(repo: subete.Repo):
- project: subete.Project
- for project in repo.approved_projects():
- project_path = project.pathlike_name()
- _copy_image(
- f"sources/projects/{project_path}",
- f"docs/assets/images/projects/{project_path}"
- )
-
-
-def _copy_program_images(repo: subete.Repo):
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- project_path = program.project_pathlike_name()
- _copy_image(
- f"sources/programs/{project_path}/{language_path}",
- f"docs/assets/images/projects/{project_path}/{language_path}"
- )
-
-
-def _copy_image(src_dir: str, dest_dir: str):
- src_dir_path = pathlib.Path(src_dir)
- dest_dir_path = pathlib.Path(dest_dir)
- if not src_dir_path.exists():
- return
-
- src_image_paths = [
- path
- for path in src_dir_path.iterdir()
- if path.is_file() and path.stem != "featured-image" and imghdr.what(path)
- ]
- if src_image_paths:
- os.makedirs(dest_dir, exist_ok=True)
- for src_image_path in src_image_paths:
- dest_image_path = dest_dir_path / src_image_path.name
- log.info("Copying image %s -> %s", str(src_image_path), str(dest_image_path))
- shutil.copy(src_image_path, dest_image_path)
-
-
-def generate_images(repo: subete.Repo) -> int:
- """
- Use image-titler to resize and crop images and add logo
-
- :param subete.Repo repo: the repo to pull from.
- :return: 0 if no error, non-zero otherwise
- """
-
- with tempfile.TemporaryDirectory() as temp_dir:
- status_code = 0
- status_code = _generate_language_images(repo, temp_dir, status_code)
- status_code = _generate_project_images(repo, temp_dir, status_code)
- status_code = _generate_program_images(repo, temp_dir, status_code)
- return status_code
-
-
-def _generate_language_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = _generate_image(
- temp_dir, "sources/languages", DEFAULT_LANGUAGE_IMAGE_NO_EXT, status_code
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- status_code = _generate_image(
- temp_dir, f"sources/languages/{language_path}",
- f"the-{language_path}-programming-language", status_code
- )
-
- return status_code
-
-
-def _generate_project_images(repo: subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = _generate_image(
- temp_dir, "sources/projects", DEFAULT_PROJECT_IMAGE_NO_EXT, status_code
- )
- for project in repo.approved_projects():
- project_path = project.pathlike_name()
- status_code = _generate_image(
- temp_dir,
- f"sources/projects/{project_path}",
- f"{project_path}-in-every-language",
- status_code
- )
-
-
-def _generate_program_images(repo:subete.Repo, temp_dir: str, status_code: int) -> int:
- status_code = _generate_image(
- temp_dir, "sources", DEFAULT_PROGRAM_IMAGE_NO_EXT, status_code
- )
- language: subete.LanguageCollection
- for language in repo:
- language_path = language.pathlike_name()
- program: subete.SampleProgram
- for program in repo[str(language)]:
- program_path = program.project_pathlike_name()
- status_code = _generate_image(
- temp_dir,
- f"sources/programs/{program_path}/{language_path}",
- f"{program_path}-in-{language_path}",
- status_code
- )
-
- return status_code
-
-
-def _generate_image(temp_dir: str, src: str, dest_filename_no_ext: str, status_code: int) -> int:
- src_image_path = next(pathlib.Path(src).glob("featured-image.*"), None)
- if not src_image_path:
- return status_code
-
- dest = pathlib.Path("docs/assets/images")
- logo = str(dest / "icon-small.png")
-
- dest_image_path = dest / f"{dest_filename_no_ext}{src_image_path.suffix}"
- log.info("Processing %s -> %s", str(src_image_path), str(dest_image_path))
- try:
- subprocess.run(
- [
- "image-titler",
- "--path", str(src_image_path),
- "--output", temp_dir,
- "--logo", logo,
- "--no_title"
- ],
- check=True
- )
- temp_image_path = next(pathlib.Path(temp_dir).iterdir())
- shutil.move(temp_image_path, dest_image_path)
- except subprocess.CalledProcessError as exc:
- log.error("image-titler exited with %d status", exc.returncode)
- status_code = 1
-
- return status_code
-
-
-def clean(folder: str):
- """
- Deletes the contents of the docs directory.
- """
- path = pathlib.Path(folder)
- if path.exists():
- for child in path.glob('*'):
- if child.is_file():
- child.unlink()
- else:
- clean(child)
- path.rmdir()
-
-
-def pluralize(count: int, singular: str, plural: Optional[str]=None):
- """
- Pluralize an item
-
- :param count: Count of number of items
- :param singular: Singular form of item
- :param plural: Plural form of item. If None, use singular plus an "s"
- :return: Pluralized item
- """
-
- if plural is None:
- plural = f"{singular}s"
-
- return singular if count == 1 else plural
-
-
-def _markdown_escape(s: str) -> str:
- return s.replace("*", r"\*")
-
+from cli import main
if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("--local", "-l", action="store_true", help="Use local contents of website")
- parsed_args = parser.parse_args()
-
- logging.basicConfig(format="%(name)-12s | %(levelname)-8s | %(message)s", level=logging.INFO)
- clean("docs/projects")
- clean("docs/languages")
- clean(AUTO_GEN_TEST_DOC_DIR)
-
- subete.repo.logger.setLevel(logging.WARNING) # Reduce the noise of subete
- log.info("Loading repos (this may take several minutes)")
- website_repo_dir = "." if parsed_args.local else None
- repo = subete.load(sample_programs_website_repo_dir=website_repo_dir)
- repo.set_additional_language_colors("additional-language-colors.yml")
-
- generate_main_page(repo)
- generate_language_paths(repo)
- generate_auto_gen_test_docs(repo)
- generate_project_paths(repo)
- generate_sample_programs(repo)
- generate_languages_index(repo)
- generate_projects_index(repo)
- copy_article_images(repo)
- status_code = generate_images(repo)
- sys.exit(status_code)
+ main()
diff --git a/scripts/cli.py b/scripts/cli.py
new file mode 100644
index 0000000000..c912d55d51
--- /dev/null
+++ b/scripts/cli.py
@@ -0,0 +1,50 @@
+import argparse
+import logging
+import sys
+
+import subete
+from assets.image_build import generate_images
+from assets.image_copy import copy_article_images
+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.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("automate")
+
+
+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_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
+ 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..b30b5a0c05
--- /dev/null
+++ b/scripts/constants.py
@@ -0,0 +1,40 @@
+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/__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..8e7f00ffc6
--- /dev/null
+++ b/scripts/generators/languages.py
@@ -0,0 +1,220 @@
+import logging
+from pathlib import Path
+
+import snakemd
+import subete
+from assets.image_lookup import find_language_image, get_default_language_image
+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
+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
+
+log = logging.getLogger(__name__)
+
+
+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.new_doc()
+
+ times = [dt for program in language for dt in get_program_datetimes(program)]
+ times.extend([language.doc_created(), language.doc_modified()])
+
+ doc_authors = language.doc_authors()
+ language_escaped = markdown_escape(language.name())
+
+ generate_front_matter(
+ doc,
+ f"The {language} Programming Language",
+ times=times,
+ image=find_language_image(language),
+ authors=doc_authors,
+ tags=[language.pathlike_name()],
+ )
+ 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 "
+ 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=str(target_dir))
+ except Exception:
+ log.exception("Failed to write %s", language.pathlike_name())
+
+
+def generate_languages_index(repo: subete.Repo) -> None:
+ """Creates the central main index.md landing page for all Programming Languages.
+
+ Args:
+ repo: The subete Repository instance.
+
+ """
+ log.info("Generating language index")
+
+ 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(
+ language_index,
+ "Programming Languages",
+ times=times,
+ image=get_default_language_image(),
+ )
+
+ 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 {is_are(total_languages)} {pluralize(total_languages, 'language')}, "
+ f"of which {repo.total_tests()} are tested"
+ )
+
+ 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)
+ 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 = 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)
+ language_statement = (
+ 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')}.",
+ )
+
+ 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(DOCS_LANGUAGES_DIR))
+
+
+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())
+
+ phrase = pluralize(language.total_programs(), "code snippet")
+
+ 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) -> None:
+ """Renders the stylized, expanded HTML table displaying language layout distributions."""
+ language_info = sorted(
+ ((lang.name(), lang.percentage(), lang.color()) for lang in repo),
+ key=lambda x: (-x[1], x[0]),
+ )
+ 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(
+ '\nClick here to expand or collapse...
\n',
+ )
+
+ for name, percentage, color in language_info:
+ bar_width = 100.0 * percentage / max_percentage
+ doc.add_raw(
+ f" \n"
+ f' | {name} | \n'
+ f' {percentage:.2f}% | \n'
+ f' | \n'
+ f"
",
+ )
+
+ doc.add_raw("
\n ")
diff --git a/scripts/generators/main_page.py b/scripts/generators/main_page.py
new file mode 100644
index 0000000000..370af7fd91
--- /dev/null
+++ b/scripts/generators/main_page.py
@@ -0,0 +1,112 @@
+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:
+ """Orchestrates the generation and export of the master documentation main page.
+
+ Args:
+ repo: The subete Repository instance to aggregate documentation metrics 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:
+ num_articles += 1
+ authors |= language.doc_authors()
+ times.extend([language.doc_created(), language.doc_modified()])
+
+ for program in language:
+ num_articles += 1
+ authors |= program.authors() | program.doc_authors()
+ times.extend(get_program_datetimes(program))
+
+ for project in repo.approved_projects():
+ num_articles += 1
+ authors |= project.doc_authors()
+ times.extend([project.doc_created(), project.doc_modified()])
+
+ 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 ",
+ ),
+ 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 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 ",
+ ),
+ snakemd.Inline(
+ "support the project",
+ link="https://therenegadecoder.com/updates/5-ways-you-can-support-the-renegade-coder/",
+ ),
+ snakemd.Inline("."),
+ ],
+ )
diff --git a/scripts/generators/projects.py b/scripts/generators/projects.py
new file mode 100644
index 0000000000..caf3b8fd01
--- /dev/null
+++ b/scripts/generators/projects.py
@@ -0,0 +1,193 @@
+import logging
+from collections import defaultdict
+from pathlib import Path
+
+import snakemd
+import subete
+from assets.image_lookup import find_project_image, get_default_project_image
+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
+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
+from utils.plural import is_are, pluralize
+
+log = logging.getLogger(__name__)
+
+
+def generate_project_paths(repo: subete.Repo) -> None:
+ """Creates the project directories and triggers individual index generation.
+
+ Args:
+ repo: The subete Repository instance to pull data from.
+
+ """
+ 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):
+ 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,
+ prev_project: subete.Project,
+ next_project: subete.Project,
+ target_dir: Path,
+ program_times: list,
+) -> None:
+ """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.
+
+ """
+ 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=[path_name],
+ )
+ 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 "
+ f"of the project as well as a list of sample programs written in various languages.",
+ )
+
+ 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", 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(
+ [
+ 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()
+
+ _add_navigation_footer(doc, prev_project, next_project)
+
+ doc.dump("index", directory=str(target_dir))
+
+
+def generate_projects_index(repo: subete.Repo) -> None:
+ """Generates the comprehensive master index.md for the main Projects page.
+
+ Args:
+ repo: The subete Repository instance to pull data from.
+
+ """
+ log.info("Generating projects index")
+ 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,
+ "Programming Projects in Every Language",
+ times=times,
+ image=get_default_project_image(),
+ )
+
+ 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 {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.",
+ )
+
+ project_links = [
+ snakemd.Inline(project.name(), link=project.requirements_url())
+ for project in sorted_projects
+ ]
+ 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('")
diff --git a/scripts/generators/sample_programs.py b/scripts/generators/sample_programs.py
new file mode 100644
index 0000000000..6927da67db
--- /dev/null
+++ b/scripts/generators/sample_programs.py
@@ -0,0 +1,206 @@
+import logging
+import shutil
+from pathlib import Path
+
+import snakemd
+import subete
+from assets.image_lookup import find_program_image
+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
+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__)
+
+DATETIME_FORMAT = "%b %d %Y %H:%M:%S"
+
+
+def generate_sample_programs(repo: subete.Repo) -> None:
+ """Creates the language folders in each project directory.
+
+ Args:
+ repo: The subete Repository instance to pull data from.
+
+ """
+ for language in repo:
+ for program in language:
+ log.info("Generate sample programs for %s", program)
+
+ project_dir = (
+ DOCS_PROJECTS_DIR / program.project_pathlike_name() / language.pathlike_name()
+ )
+ 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.
+
+ Args:
+ program: The sample program instance to document.
+ path: The directory Path where the documentation index will be saved.
+
+ """
+ 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(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)
+ _add_welcome_paragraph(
+ doc,
+ program.project_name(),
+ language_escaped,
+ language_docs_url,
+ program,
+ )
+ _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=program.authors() | program.doc_authors(),
+ tags=[lang_path_name, proj_path_name],
+ )
+
+ generate_no_edit_note(
+ doc,
+ program_root_str,
+ lang_path_name,
+ list(PROGRAM_MD_FILES),
+ )
+
+
+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(
+ [
+ "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)
+
+
+def _add_solution_block(
+ doc: snakemd.Document,
+ program: subete.SampleProgram,
+ path: Path,
+ language_escaped: str,
+) -> None:
+ """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(
+ snakemd.Raw(f'
'),
+ )
+ else:
+ doc.add_paragraph("{% raw %}")
+ doc.add_code(
+ program.code(),
+ lang=language_escaped.lower().replace(" ", "_"),
+ )
+ doc.add_paragraph("{% endraw %}")
+
+
+def _add_author_credits(
+ doc: snakemd.Document,
+ program: subete.SampleProgram,
+ language_escaped: str,
+ language_docs_url: str,
+) -> None:
+ """Builds lists detailing code implementation authors vs documentation contributors."""
+ authors = program.authors()
+ doc_authors = program.doc_authors()
+
+ doc.add_block(
+ snakemd.Paragraph(
+ [
+ f"{program.project_name()} in ",
+ snakemd.Inline(language_escaped, link=language_docs_url),
+ " was written by:",
+ ],
+ ),
+ )
+ add_authors_to_doc(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",
+ )
+
+
+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 doc_modified
+ and (created_at != modified)
+ and (doc_modified < modified)
+ ):
+ 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.",
+ )
diff --git a/scripts/generators/tests.py b/scripts/generators/tests.py
new file mode 100644
index 0000000000..5fb3269f28
--- /dev/null
+++ b/scripts/generators/tests.py
@@ -0,0 +1,46 @@
+import logging
+import os
+from pathlib import Path
+
+import glotter
+import subete
+from constants import GENERATED_DIR
+
+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.
+
+ Args:
+ repo: The subete Repository instance to pull information from.
+
+ """
+ log.info("Generating test documentation")
+
+ doc_dir = GENERATED_DIR.resolve()
+ repo_dir = Path(repo.sample_programs_repo_dir())
+
+ with chdir(repo_dir):
+ glotter.generate_test_docs(
+ doc_dir=doc_dir,
+ repo_name="Sample Programs",
+ repo_url="https://github.com/TheRenegadeCoder/sample-programs",
+ )
diff --git a/scripts/logging_setup.py b/scripts/logging_setup.py
new file mode 100644
index 0000000000..93869838ff
--- /dev/null
+++ b/scripts/logging_setup.py
@@ -0,0 +1,8 @@
+import logging
+
+import subete
+
+
+def setup_logging() -> None:
+ logging.basicConfig(format="%(name)-20s | %(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..15ff95335a
--- /dev/null
+++ b/scripts/markdown/articles.py
@@ -0,0 +1,80 @@
+import logging
+
+import snakemd
+import subete
+from utils.plural import is_are, 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.
+
+ 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("Generating article section of %s", project)
+ doc.add_heading("Articles", level=2)
+
+ articles = []
+ project_name = project.name()
+
+ for lang in repo:
+ try:
+ program = lang[project_name]
+ except KeyError:
+ continue
+
+ program_escaped = markdown_escape(str(program))
+ articles.append(
+ snakemd.Inline(
+ program_escaped,
+ link=program.documentation_url(),
+ ),
+ )
+
+ 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("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",
+ )
+
+
+def add_language_article_section(doc: snakemd.Document, repo: subete.Repo, language: str) -> None:
+ """Generates a list of articles for each language page.
+
+ 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)
+
+ articles = [
+ snakemd.Inline(
+ markdown_escape(str(program)),
+ link=program.documentation_url(),
+ )
+ 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))
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..36e13dd94e
--- /dev/null
+++ b/scripts/markdown/front_matter.py
@@ -0,0 +1,64 @@
+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: Iterable[datetime.datetime | None] | None = None,
+ image: str | None = None,
+ authors: Iterable[str] | None = None,
+ tags: Iterable[str] | None = None,
+) -> None:
+ """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)
+
+ front_matter: dict[str, object] = {
+ "title": title,
+ "title1": top_title,
+ "title2": bottom_title,
+ "layout": "default",
+ }
+
+ 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
+
+ if authors:
+ front_matter["authors"] = sorted(authors, key=str.casefold)
+
+ if tags:
+ front_matter["tags"] = sorted(tags, key=str.casefold)
+
+ 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.")
diff --git a/scripts/markdown/note.py b/scripts/markdown/note.py
new file mode 100644
index 0000000000..e2a6f2a096
--- /dev/null
+++ b/scripts/markdown/note.py
@@ -0,0 +1,33 @@
+import snakemd
+from constants import AUTO_GENERATED_NOTICE, CONTRIBUTING_NOTICE
+
+
+def generate_no_edit_note(
+ doc: snakemd.Document,
+ source: str,
+ source_instance: str,
+ filenames: list[str],
+) -> None:
+ """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.
+
+ """
+ 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..9fcb8516c5
--- /dev/null
+++ b/scripts/markdown/sections.py
@@ -0,0 +1,84 @@
+import logging
+
+import snakemd
+from constants import GENERATED_DIR, SOURCE_DIR
+
+log = logging.getLogger(__name__)
+
+
+def add_section(
+ doc: snakemd.Document,
+ source: str,
+ source_instance: str,
+ section: str,
+ level: int = 2,
+) -> None:
+ """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.
+
+ """
+ doc.add_heading(section, level=level)
+
+ filename = f"{section.lower().replace(' ', '-')}.md"
+ 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)
+ doc.add_raw(file_path.read_text(encoding="utf-8"))
+ else:
+ log.warning("Failed to find %s in %s", section, file_path)
+ paragraph = doc.add_paragraph(
+ f"No '{section}' section available. Please consider contributing.",
+ )
+ 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:
+ """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 = SOURCE_DIR / source / source_instance
+ valid_path = instance_path / "valid-tests.md"
+ invalid_path = instance_path / "invalid-tests.md"
+
+ auto_gen_base = GENERATED_DIR
+ auto_gen_path = auto_gen_base / source_instance / "testing.md"
+
+ if auto_gen_path.exists():
+ add_section(
+ doc,
+ 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(
+ "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)
+ else:
+ add_section(doc, source, source_instance, "Testing", level=2)
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")
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..78f3900615
--- /dev/null
+++ b/scripts/utils/files.py
@@ -0,0 +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:
+ """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():
+ shutil.rmtree(path)
+
+ mkdir(path)
diff --git a/scripts/utils/plural.py b/scripts/utils/plural.py
new file mode 100644
index 0000000000..5d8ed613a4
--- /dev/null
+++ b/scripts/utils/plural.py
@@ -0,0 +1,46 @@
+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:
+ """Return the count and appropriate word 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:
+ A string combining the count and the correct word form (e.g.,
+ "1 project", "5 projects").
+
+ """
+ word = select(count, singular, plural or f"{singular}s")
+ return f"{count} {word}"
+
+
+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")
diff --git a/scripts/utils/text.py b/scripts/utils/text.py
new file mode 100644
index 0000000000..491cc52615
--- /dev/null
+++ b/scripts/utils/text.py
@@ -0,0 +1,47 @@
+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
+
+ # 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, ""