commit ba26c8c1b6f9019ae266ceed5e88a18fac78c25f Author: ge Date: Thu Sep 29 07:53:42 2022 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..be7d8fd --- /dev/null +++ b/Makefile @@ -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 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 + +%: + @: diff --git a/README b/README new file mode 100644 index 0000000..ce0bf7f --- /dev/null +++ b/README @@ -0,0 +1,6 @@ +reStructuredWeb -- static site generator. + +Docs: + +* https://nixhacks.net/rstw/ +* https://git/nxhs.cloud/ge/rstw_docs/ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0fdb1d8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +docutils==0.17.1 +Jinja2==3.1.2 +Pygments==2.12.0 +toml==0.10.2 +colorlog==6.7.0 diff --git a/rstw b/rstw new file mode 100755 index 0000000..5127ffa --- /dev/null +++ b/rstw @@ -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()