summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMikhail Romanko <me@blankhex.com>2025-04-17 11:56:15 +0300
committerMikhail Romanko <me@blankhex.com>2025-04-17 12:06:16 +0300
commit92a528b03b9a0c77d84bd6811a4f7e10cf7c62e7 (patch)
tree4fdc2598b3533f7bb8d9de84dabeb865b300f36b
downloadsitegen-92a528b03b9a0c77d84bd6811a4f7e10cf7c62e7.tar.gz
Initial commit
-rw-r--r--.gitignore256
-rw-r--r--.idea/.gitignore3
-rw-r--r--.idea/SiteGen.iml10
-rw-r--r--.idea/inspectionProfiles/profiles_settings.xml6
-rw-r--r--.idea/misc.xml6
-rw-r--r--.idea/modules.xml8
-rw-r--r--.idea/vcs.xml6
-rw-r--r--content/common/404.md9
-rw-r--r--content/common/favicon.icobin0 -> 122702 bytes
-rw-r--r--content/common/favicon.pngbin0 -> 26905 bytes
-rw-r--r--content/common/images/01-oldsite.pngbin0 -> 133318 bytes
-rw-r--r--content/common/images/hex.pngbin0 -> 26905 bytes
-rw-r--r--content/common/index.md3
-rw-r--r--content/common/robots.txt14
-rw-r--r--content/en/about.md19
-rw-r--r--content/en/blog.md4
-rw-r--r--content/en/blog/2025/01-sitegen.md135
-rw-r--r--content/en/projects.md3
-rw-r--r--content/ru/about.md19
-rw-r--r--content/ru/blog.md4
-rw-r--r--content/ru/blog/2025/01-sitegen.md134
-rw-r--r--content/ru/projects.md3
-rw-r--r--main.py89
-rw-r--r--requirements.txt5
-rw-r--r--template/en.html5
-rw-r--r--template/none.html5
-rw-r--r--template/ru.html5
-rw-r--r--template/style.html16
28 files changed, 767 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..cfd9b9d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,256 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# UV
+# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+#uv.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
+.pdm.toml
+.pdm-python
+.pdm-build/
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+# Ruff stuff:
+.ruff_cache/
+
+# PyPI configuration file
+.pypirc
+
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+# Artifacts
+*.tgz
+public \ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..26d3352
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/.idea/SiteGen.iml b/.idea/SiteGen.iml
new file mode 100644
index 0000000..f286330
--- /dev/null
+++ b/.idea/SiteGen.iml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="PYTHON_MODULE" version="4">
+ <component name="NewModuleRootManager">
+ <content url="file://$MODULE_DIR$">
+ <excludeFolder url="file://$MODULE_DIR$/.venv" />
+ </content>
+ <orderEntry type="jdk" jdkName="Python 3.9 (SiteGen)" jdkType="Python SDK" />
+ <orderEntry type="sourceFolder" forTests="false" />
+ </component>
+</module> \ No newline at end of file
diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000..105ce2d
--- /dev/null
+++ b/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+<component name="InspectionProjectProfileManager">
+ <settings>
+ <option name="USE_PROJECT_PROFILE" value="false" />
+ <version value="1.0" />
+ </settings>
+</component> \ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..06ec480
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="Black">
+ <option name="sdkName" value="Python 3.9 (SiteGen)" />
+ </component>
+</project> \ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..d4faa8a
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="ProjectModuleManager">
+ <modules>
+ <module fileurl="file://$PROJECT_DIR$/.idea/SiteGen.iml" filepath="$PROJECT_DIR$/.idea/SiteGen.iml" />
+ </modules>
+ </component>
+</project> \ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project version="4">
+ <component name="VcsDirectoryMappings">
+ <mapping directory="$PROJECT_DIR$" vcs="Git" />
+ </component>
+</project> \ No newline at end of file
diff --git a/content/common/404.md b/content/common/404.md
new file mode 100644
index 0000000..05ed756
--- /dev/null
+++ b/content/common/404.md
@@ -0,0 +1,9 @@
+Sorry, there is no page you're looking for :(
+
+Check [main page](/en/about)
+
+---
+
+Извините, здесь нет страницы, которую вы ищете :(
+
+Загляните на [главную страницу](/ru/about)
diff --git a/content/common/favicon.ico b/content/common/favicon.ico
new file mode 100644
index 0000000..b0bfab5
--- /dev/null
+++ b/content/common/favicon.ico
Binary files differ
diff --git a/content/common/favicon.png b/content/common/favicon.png
new file mode 100644
index 0000000..8813264
--- /dev/null
+++ b/content/common/favicon.png
Binary files differ
diff --git a/content/common/images/01-oldsite.png b/content/common/images/01-oldsite.png
new file mode 100644
index 0000000..959de40
--- /dev/null
+++ b/content/common/images/01-oldsite.png
Binary files differ
diff --git a/content/common/images/hex.png b/content/common/images/hex.png
new file mode 100644
index 0000000..8813264
--- /dev/null
+++ b/content/common/images/hex.png
Binary files differ
diff --git a/content/common/index.md b/content/common/index.md
new file mode 100644
index 0000000..41b56c5
--- /dev/null
+++ b/content/common/index.md
@@ -0,0 +1,3 @@
+[English](en/about)
+
+[Русский](ru/about)
diff --git a/content/common/robots.txt b/content/common/robots.txt
new file mode 100644
index 0000000..335b71b
--- /dev/null
+++ b/content/common/robots.txt
@@ -0,0 +1,14 @@
+User-agent: *
+Allow: /
+
+User-agent: GPTBot
+Disallow: /
+
+User-agent: ChatGPT-User
+Disallow: /
+
+User-agent: anthropic-ai
+Disallow: /
+
+User-agent: Google-Extended
+Disallow: /
diff --git a/content/en/about.md b/content/en/about.md
new file mode 100644
index 0000000..6291e85
--- /dev/null
+++ b/content/en/about.md
@@ -0,0 +1,19 @@
+# About
+
+Graduate of <a rel="noreferrer" href="https://itmo.ru" target="_blank">ITMO University</a>
+and <a rel="noreferrer" href="https://www.spbstu.ru" target="_blank">Saint Petersburg State Polytechnic University</a>.
+
+I like:
+- programing in C and C++
+- studying various algorithms
+- developing games (usually small prototypes)
+- foxes
+
+## Contacts
+
+Social Media:
+- <a rel="noreferrer" target="_blank" href="https://twitter.com/_blankhex_">X/Twitter</a> (`@_blankhex_`)
+- <a rel="noreferrer" target="_blank" href="https://www.reddit.com/user/_blankhex_">Reddit</a> (`u/_blankhex_`)
+- <a rel="noreferrer" target="_blank" href="https://github.com/blankhex">GitHub</a> (`blankhex`)
+
+You can also contact me via email: [me@blankhex.com](mailto:me@blankhex.com) \ No newline at end of file
diff --git a/content/en/blog.md b/content/en/blog.md
new file mode 100644
index 0000000..b5363e1
--- /dev/null
+++ b/content/en/blog.md
@@ -0,0 +1,4 @@
+# Blog
+
+## 2025
+Apr 16 - [Static site generator in 90 lines of Python code](blog/2025/01-sitegen)
diff --git a/content/en/blog/2025/01-sitegen.md b/content/en/blog/2025/01-sitegen.md
new file mode 100644
index 0000000..99fb97f
--- /dev/null
+++ b/content/en/blog/2025/01-sitegen.md
@@ -0,0 +1,135 @@
+# Static site generator in 90 lines of Python code
+
+Created: Apr 16, 2025
+
+[На русском](/ru/blog/2025/01-sitegen)
+
+A long time ago, I made a small business card website. It consisted of three
+simple HTML pages, one CSS file (which I generated from SCSS), several fonts
+and images. That was more than enough to get a link to my website featured
+in a resume or social media profile.
+
+
+![Picture of the old website](/images/01-oldsite.png "Picture of the old website")
+
+I recently decided to continue working <a href="https://github.com/blankhex/bhlib" target="_blank">on my pet-project</a>
+and would like to publish all sorts of notes and articles on this topic on my
+website. I didn't want to manually mess with HTML files, so I decided to look
+for an alternative in the form of some kind of static website generator.
+Ideally, I would like it to be:
+
+- Small and simple
+- Able to work with Markdown
+- Able syntax-highlight blocks of code
+
+Unfortunately, I couldn't find any suitable solutions for myself, so I decided
+to build my own using Python, <a href="https://mistune.lepture.com/en/latest/" target="_blank">mistune</a>
+Markdown parser, <a href="https://jinja.palletsprojects.com/en/stable/" target="_blank">Jinja2</a>
+template engine, and <a href="https://pygments.org" target="_blank">Pygments</a>.
+The whole generation process boils down to the following:
+
+1. For every file in the input directory check whether it is Markdown
+ - If yes - convert it to HTML (with highlighting) and write to output directory
+ - If no - copy as is to output directory
+2. Compress content of the output directory
+
+This generation process has a rather major drawback - due to the fact that
+there is no post-processing of HTML, any links to other Markdown pages must
+end with a `.html` extension[^1].
+
+[^1]: This can be mitigated by special web-server configuration, that replaces
+ `.md` extension with `.html` or by omitting `.md` extension entirely and
+ using something like `try_files $uri $uri.html`
+
+Here is the code:
+
+```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<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)
+```
+
diff --git a/content/en/projects.md b/content/en/projects.md
new file mode 100644
index 0000000..3440a80
--- /dev/null
+++ b/content/en/projects.md
@@ -0,0 +1,3 @@
+# Projects
+
+As of now this page is empty :( \ No newline at end of file
diff --git a/content/ru/about.md b/content/ru/about.md
new file mode 100644
index 0000000..d7e0007
--- /dev/null
+++ b/content/ru/about.md
@@ -0,0 +1,19 @@
+# Обо мне
+
+Выпускник университетов <a rel="noreferrer" href="https://itmo.ru" target="_blank">ИТМО</a>
+и <a rel="noreferrer" href="https://www.spbstu.ru" target="_blank">СПбПУ</a>.
+
+Я люблю:
+- программирование на C и C++
+- изучение различных алгоритмов
+- разработку игр (обычно небольшие прототипы)
+- лис
+
+## Контакты
+
+Я в соцсетях:
+- <a rel="noreferrer" target="_blank" href="https://twitter.com/_blankhex_">X/Twitter</a> (`@_blankhex_`)
+- <a rel="noreferrer" target="_blank" href="https://www.reddit.com/user/_blankhex_">Reddit</a> (`u/_blankhex_`)
+- <a rel="noreferrer" target="_blank" href="https://github.com/blankhex">GitHub</a> (`blankhex`)
+
+Также можно воспользоваться электронной почтой [me@blankhex.com](mailto:me@blankhex.com)
diff --git a/content/ru/blog.md b/content/ru/blog.md
new file mode 100644
index 0000000..b12aece
--- /dev/null
+++ b/content/ru/blog.md
@@ -0,0 +1,4 @@
+# Блог
+
+## 2025
+16 апреля - [Генератор статических сайтов в 90 строк Python кода](blog/2025/01-sitegen) \ No newline at end of file
diff --git a/content/ru/blog/2025/01-sitegen.md b/content/ru/blog/2025/01-sitegen.md
new file mode 100644
index 0000000..76b7353
--- /dev/null
+++ b/content/ru/blog/2025/01-sitegen.md
@@ -0,0 +1,134 @@
+# Генератор статических сайтов в 90 строк Python кода
+
+Создано: 16 апреля 2025
+
+[In English](/en/blog/2025/01-sitegen)
+
+Давным-давно я сделал небольшой сайт-визитку - он состоял из трех простеньких
+HTML-страниц, одного CSS-файла (который я генерировал из SCSS), нескольких
+шрифтов и картинок. Этого было более чем достаточно, чтобы ссылка на мой
+сайт красовалась в каком-либо резюме или профиле соцсети.
+
+![Изображение старого сайта](/images/01-oldsite.png "Изображение старого сайта")
+
+Недавно я решил продолжить работу <a href="https://github.com/blankhex/bhlib" target="_blank">над своим pet проектом</a>
+и хотел бы публиковать на своем сайте всякие заметки и статьи на эту тему.
+Мне не хотелось вручную возиться с HTML-файлам, поэтому я решил подыскать
+альтернативу в виде какого-нибудь статического генератора сайтов. В идеале, я
+хотел бы, чтобы он был:
+
+- Небольшим и достаточно простым
+- Мог работать с Markdown
+- Мог выполнять подсветку синтаксиса в блоках кода
+
+К сожалению, я не смог найти ни одно подходящее для себя решение, поэтому
+я решил собрать свое на коленке используя Python, парсер Markdown'а <a href="https://mistune.lepture.com/en/latest/" target="_blank">mistune</a>,
+шаблонизатор <a href="https://jinja.palletsprojects.com/en/stable/" target="_blank">Jinja2</a>
+и <a href="https://pygments.org" target="_blank">Pygments</a>. Весь процесс
+генерации сводиться к следующему:
+
+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<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)
+```
diff --git a/content/ru/projects.md b/content/ru/projects.md
new file mode 100644
index 0000000..16a61b1
--- /dev/null
+++ b/content/ru/projects.md
@@ -0,0 +1,3 @@
+# Проекты
+
+Пока здесь ничего нет :( \ No newline at end of file
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..e9fd447
--- /dev/null
+++ b/main.py
@@ -0,0 +1,89 @@
+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 for english, russian and 'common' folder
+convert_dir('content/en', 'public/en', 'en.html')
+convert_dir('content/ru', 'public/ru', 'ru.html')
+convert_dir('content/common', 'public', 'none.html')
+with tarfile.open('public.tgz', 'w:gz') as tar:
+ for file in os.listdir('public'):
+ tar.add(os.path.join('public', file), file)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..7694f3b
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,5 @@
+Jinja2==3.1.6
+MarkupSafe==3.0.2
+mistune==3.1.3
+Pygments==2.19.1
+typing_extensions==4.13.2
diff --git a/template/en.html b/template/en.html
new file mode 100644
index 0000000..0ffc06e
--- /dev/null
+++ b/template/en.html
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<html lang="en">
+<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"/>{% include 'style.html' %}<link rel="icon" type="image/png" href="/favicon.png"/><title>{{ title }}</title></head>
+<body><header><div id="logo">blankhex.com</div><nav><a href="/en/about">About</a><a href="/en/blog">Blog</a><a href="/en/projects">Projects</a></nav></header><article>{{ body }}</article><hr><footer><img style="margin:auto; display:block; max-width: 25%; height: auto;" src="/images/hex.png" alt="outline of an orange hexagon"/></footer></body>
+</html>
diff --git a/template/none.html b/template/none.html
new file mode 100644
index 0000000..fdb646c
--- /dev/null
+++ b/template/none.html
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"/>{% include 'style.html' %}<link rel="icon" type="image/png" href="/favicon.png"/></head>
+<body><header><div id="logo">blankhex.com</div></header><article>{{ body }}</article><hr></body>
+</html>
diff --git a/template/ru.html b/template/ru.html
new file mode 100644
index 0000000..cc64c9c
--- /dev/null
+++ b/template/ru.html
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<html lang="ru">
+<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"/>{% include 'style.html' %}<link rel="icon" type="image/png" href="/favicon.png"/><title>{{ title }}</title></head>
+<body><header><div id="logo">blankhex.com</div><nav><a href="/ru/about">Обо мне</a><a href="/ru/blog">Блог</a><a href="/ru/projects">Проекты</a></nav></header><article>{{ body }}</article><hr><footer><img style="margin:auto; display:block; max-width: 25%; height: auto;" src="/images/hex.png" alt="очертание оранжевого шестиугольника"/></footer></body>
+</html>
diff --git a/template/style.html b/template/style.html
new file mode 100644
index 0000000..01d4a37
--- /dev/null
+++ b/template/style.html
@@ -0,0 +1,16 @@
+<style>
+body { max-width: 45em; margin: 40px auto; padding: 0 10px; font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; color: #444 }
+h1, h2, h3 { line-height:1.2 }
+header { display: flex; justify-content: space-between; }
+header > nav > a { margin-left: 1em; }
+pre { font-size: 80%; overflow-x: scroll; }
+img { display: block; margin: auto; max-width: 100%; }
+#logo { font-family: Georgia, serif; font-weight: bold; }
+{{ light_style }}
+@media (prefers-color-scheme: dark) {
+body { color: #c9d1d9; background: #0d1117 }
+a:link { color:#58a6ff }
+a:visited { color: #8e96f0 }
+{{ dark_style }}
+}
+</style> \ No newline at end of file