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'''{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..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' \n' + f' \n' + f' \n' + f" ", + ) + + doc.add_raw("
    {name}{percentage:.2f}%
    \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'{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"{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, ""