Files
sitegen/content/ru/blog/2025/01-sitegen.md
2025-04-17 12:06:16 +03:00

6.5 KiB
Raw Blame History

Генератор статических сайтов в 90 строк Python кода

Создано: 16 апреля 2025

In English

Давным-давно я сделал небольшой сайт-визитку - он состоял из трех простеньких HTML-страниц, одного CSS-файла (который я генерировал из SCSS), нескольких шрифтов и картинок. Этого было более чем достаточно, чтобы ссылка на мой сайт красовалась в каком-либо резюме или профиле соцсети.

Изображение старого сайта

Недавно я решил продолжить работу над своим pet проектом и хотел бы публиковать на своем сайте всякие заметки и статьи на эту тему. Мне не хотелось вручную возиться с HTML-файлам, поэтому я решил подыскать альтернативу в виде какого-нибудь статического генератора сайтов. В идеале, я хотел бы, чтобы он был:

  • Небольшим и достаточно простым
  • Мог работать с Markdown
  • Мог выполнять подсветку синтаксиса в блоках кода

К сожалению, я не смог найти ни одно подходящее для себя решение, поэтому я решил собрать свое на коленке используя Python, парсер Markdown'а mistune, шаблонизатор Jinja2 и Pygments. Весь процесс генерации сводиться к следующему:

  1. Для каждого файла во входном каталоге проверяем, является ли он Markdown
    • Если да - преобразуем его в HTML (с подсветкой синтаксиса) и записываем в выходной каталог
    • Если нет - копируем файл как есть в выходной каталог
  2. Сжимаем содержимое выходного каталога

У данного процесса генерации есть достаточно крупный недостаток - из-за того, что нет постобработки HTML, любые ссылки на другие страницы Markdown должны заканчиваться расширением .html1 .

Сам код генератора:

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<pre><code>%s</code></pre>\n' % mistune.escape(code)
        lexer = get_lexer_by_name(info, stripall=True)
        formatter = HtmlFormatter(lineseparator='<br>')
        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('<h1>(.*?)</h1>', 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)

  1. Эту проблему можно устранить с помощью специальной настройки веб-сервера, которая заменяет в расширение .md на .html или путем автоматического дописывания расширения .html (к примеру: try_files $uri $uri.html). ↩︎