From cb51684a6b6ab4ae47af5feb375cdf2f44c2b62f Mon Sep 17 00:00:00 2001 From: ge Date: Thu, 29 Sep 2022 23:23:54 +0300 Subject: [PATCH] feat: Add CLI, various improvements, renamed to rsw.py --- rstw => rsw.py | 276 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 194 insertions(+), 82 deletions(-) rename rstw => rsw.py (51%) mode change 100755 => 100644 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()