# src/litterate/generator.py annotated source

Back to index

## Generator Module

This module contains the bulk of the logic for generating litterate pages. It exports a function that the command-line utility calls with configurations.

    5

    6import html

    7from pathlib import Path

    8from typing import Any

    9

    10import markdown

    11from jinja2 import Template

    12

This isn't optimal, but for now, we read the three template files into memory at module load time, so we can reuse them later without repeatedly reading from disk.

    15

    16TEMPLATE_DIR = Path(__file__).parent / "templates"

    17INDEX_TEMPLATE = Template((TEMPLATE_DIR / "index.html").read_text())

    18STYLES_CSS = (TEMPLATE_DIR / "main.css").read_text()

    19SOURCE_TEMPLATE = Template((TEMPLATE_DIR / "source.html").read_text())

    20

    21

### Language Detection

Detect the programming language from file extension for syntax highlighting. If the user specifies a language in the config, we use that instead of auto-detection.

    26

    27def detect_language(file_path: Path, config: dict[str, Any]) -> str:

    28    if "language" in config and config["language"] != "auto":

    29        return config["language"]

    30

Map common file extensions to Highlight.js language identifiers. This covers most popular programming languages.

    33    ext_map = {

    34        ".py": "python",

    35        ".js": "javascript",

    36        ".jsx": "javascript",

    37        ".ts": "typescript",

    38        ".tsx": "typescript",

    39        ".java": "java",

    40        ".cpp": "cpp",

    41        ".cc": "cpp",

    42        ".cxx": "cpp",

    43        ".c": "c",

    44        ".h": "c",

    45        ".hpp": "cpp",

    46        ".rs": "rust",

    47        ".go": "go",

    48        ".rb": "ruby",

    49        ".php": "php",

    50        ".sh": "bash",

    51        ".bash": "bash",

    52        ".zsh": "bash",

    53        ".fish": "bash",

    54        ".swift": "swift",

    55        ".kt": "kotlin",

    56        ".scala": "scala",

    57        ".r": "r",

    58        ".sql": "sql",

    59        ".html": "html",

    60        ".xml": "xml",

    61        ".css": "css",

    62        ".scss": "scss",

    63        ".sass": "sass",

    64        ".json": "json",

    65        ".yaml": "yaml",

    66        ".yml": "yaml",

    67        ".toml": "toml",

    68        ".md": "markdown",

    69        ".rst": "rst",

    70        ".tex": "latex",

    71        ".vim": "vim",

    72        ".lua": "lua",

    73        ".pl": "perl",

    74        ".pm": "perl",

    75    }

    76

    77    suffix = file_path.suffix.lower()

    78    return ext_map.get(suffix, "plaintext")

    79

    80

### Helper Functions

These utility functions help with text processing and path management.

    84

Line Wrapping: Helper function to wrap a given line of text into multiple lines, with `limit` characters per line.

    87

    88def wrap_line(line: str, limit: int) -> str:

    89    result = []

    90    for i in range(0, len(line), limit):

    91        result.append(line[i:i + limit])

    92    return "\n".join(result)

    93

    94

HTML Encoding: Escape characters that won't display in HTML correctly, like the very common `>`, `<`, and `&` characters in code.

    97

    98def encode_html(code: str) -> str:

    99    return html.escape(code)

    100

    101

Path Mapping: Function that maps a given source file to the path where its annotated version will be saved.

    104

    105def get_output_path_for_source_path(source_path: Path, config: dict[str, Any]) -> Path:

    106    output_dir = Path(config["output_directory"])

    107    return output_dir / f"{source_path}.html"

    108

    109

### Index Page Generation

Function to populate the `index.html` page of the generated site with all the source links, project name, description, etc.

    114

    115def populate_index_page(source_files: list[Path], config: dict[str, Any]) -> str:

    116    output_dir = Path(config["output_directory"])

    117    base_url = config["baseURL"]

    118

Generate links for each source file, handling both local and web deployment scenarios.

    120    files_html = []

    121    for source_path in source_files:

    122        output_path = get_output_path_for_source_path(source_path, config)

    123        relative_path = output_path.relative_to(output_dir)

    124

    125        if base_url == "./":

    126            link = str(relative_path)

    127        else:

    128            link = f"{base_url}{relative_path}"

    129

    130        files_html.append(

    131            f'<p class="sourceLink"><a href="{link}">{source_path}</a></p>'

    132        )

    133

For CSS link, handle baseURL properly. The template appends "main.css", so just pass the directory path.

    136    css_base = "" if base_url == "./" else base_url

    137

    138    return INDEX_TEMPLATE.render(

    139        title=config["name"],

    140        description=markdown.markdown(config["description"]),

    141        sourcesList="\n".join(files_html),

    142        baseURL=css_base,

    143    )

    144

    145

### Parsing Source Lines

