Initial commit
This commit is contained in:
256
.gitignore
vendored
Normal file
256
.gitignore
vendored
Normal file
@@ -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
|
||||||
3
.idea/.gitignore
generated
vendored
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
10
.idea/SiteGen.iml
generated
Normal file
10
.idea/SiteGen.iml
generated
Normal file
@@ -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>
|
||||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
||||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -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>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -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>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -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>
|
||||||
9
content/common/404.md
Normal file
9
content/common/404.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Sorry, there is no page you're looking for :(
|
||||||
|
|
||||||
|
Check [main page](/en/about)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Извините, здесь нет страницы, которую вы ищете :(
|
||||||
|
|
||||||
|
Загляните на [главную страницу](/ru/about)
|
||||||
BIN
content/common/favicon.ico
Normal file
BIN
content/common/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
content/common/favicon.png
Normal file
BIN
content/common/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
BIN
content/common/images/01-oldsite.png
Normal file
BIN
content/common/images/01-oldsite.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
BIN
content/common/images/hex.png
Normal file
BIN
content/common/images/hex.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
3
content/common/index.md
Normal file
3
content/common/index.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[English](en/about)
|
||||||
|
|
||||||
|
[Русский](ru/about)
|
||||||
14
content/common/robots.txt
Normal file
14
content/common/robots.txt
Normal file
@@ -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: /
|
||||||
19
content/en/about.md
Normal file
19
content/en/about.md
Normal file
@@ -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)
|
||||||
4
content/en/blog.md
Normal file
4
content/en/blog.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Blog
|
||||||
|
|
||||||
|
## 2025
|
||||||
|
Apr 16 - [Static site generator in 90 lines of Python code](blog/2025/01-sitegen)
|
||||||
135
content/en/blog/2025/01-sitegen.md
Normal file
135
content/en/blog/2025/01-sitegen.md
Normal file
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
3
content/en/projects.md
Normal file
3
content/en/projects.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Projects
|
||||||
|
|
||||||
|
As of now this page is empty :(
|
||||||
19
content/ru/about.md
Normal file
19
content/ru/about.md
Normal file
@@ -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)
|
||||||
4
content/ru/blog.md
Normal file
4
content/ru/blog.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Блог
|
||||||
|
|
||||||
|
## 2025
|
||||||
|
16 апреля - [Генератор статических сайтов в 90 строк Python кода](blog/2025/01-sitegen)
|
||||||
134
content/ru/blog/2025/01-sitegen.md
Normal file
134
content/ru/blog/2025/01-sitegen.md
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
# Генератор статических сайтов в 90 строк Python кода
|
||||||
|
|
||||||
|
Создано: 16 апреля 2025
|
||||||
|
|
||||||
|
[In English](/en/blog/2025/01-sitegen)
|
||||||
|
|
||||||
|
Давным-давно я сделал небольшой сайт-визитку - он состоял из трех простеньких
|
||||||
|
HTML-страниц, одного CSS-файла (который я генерировал из SCSS), нескольких
|
||||||
|
шрифтов и картинок. Этого было более чем достаточно, чтобы ссылка на мой
|
||||||
|
сайт красовалась в каком-либо резюме или профиле соцсети.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Недавно я решил продолжить работу <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)
|
||||||
|
```
|
||||||
3
content/ru/projects.md
Normal file
3
content/ru/projects.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Проекты
|
||||||
|
|
||||||
|
Пока здесь ничего нет :(
|
||||||
89
main.py
Normal file
89
main.py
Normal file
@@ -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)
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -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
|
||||||
5
template/en.html
Normal file
5
template/en.html
Normal file
@@ -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>
|
||||||
5
template/none.html
Normal file
5
template/none.html
Normal file
@@ -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>
|
||||||
5
template/ru.html
Normal file
5
template/ru.html
Normal file
@@ -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>
|
||||||
16
template/style.html
Normal file
16
template/style.html
Normal file
@@ -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>
|
||||||
Reference in New Issue
Block a user