This commit is contained in:
ge 2022-09-29 07:53:42 +03:00
commit ba26c8c1b6
5 changed files with 360 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
build/

36
Makefile Normal file
View File

@ -0,0 +1,36 @@
CONTENTDIR = content
STATICDIR = static
BUILDDIR = build
all: html
help:
@echo 'Build site'
@echo
@echo 'Available targets:'
@echo
@echo ' html render HTML'
@echo ' serve serve site'
@echo ' css <theme> generate Pygments stylesheet'
@echo
@echo 'Run without target to build html'
html:
test -d $(BUILDDIR) && rm -rf $(BUILDDIR) || true
python rstw
serve:
if hash http-server; \
then \
http-server $(BUILDDIR)/; \
else \
python3 -m http.server --directory $(BUILDDIR)/; \
fi
css:
mkdir -pv $(STATICDIR)/css/pygments
pygmentize -f html -S $(filter-out $@,$(MAKECMDGOALS)) -a .highlight \
> $(STATICDIR)/css/pygments/$(filter-out $@,$(MAKECMDGOALS)).css
%:
@:

6
README Normal file
View File

@ -0,0 +1,6 @@
reStructuredWeb -- static site generator.
Docs:
* https://nixhacks.net/rstw/
* https://git/nxhs.cloud/ge/rstw_docs/

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
docutils==0.17.1
Jinja2==3.1.2
Pygments==2.12.0
toml==0.10.2
colorlog==6.7.0

312
rstw Executable file
View File

