# Генератор статических сайтов в 90 строк Python кода Создано: 16 апреля 2025 [In English](/en/blog/2025/01-sitegen) Давным-давно я сделал небольшой сайт-визитку - он состоял из трех простеньких HTML-страниц, одного CSS-файла (который я генерировал из SCSS), нескольких шрифтов и картинок. Этого было более чем достаточно, чтобы ссылка на мой сайт красовалась в каком-либо резюме или профиле соцсети. ![Изображение старого сайта](/images/01-oldsite.png "Изображение старого сайта") Недавно я решил продолжить работу над своим pet проектом и хотел бы публиковать на своем сайте всякие заметки и статьи на эту тему. Мне не хотелось вручную возиться с HTML-файлам, поэтому я решил подыскать альтернативу в виде какого-нибудь статического генератора сайтов. В идеале, я хотел бы, чтобы он был: - Небольшим и достаточно простым - Мог работать с Markdown - Мог выполнять подсветку синтаксиса в блоках кода К сожалению, я не смог найти ни одно подходящее для себя решение, поэтому я решил собрать свое на коленке используя Python, парсер Markdown'а mistune, шаблонизатор Jinja2 и Pygments. Весь процесс генерации сводиться к следующему: 1. Для каждого файла во входном каталоге проверяем, является ли он Markdown - Если да - преобразуем его в HTML (с подсветкой синтаксиса) и записываем в выходной каталог - Если нет - копируем файл как есть в выходной каталог 2. Сжимаем содержимое выходного каталога У данного процесса генерации есть достаточно крупный недостаток - из-за того, что нет постобработки HTML, любые ссылки на другие страницы Markdown должны заканчиваться расширением `.html`[^1]. [^1]: Эту проблему можно устранить с помощью специальной настройки веб-сервера, которая заменяет в расширение `.md` на `.html` или путем автоматического дописывания расширения `.html` (к примеру: `try_files $uri $uri.html`). Сам код генератора: ```python import re, jinja2, mistune, shutil, os, pathlib, tarfile from pygments.lexers import get_lexer_by_name from pygments.formatters import HtmlFormatter from pygments import highlight class PygmentsHTMLRenderer(mistune.HTMLRenderer): def block_code(self, code: str, info = None): if not info: return '\n
%s
\n' % mistune.escape(code) lexer = get_lexer_by_name(info, stripall=True) formatter = HtmlFormatter(lineseparator='
') return highlight(code, lexer, formatter) def convert_markdown(page: str): plugins = ['footnotes', 'table', 'strikethrough', 'url'] renderer = PygmentsHTMLRenderer(escape=False) return mistune.create_markdown(plugins=plugins, renderer=renderer)(page) def extract_title(page: str): matches = re.match('

(.*?)

', page) if matches: return matches.group(1) return 'BlankHex' def handle_file(path: str, input_dir: str, output_dir: str, template_name: str): # Calculate input and output paths relpath = os.path.relpath(path, input_dir) input_path = path output_path = os.path.join(output_dir, relpath) if input_path.endswith('.md'): output_path = output_path.replace('.md', '.html') # Don't convert if output path exists if os.path.exists(output_path): return # Run conversion pathlib.Path(os.path.dirname(output_path)).mkdir(parents=True, exist_ok=True) if input_path.endswith('.md'): # Read Markdown document with open(input_path, 'r') as handle: markdown_page = handle.read() # Get Pygments styles for light and dark themes light_style = HtmlFormatter(style='default').get_style_defs() dark_style = HtmlFormatter(style='monokai').get_style_defs() # Convert Markdown document to HTML document html_page = convert_markdown(markdown_page) html_header = extract_title(html_page) environment = jinja2.Environment(loader=jinja2.FileSystemLoader('template/')) template = environment.get_template(template_name) output_page = template.render(title=html_header, body=html_page, light_style=light_style, dark_style=dark_style) # Write HTML document with open(output_path, 'w') as handle: handle.write(output_page) else: # Copy file as is shutil.copy(path, output_path) def convert_dir(input_dir: str, output_dir: str, template_name: str): # Convert or copy every file from the input directory to the output directory for subdir, dirs, files in os.walk(input_dir): for file in files: handle_file(os.path.join(subdir, file), input_dir, output_dir, template_name) # Remove output from previous run if os.path.isdir('public'): shutil.rmtree('public') if os.path.isfile('public.tgz'): os.remove('public.tgz') # Run conversion convert_dir('content', 'public', 'template.html') with tarfile.open('public.tgz', 'w:gz') as tar: for file in os.listdir('public'): tar.add(os.path.join('public', file), file) ```