`lines_to_line_pairs` works by having two arrays -- one of the annotation-lineNumber-source line tuples, and one buffer for the annotation text being read.

    150

    151def lines_to_line_pairs(

    152    lines: list[str], config: dict[str, Any]

    153) -> list[tuple[str, str, int]]:

    154    line_pairs = []

    155    doc_line = ""

    156    in_annotation_comment = False

    157

    158    annotation_start = config["annotation_start_mark"]

    159    annotation_continue = config["annotation_continue_mark"]

    160    wrap_limit = config["wrap"]

    161

    162    def process_code_line(code_line: str) -> str:

    163        encoded = encode_html(code_line)

    164        if wrap_limit != 0:

    165            return wrap_line(encoded, wrap_limit)

    166        return encoded

    167

    168    def push_pair(code_line: str, line_number: int) -> None:

    169        nonlocal doc_line

    170

    171        if doc_line:

Add spacing between annotation blocks for better readability

    173            if line_pairs and line_pairs[-1][0]:

    174                line_pairs.append(("", "", ""))

    175            line_pairs.append(

    176                (markdown.markdown(doc_line), process_code_line(code_line), line_number)

    177            )

    178        else:

    179            line_pairs.append(("", process_code_line(code_line), line_number))

    180

    181        doc_line = ""

    182

    183    def push_comment(line: str) -> None:

    184        nonlocal doc_line

    185

    186        if line.strip().startswith(annotation_start):

    187            doc_line = line.replace(annotation_start, "", 1).strip()

    188        else:

    189            doc_line += "\n" + line.replace(annotation_continue, "", 1).strip()

    190

Main parsing loop: iterate through source lines and identify annotation comments vs regular code lines.

    193    for idx, line in enumerate(lines):

    194        stripped = line.strip()

    195

    196        if stripped.startswith(annotation_start):

    197            in_annotation_comment = True

    198            push_comment(line)

    199        elif stripped.startswith(annotation_continue):

    200            if in_annotation_comment:

    201                push_comment(line)

    202            else:

    203                push_pair(line, idx + 1)

    204        else:

    205            if in_annotation_comment:

    206                in_annotation_comment = False

    207            push_pair(line, idx + 1)

    208

    209    return line_pairs

    210

    211

### Page Creation

This function is called for each source file, to process and save the Litterate version of the source file in the correct place.

    216

    217def create_and_save_page(source_path: Path, config: dict[str, Any]) -> None:

    218    content = source_path.read_text(encoding="utf-8")

    219

    220    line_pairs = lines_to_line_pairs(content.split("\n"), config)

    221

    222    language = detect_language(source_path, config)

    223

Generate HTML for each line, combining documentation and source code

    225    lines_html = []

    226    for doc, source, line_number in line_pairs:

    227        lines_html.append(

    228            f'<div class="line">'

    229            f'<div class="doc">{doc}</div>'

    230            f'<pre class="source language-{language}">'

    231            f'<strong class="lineNumber">{line_number}</strong>'

    232            f'<code class="language-{language}">{source}</code>'

    233            f'</pre>'

    234            f'</div>'

    235        )

    236

Calculate relative paths for navigation, handling both local file viewing and web deployment

    238    output_dir = Path(config["output_directory"])

    239    output_path = get_output_path_for_source_path(source_path, config)

    240    base_url = config["baseURL"]

    241

    242    if base_url == "./":

    243        depth = len(output_path.relative_to(output_dir).parts) - 1

    244        base_link = "../" * depth if depth > 0 else "./"

    245        css_link = base_link + "main.css"

    246    else:

    247        base_link = base_url

    248        css_link = f"{base_url}main.css"

    249

    250    annotated_page = SOURCE_TEMPLATE.render(

    251        title=str(source_path),

    252        lines="\n".join(lines_html),

    253        baseURL=base_link,

    254        cssURL=css_link,

    255    )

    256

Save to output directory, creating parent directories as needed

    258    output_path.parent.mkdir(parents=True, exist_ok=True)

    259    output_path.write_text(annotated_page, encoding="utf-8")

    260

    261

### Main Entry Point

This is the main function called by the CLI to generate literate documentation for all specified source files.

    266

    267def generate_litterate_pages(source_files: list[Path], config: dict[str, Any]) -> None:

    268    output_directory = Path(config["output_directory"])

    269

    270    output_directory.mkdir(parents=True, exist_ok=True)

    271

Generate and write the index page listing all source files

    273    index_html = populate_index_page(source_files, config)

    274    (output_directory / "index.html").write_text(index_html, encoding="utf-8")

    275

Write the CSS stylesheet

    277    (output_directory / "main.css").write_text(STYLES_CSS, encoding="utf-8")

    278

Process each source file, generating its annotated page

    280    for source_file in source_files:

    281        create_and_save_page(source_file, config)

    282        print(f"Annotated {source_file}")

    283