feat: Add CLI, various improvements, renamed to rsw.py
This commit is contained in:
parent
1fe0b0b7e2
commit
cb51684a6b
270
rstw → rsw.py
Executable file → Normal file
270
rstw → rsw.py
Executable file → Normal 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:
|
||||||
|
"""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:
|
try:
|
||||||
with open('settings.toml', 'r') as file:
|
with open(config_file, 'r') as file:
|
||||||
settings = toml.loads(file.read())
|
config = toml.loads(file.read())
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
log.error('Cannot open \'settings.toml\'. ' +
|
log.error("Cannot load configuration from '{}': {}".format(
|
||||||
'Is the file actually in the current directory?')
|
config_file, err))
|
||||||
|
sys.exit(1)
|
||||||
|
return merge_dicts(DEFAULT_CONFIG, config)
|
||||||
|
|
||||||
# Fallback to default settings
|
def load_config_wrapper(args: dict) -> dict:
|
||||||
|
if args['--config']:
|
||||||
# `build` section
|
config = load_config(config_file = args['--config'])
|
||||||
for dir in list(BUILD_DEFAULTS.keys()):
|
else:
|
||||||
try:
|
config = load_config()
|
||||||
settings['build'][dir]
|
return config
|
||||||
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. #
|
# 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()
|
Loading…
Reference in New Issue
Block a user