#!/usr/bin/env python
# -*- coding: utf-8 -*-
# reStructuredWeb (rSW, reSW or rstW) -- static site generator.
# Copyright (c) 2022 ge https://nixnahcks.net/resw/
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""reSW static site generator.
Usage: rsw init [--no-makefile] []
rsw build [-c ]
rsw print [-c ] [--default] [--json]
rsw (-h | --help | -v | --version)
Commands:
init initialise new site.
build build site.
print print configuration.
Options:
-c , --config configuaration file.
-j, --json JSON output.
-d, --default print default config.
-M, --no-makefile do not create Makefile.
-h, --help print this help message and exit.
-v, --version print version and exit.
Copyright (C) 2022 ge .
License GPLv3+: GNU GPL version 3 or later .
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
"""
__version__ = '0.1.1'
import os
import sys
import shutil
import datetime
import logging
import importlib.resources
import toml
import jinja2
import colorlog
import docopt
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. #
# ------------------------------------------------------------- #
CONFIG_FILE = 'settings.toml'
DEFAULT_CONFIG = {
'defaults': {
'template': 'template.jinja2',
'type': 'page',
},
'dirs': {
'build_dir': 'build',
'content_dir': 'content',
'templates_dir': 'layouts',
'static_dir': 'static',
},
'site': {
'datetime_format': '%Y-%m-%d',
},
'pygments': {
'theme': 'default',
},
'docutils': {},
}
def merge_dicts(a: dict, b: dict, path = None) -> dict:
"""Merge b into a. Return modified a.
Ref: https://stackoverflow.com/a/7205107
"""
if path is None: path = []
for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
merge_dicts(a[key], b[key], path + [str(key)])
elif a[key] == b[key]:
pass # same leaf value
else:
a[key] = b[key] # replace existing key's values
else:
a[key] = b[key]
return a
def load_config(config_file: str = CONFIG_FILE):
"""Load configuration file and fallback to default config."""
try:
with open(config_file, 'r') as file:
config = toml.loads(file.read())
except OSError as err:
log.error("Cannot load configuration from '{}': {}".format(
config_file, err))
sys.exit(1)
return merge_dicts(DEFAULT_CONFIG, config)
def load_config_wrapper(args: dict) -> dict:
if args['--config']:
config = load_config(config_file = args['--config'])
else:
config = load_config()
return config
# ------------------------------------------------------------- #
# 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 #
# ------------------------------------------------------------- #
# DIRECTIVES
# 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(config: dict, 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 = config['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(config: dict, 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'],
config['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',
config['dirs']['content_dir'])
try:
if docinfo['type'] == 'post':
posts.append(docinfo)
else:
pages.append(docinfo)
except KeyError:
if config['defaults']['type'] == 'post':
posts.append(docinfo)
else:
pages.append(docinfo)
# Sort posts by date (newest in top)
posts.sort(key=lambda date: datetime.datetime.strptime(date['date'],
config['site']['datetime_format']), reverse = True)
return {'posts': posts, 'pages': pages}
def build(config: dict):
"""Build site."""
# Prepare build directory
os.makedirs(config['dirs']['build_dir'], exist_ok = True)
log.info('Collecting data ...')
rst_files = find_rst_files(config['dirs']['content_dir'])
lists = aggregate_lists(config, 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_docinfo = parsedocinfo(source)
# Render HTML files
html_file_path = os.path.join(config['dirs']['build_dir'],
os.path.relpath(os.path.splitext(rst_file)[0] + '.html',
config['dirs']['content_dir']))
log.info('Rendering page: %s' % html_file_path)
# Get page template from docinfo
try:
template = page_docinfo['template']
if not os.path.exists(os.path.join(
config['dirs']['templates_dir'], template)):
log.error('{}: Template does not exist: {}'.format(
rst_file, template))
sys.exit(1)
except KeyError:
template = config['defaults']['template']
# Render HTML
html_body = render_html_body(config, source)
# Render template
html_page = render_template(
template,
templates_dir = config['dirs']['templates_dir'],
pygments_theme = config['pygments']['theme'],
site = config['site'],
page = page_docinfo,
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("Copying static files from '{}' and '{}' to '{}'".format(
config['dirs']['static_dir'],
config['dirs']['content_dir'],
config['dirs']['build_dir']))
copy_files(config['dirs']['static_dir'], config['dirs']['build_dir'])
copy_files(config['dirs']['content_dir'], config['dirs']['build_dir'])
log.info('Success')
# ------------------------------------------------------------- #
# Command Line Interface. #
# ------------------------------------------------------------- #
def init(dirname: str = '.', no_makefile: bool = False):
"""Initialise new site."""
# Make site dirs
for dir in list(DEFAULT_CONFIG['dirs'].keys()):
if dir == 'build_dir':
pass
else:
os.makedirs(os.path.join(dirname,
DEFAULT_CONFIG['dirs'][dir]), exist_ok = True)
# Make Makefile
if not no_makefile:
makefile = render_template('Makefile.jinja2',
templates_dir = os.path.dirname(
importlib.resources.path('rsw', 'Makefile.jinja2')),
content_dir = DEFAULT_CONFIG['dirs']['content_dir'],
static_dir = DEFAULT_CONFIG['dirs']['static_dir'],
build_dir = DEFAULT_CONFIG['dirs']['build_dir'],
config = CONFIG_FILE
)
write_to_file(os.path.join(dirname, 'Makefile'), makefile)
# Make configuration file
settings = toml.dumps(DEFAULT_CONFIG)
write_to_file(os.path.join(dirname, CONFIG_FILE), settings)
# Make .gitignore
gitignore = 'build/' # file content
write_to_file(os.path.join(dirname, '.gitignore'), gitignore)
log.info("Site initialised in '%s'" % dirname)
def print_config(config: dict, args: dict):
if args['--json']:
import json
print(json.dumps(config, indent = 2))
else:
import pprint
pprint.pprint(config)
def cli():
args = docopt.docopt(__doc__, version = __version__)
if args['init']:
if args['']:
init(dirname = args[''], no_makefile = args['--no-makefile'])
else:
init(dirname = os.getcwd(), no_makefile = args['--no-makefile'])
elif args['build']:
build(config = load_config_wrapper(args))
elif args['print']:
if args['--default']:
print_config(DEFAULT_CONFIG, args)
else:
print_config(load_config_wrapper(args), args)
else:
pass
if __name__ == '__main__':
cli()