feat: Add CLI, various improvements, renamed to rsw.py

This commit is contained in:
ge 2022-09-29 23:23:54 +03:00
parent 1fe0b0b7e2
commit cb51684a6b

270
rstw → rsw.py Executable file → Normal file
View File

@ -1,6 +1,48 @@
#!/usr/bin/python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 <https://www.gnu.org/licenses/>.
"""reSW static site generator.
Usage: rsw init [--no-makefile] [<name>]
rsw build [-c <file>]
rsw print [-c <file>] [--default] [--json]
rsw (-h | --help | -v | --version)
Commands:
init initialise new site.
build build site.
print print configuration.
Options:
-c <file>, --config <file> 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 <http://nixhacks.net/resw/>.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
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' __version__ = '0.1.0'
import os import os
@ -8,9 +50,11 @@ import sys
import shutil import shutil
import datetime import datetime
import logging import logging
import toml import toml
import jinja2 import jinja2
import colorlog import colorlog
import docopt
from typing import List from typing import List
from collections import namedtuple from collections import namedtuple
@ -28,69 +72,73 @@ from pygments.formatters import HtmlFormatter
# Setup logger. # # Setup logger. #
# ------------------------------------------------------------- # # ------------------------------------------------------------- #
LOGFORMAT = " %(log_color)s%(levelname)-8s%(reset)s \ LOGFORMAT = '%(log_color)s%(levelname)-8s%(reset)s \
%(log_color)s%(message)s%(reset)s" %(log_color)s%(message)s%(reset)s'
log = logging.getLogger('reStructuredWeb') log = logging.getLogger('reStructuredWeb')
log.setLevel(logging.INFO) log.setLevel(logging.INFO)
handler = logging.StreamHandler(stream=sys.stdout) handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter( handler.setFormatter(colorlog.ColoredFormatter(LOGFORMAT))
colorlog.ColoredFormatter(LOGFORMAT))
log.addHandler(handler) log.addHandler(handler)
# ------------------------------------------------------------- # # ------------------------------------------------------------- #
# Configuration. # # Configuration. #
# ------------------------------------------------------------- # # ------------------------------------------------------------- #
# Set defaults CONFIG_FILE = 'settings.toml'
BUILD_DEFAULTS = { DEFAULT_CONFIG = {
'defaults': {
'template': 'template.jinja2',
'type': 'page',
},
'dirs': {
'build_dir': 'build', 'build_dir': 'build',
'content_dir': 'content', 'content_dir': 'content',
'templates_dir': 'layouts', 'templates_dir': 'layouts',
'static_dir': 'static' '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 def merge_dicts(a: dict, b: dict, path = None) -> dict:
try: """Merge b into a. Return modified a.
with open('settings.toml', 'r') as file: Ref: https://stackoverflow.com/a/7205107
settings = toml.loads(file.read()) """
except OSError as err: if path is None: path = []
log.error('Cannot open \'settings.toml\'. ' + for key in b:
'Is the file actually in the current directory?') 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 def load_config(config_file: str = CONFIG_FILE):
"""Load configuration file and fallback to default config."""
# `build` section
for dir in list(BUILD_DEFAULTS.keys()):
try: try:
settings['build'][dir] with open(config_file, 'r') as file:
except KeyError: config = toml.loads(file.read())
settings['build'][dir] = BUILD_DEFAULTS[dir] 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 def load_config_wrapper(args: dict) -> dict:
try: if args['--config']:
settings['site']['datetime_format'] config = load_config(config_file = args['--config'])
except KeyError: else:
settings['site']['datetime_format'] = \ config = load_config()
SITE_DEFAULTS['datetime_format'] return config
# `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. # # Parse docinfo from rST files. #
@ -132,6 +180,8 @@ def parsedocinfo(data: str) -> dict:
# Extra reStructuredText directives and roles # # Extra reStructuredText directives and roles #
# ------------------------------------------------------------- # # ------------------------------------------------------------- #
# DIRECTIVES
# Pygments reST `code-block` directive. # Pygments reST `code-block` directive.
# Source: https://docutils.sourceforge.io/sandbox/code-block-directive/ # Source: https://docutils.sourceforge.io/sandbox/code-block-directive/
# `code-block` BEGIN # `code-block` BEGIN
@ -172,14 +222,14 @@ def render_template(template: str, templates_dir = '.', **kwargs) -> str:
# Render HTML from reStructuredText. # # 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. """Return HTML body converted from reStructuredText.
See: See:
* help(docutils.core.publish_parts) * help(docutils.core.publish_parts)
* https://docutils.sourceforge.io/docs/user/config.html * https://docutils.sourceforge.io/docs/user/config.html
""" """
html = publish_parts(source = text, writer = html5_polyglot.Writer(), html = publish_parts(source = text, writer = html5_polyglot.Writer(),
settings_overrides = settings['docutils'] settings_overrides = config['docutils']
) )
return html['body'] return html['body']
@ -211,7 +261,7 @@ def copy_files(source_dir: str, destination_dir: str):
# Build site! # # Build site! #
# ------------------------------------------------------------- # # ------------------------------------------------------------- #
def aggregate_lists(file_list: list) -> dict: def aggregate_lists(config: dict, file_list: list) -> dict:
posts = [] posts = []
pages = [] pages = []
for rst_file in file_list: for rst_file in file_list:
@ -221,7 +271,7 @@ def aggregate_lists(file_list: list) -> dict:
# Validate date format # Validate date format
try: try:
dt = datetime.datetime.strptime(docinfo['date'], dt = datetime.datetime.strptime(docinfo['date'],
settings['site']['datetime_format']) config['site']['datetime_format'])
except (KeyError, ValueError) as err: except (KeyError, ValueError) as err:
log.error('Wrong formatted or missing date in file' + log.error('Wrong formatted or missing date in file' +
'\'{}\': {}'.format(rst_file, err)) '\'{}\': {}'.format(rst_file, err))
@ -229,64 +279,68 @@ def aggregate_lists(file_list: list) -> dict:
# Add path ot file # Add path ot file
docinfo['path'] = '/' + os.path.relpath( docinfo['path'] = '/' + os.path.relpath(
os.path.splitext(rst_file)[0] + '.html', os.path.splitext(rst_file)[0] + '.html',
settings['build']['content_dir']) config['dirs']['content_dir'])
try: try:
if docinfo['type'] == 'post': if docinfo['type'] == 'post':
posts.append(docinfo) posts.append(docinfo)
else: else:
pages.append(docinfo) pages.append(docinfo)
except KeyError: 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) # Sort posts by date (newest in top)
posts.sort(key=lambda date: datetime.datetime.strptime(date['date'], 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} return {'posts': posts, 'pages': pages}
def build_site(): def build(config: dict):
"""Build site."""
# Prepare build directory # 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 ...') log.info('Collecting data ...')
rst_files = find_rst_files(settings['build']['content_dir']) rst_files = find_rst_files(config['dirs']['content_dir'])
lists = aggregate_lists(rst_files) lists = aggregate_lists(config, rst_files)
for rst_file in rst_files: for rst_file in rst_files:
with open(rst_file, 'r', encoding = 'utf-8') as rst: with open(rst_file, 'r', encoding = 'utf-8') as rst:
source = rst.read() source = rst.read()
log.info('Parsing docinfo: %s' % rst_file) log.info('Parsing docinfo: %s' % rst_file)
page_meta = parsedocinfo(source) page_docinfo = parsedocinfo(source)
# Render HTML files # 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', 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) log.info('Rendering page: %s' % html_file_path)
# Page template from docinfo # Get page template from docinfo
try: try:
template = page_meta['template'] template = page_docinfo['template']
if not os.path.exists(os.path.join( 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( log.error('{}: Template does not exist: {}'.format(
rst_file, template)) rst_file, template))
sys.exit(1) sys.exit(1)
except KeyError: except KeyError:
template = settings['build']['default_template'] template = config['defaults']['template']
# Render HTML # Render HTML
html_body = render_html_body(source) html_body = render_html_body(config, source)
# Render template # Render template
html_page = render_template( html_page = render_template(
template, template,
templates_dir = settings['build']['templates_dir'], templates_dir = config['dirs']['templates_dir'],
pygments_theme = settings['pygments']['theme'], pygments_theme = config['pygments']['theme'],
site = settings['site'], site = config['site'],
page = page_meta, page = page_docinfo,
aggr = lists, aggr = lists,
html = html_body html = html_body
) )
@ -296,17 +350,75 @@ def build_site():
write_to_file(html_file_path, html_page) write_to_file(html_file_path, html_page)
# Copy additional files to build_dir # Copy additional files to build_dir
log.info('Copy static files from {}, {} to {}'.format( log.info("Copy static files from '{}' and '{}' to '{}'".format(
settings['build']['static_dir'], config['dirs']['static_dir'],
settings['build']['content_dir'], config['dirs']['content_dir'],
settings['build']['build_dir'])) config['dirs']['build_dir']))
copy_files(settings['build']['static_dir'], copy_files(config['dirs']['static_dir'], config['dirs']['build_dir'])
settings['build']['build_dir']) copy_files(config['dirs']['content_dir'], config['dirs']['build_dir'])
copy_files(settings['build']['content_dir'],
settings['build']['build_dir'])
log.info('Success') 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['<name>']:
init(dirname = args['<name>'], 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__': if __name__ == '__main__':
build_site() cli()