diff --git a/rstw b/rsw.py
old mode 100755
new mode 100644
similarity index 51%
rename from rstw
rename to rsw.py
index 5127ffa..8f18b58
--- a/rstw
+++ b/rsw.py
@@ -1,6 +1,48 @@
-#!/usr/bin/python
+#!/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.0'
import os
@@ -8,9 +50,11 @@ import sys
import shutil
import datetime
import logging
+
import toml
import jinja2
import colorlog
+import docopt
from typing import List
from collections import namedtuple
@@ -28,69 +72,73 @@ from pygments.formatters import HtmlFormatter
# Setup logger. #
# ------------------------------------------------------------- #
-LOGFORMAT = " %(log_color)s%(levelname)-8s%(reset)s \
-%(log_color)s%(message)s%(reset)s"
+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))
+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'
+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': {},
}
-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?')
+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
-# Fallback to default settings
-
-# `build` section
-for dir in list(BUILD_DEFAULTS.keys()):
+def load_config(config_file: str = CONFIG_FILE):
+ """Load configuration file and fallback to default config."""
try:
- settings['build'][dir]
- except KeyError:
- settings['build'][dir] = BUILD_DEFAULTS[dir]
+ 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)
-# `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
+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. #
@@ -132,6 +180,8 @@ def parsedocinfo(data: str) -> dict:
# Extra reStructuredText directives and roles #
# ------------------------------------------------------------- #
+# DIRECTIVES
+
# Pygments reST `code-block` directive.
# Source: https://docutils.sourceforge.io/sandbox/code-block-directive/
# `code-block` BEGIN
@@ -172,14 +222,14 @@ def render_template(template: str, templates_dir = '.', **kwargs) -> str:
# Render HTML from reStructuredText. #
# ------------------------------------------------------------- #
-def render_html_body(text: str) -> str:
+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 = settings['docutils']
+ settings_overrides = config['docutils']
)
return html['body']
@@ -211,7 +261,7 @@ def copy_files(source_dir: str, destination_dir: str):
# Build site! #
# ------------------------------------------------------------- #
-def aggregate_lists(file_list: list) -> dict:
+def aggregate_lists(config: dict, file_list: list) -> dict:
posts = []
pages = []
for rst_file in file_list:
@@ -221,7 +271,7 @@ def aggregate_lists(file_list: list) -> dict:
# Validate date format
try:
dt = datetime.datetime.strptime(docinfo['date'],
- settings['site']['datetime_format'])
+ config['site']['datetime_format'])
except (KeyError, ValueError) as err:
log.error('Wrong formatted or missing date in file' +
'\'{}\': {}'.format(rst_file, err))
@@ -229,64 +279,68 @@ def aggregate_lists(file_list: list) -> dict:
# Add path ot file
docinfo['path'] = '/' + os.path.relpath(
os.path.splitext(rst_file)[0] + '.html',
- settings['build']['content_dir'])
+ config['dirs']['content_dir'])
try:
if docinfo['type'] == 'post':
posts.append(docinfo)
else:
pages.append(docinfo)
except KeyError:
- pages.append(docinfo) # may 'default_article_type'
+ 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'],
- settings['site']['datetime_format']), reverse = True)
+ config['site']['datetime_format']), reverse = True)
return {'posts': posts, 'pages': pages}
-def build_site():
+def build(config: dict):
+ """Build site."""
# Prepare build directory
- os.makedirs(settings['build']['build_dir'], exist_ok = True)
+ os.makedirs(config['dirs']['build_dir'], exist_ok = True)
log.info('Collecting data ...')
- rst_files = find_rst_files(settings['build']['content_dir'])
- lists = aggregate_lists(rst_files)
+ 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_meta = parsedocinfo(source)
+ page_docinfo = parsedocinfo(source)
# Render HTML files
- html_file_path = os.path.join(settings['build']['build_dir'],
+ html_file_path = os.path.join(config['dirs']['build_dir'],
os.path.relpath(os.path.splitext(rst_file)[0] + '.html',
- settings['build']['content_dir']))
+ config['dirs']['content_dir']))
log.info('Rendering page: %s' % html_file_path)
- # Page template from docinfo
+ # Get page template from docinfo
try:
- template = page_meta['template']
+ template = page_docinfo['template']
if not os.path.exists(os.path.join(
- settings['build']['templates_dir'], template)):
+ config['dirs']['templates_dir'], template)):
log.error('{}: Template does not exist: {}'.format(
rst_file, template))
sys.exit(1)
except KeyError:
- template = settings['build']['default_template']
+ template = config['defaults']['template']
# Render HTML
- html_body = render_html_body(source)
+ html_body = render_html_body(config, 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,
+ templates_dir = config['dirs']['templates_dir'],
+ pygments_theme = config['pygments']['theme'],
+ site = config['site'],
+ page = page_docinfo,
aggr = lists,
html = html_body
)
@@ -296,17 +350,75 @@ def build_site():
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']))
+ log.info("Copy static files from '{}' and '{}' to '{}'".format(
+ config['dirs']['static_dir'],
+ config['dirs']['content_dir'],
+ config['dirs']['build_dir']))
- copy_files(settings['build']['static_dir'],
- settings['build']['build_dir'])
- copy_files(settings['build']['content_dir'],
- settings['build']['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',
+ 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__':
- build_site()
+ cli()