#!/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()