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