@ -0,0 +1,312 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
__version__ = '0.1.0'
import os
import sys
import shutil
import datetime
import logging
import toml
import jinja2
import colorlog
from typing import List
from collections import namedtuple
from docutils.core import publish_parts
from docutils.core import publish_doctree
from docutils.writers import html5_polyglot
from docutils import nodes
from docutils.parsers.rst import directives
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import HtmlFormatter
# ------------------------------------------------------------- #
# Setup logger. #
# ------------------------------------------------------------- #
LOGFORMAT = " %(log_color)s%(levelname)-8s%(reset)s \
%(log_color)s%(message)s%(reset)s"
log = logging.getLogger('reStructuredWeb')
log.setLevel(logging.INFO)
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(
colorlog.ColoredFormatter(LOGFORMAT))
log.addHandler(handler)
# ------------------------------------------------------------- #
# Configuration. #
# ------------------------------------------------------------- #
# Set defaults
BUILD_DEFAULTS = {
'build_dir': 'build',
'content_dir': 'content',
'templates_dir': 'layouts',
'static_dir': 'static'
}
SITE_DEFAULTS = {'datetime_format': '%Y-%m-%d'}
PYGMENTS_DEFAULTS = {'theme': 'default'}
DOCUTILS_DEFAULTS = {}
# Read configuration file
try:
with open('settings.toml', 'r') as file:
settings = toml.loads(file.read())
except OSError as err:
log.error('Cannot open \'settings.toml\'. ' +
'Is the file actually in the current directory?')
# Fallback to default settings
# `build` section
for dir in list(BUILD_DEFAULTS.keys()):
try:
settings['build'][dir]
except KeyError:
settings['build'][dir] = BUILD_DEFAULTS[dir]
# `site` section
try:
settings['site']['datetime_format']
except KeyError:
settings['site']['datetime_format'] = \
SITE_DEFAULTS['datetime_format']
# `pygments` section
try:
settings['pygments']['theme']
except KeyError:
try:
settings['pygments']
except KeyError:
settings['pygments'] = {}
settings['pygments']['theme'] = PYGMENTS_DEFAULTS['theme']
# `docutils` section
try:
settings['docutils']
except KeyError:
settings['docutils'] = DOCUTILS_DEFAULTS
# ------------------------------------------------------------- #
# Parse docinfo from rST files. #
# ------------------------------------------------------------- #
# Code below is copy-pasted from https://github.com/zeddee/parsedocinfo
# and modified a little bit. Zeddee, thank you!
# Original license SPDX identifier: Apache-2.0
# -- parsedocinfo BEGIN --
DocInfo = namedtuple("DocInfo", 'name body')
def _traverse_fields(field: List) -> DocInfo:
field_name = field.getElementsByTagName("field_name")[0]
field_body = field.getElementsByTagName("field_body")[0]
return DocInfo(field_name.firstChild.nodeValue,
" ".join(val.firstChild.nodeValue for val in field_body.childNodes))
def _traverse_docinfo(docinfo_list: List) -> List[DocInfo]:
out = []
for i in docinfo_list:
for node in i.childNodes:
if node.tagName == "field":
out.append(_traverse_fields(node))
else:
out.append(DocInfo(node.tagName,
" ".join(val.nodeValue for val in node.childNodes)
)
)
return out
def parsedocinfo(data: str) -> dict:
docinfo = publish_doctree(data).asdom().getElementsByTagName("docinfo")
return dict(_traverse_docinfo(docinfo))
# -- parsedocinfo END --
# ------------------------------------------------------------- #
# Extra reStructuredText directives and roles #
# ------------------------------------------------------------- #
# Pygments reST `code-block` directive.
# Source: https://docutils.sourceforge.io/sandbox/code-block-directive/
# `code-block` BEGIN
pygments_formatter = HtmlFormatter()
def pygments_directive(name, arguments, options, content, lineno,
content_offset, block_text, state, state_machine):
try:
lexer = get_lexer_by_name(arguments[0])
except ValueError:
# no lexer found - use the text one instead of an exception
lexer = get_lexer_by_name('text')
parsed = highlight(u'\n'.join(content), lexer, pygments_formatter)
return [nodes.raw('', parsed, format='html')]
pygments_directive.arguments = (1, 0, 1)
pygments_directive.content = 1
directives.register_directive('code-block', pygments_directive)
# `code-block` END
# ------------------------------------------------------------- #
# Jinja2 specific functions. #
# ------------------------------------------------------------- #
def render_template(template: str, templates_dir = '.', **kwargs) -> str:
"""Render Jinja2 template from file. Usage::
render_template('index.j2',
templates_dir = './templates',
title = 'My title')
"""
env = jinja2.Environment(loader = jinja2.FileSystemLoader(templates_dir))
return env.get_template(template).render(**kwargs)
# ------------------------------------------------------------- #
# Render HTML from reStructuredText. #
# ------------------------------------------------------------- #
def render_html_body(text: str) -> str:
"""Return HTML body converted from reStructuredText.
See:
* help(docutils.core.publish_parts)
* https://docutils.sourceforge.io/docs/user/config.html
"""
html = publish_parts(source = text, writer = html5_polyglot.Writer(),
settings_overrides = settings['docutils']
)
return html['body']
# ------------------------------------------------------------- #
# File operations. #
# ------------------------------------------------------------- #
def find_rst_files(directory: str) -> list:
"""Return the list of rST files from directory.
Scan subdirectories too.
"""
file_list = []
for root, dirs, files in os.walk(directory):
for name in files:
if os.path.splitext(name)[1] == '.rst':
file_list.append(os.path.join(root, name))
return file_list
def write_to_file(path: str, data: str):
with open(path, 'w', encoding = 'utf-8') as file:
file.write(data)
def copy_files(source_dir: str, destination_dir: str):
shutil.copytree(source_dir, destination_dir,
ignore = shutil.ignore_patterns('*.rst'),
dirs_exist_ok = True)
# ------------------------------------------------------------- #
# Build site! #
# ------------------------------------------------------------- #
def aggregate_lists(file_list: list) -> dict:
posts = []
pages = []
for rst_file in file_list:
with open(rst_file, 'r', encoding = 'utf-8') as rst:
docinfo = parsedocinfo(rst.read())
# Validate date format
try:
dt = datetime.datetime.strptime(docinfo['date'],
settings['site']['datetime_format'])
except (KeyError, ValueError) as err:
log.error('Wrong formatted or missing date in file' +
'\'{}\': {}'.format(rst_file, err))
# Add path ot file
docinfo['path'] = '/' + os.path.relpath(
os.path.splitext(rst_file)[0] + '.html',
settings['build']['content_dir'])
try:
if docinfo['type'] == 'post':
posts.append(docinfo)
else:
pages.append(docinfo)
except KeyError:
pages.append(docinfo) # may 'default_article_type'
# Sort posts by date (newest in top)
posts.sort(key=lambda date: datetime.datetime.strptime(date['date'],
settings['site']['datetime_format']), reverse = True)
return {'posts': posts, 'pages': pages}
def build_site():
# Prepare build directory
os.makedirs(settings['build']['build_dir'], exist_ok = True)
log.info('Collecting data ...')
rst_files = find_rst_files(settings['build']['content_dir'])
lists = aggregate_lists(rst_files)
for rst_file in rst_files:
with open(rst_file, 'r', encoding = 'utf-8') as rst:
source = rst.read()
log.info('Parsing docinfo: %s' % rst_file)
page_meta = parsedocinfo(source)
# Render HTML files
html_file_path = os.path.join(settings['build']['build_dir'],
os.path.relpath(os.path.splitext(rst_file)[0] + '.html',
settings['build']['content_dir']))
log.info('Rendering page: %s' % html_file_path)
# Page template from docinfo
try:
template = page_meta['template']
if not os.path.exists(os.path.join(
settings['build']['templates_dir'], template)):
log.error('{}: Template does not exist: {}'.format(
rst_file, template))
sys.exit(1)
except KeyError:
template = settings['build']['default_template']
# Render HTML
html_body = render_html_body(source)
# Render template
html_page = render_template(
template,
templates_dir = settings['build']['templates_dir'],
pygments_theme = settings['pygments']['theme'],
site = settings['site'],
page = page_meta,
aggr = lists,
html = html_body
)
# Save rendered page
os.makedirs(os.path.dirname(html_file_path), exist_ok = True)
write_to_file(html_file_path, html_page)
# Copy additional files to build_dir
log.info('Copy static files from {}, {} to {}'.format(
settings['build']['static_dir'],
settings['build']['content_dir'],
settings['build']['build_dir']))
copy_files(settings['build']['static_dir'],
settings['build']['build_dir'])
copy_files(settings['build']['content_dir'],
settings['build']['build_dir'])
log.info('Success')
if __name__ == '__main__':
build_site()