peji/peji.py

414 lines
11 KiB
Python

##########################################################################
#
# Pēji — static site generator.
# Homepage: https://peji.gch.icu
#
# LICENSE: The Unlicense
#
# This is free and unencumbered software released into the public domain.
#
# Anyone is free to copy, modify, publish, use, compile, sell, or
# distribute this software, either in source code form or as a compiled
# binary, for any purpose, commercial or non-commercial, and by any
# means.
#
# In jurisdictions that recognize copyright laws, the author or authors
# of this software dedicate any and all copyright interest in the
# software to the public domain. We make this dedication for the benefit
# of the public at large and to the detriment of our heirs and
# successors. We intend this dedication to be an overt act of
# relinquishment in perpetuity of all present and future rights to this
# software under copyright law.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
# OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
#
# For more information, please refer to <http://unlicense.org/>
#
##########################################################################
__version__ = '1.0.2'
import os
import re
import sys
import shutil
import yaml
import click
import jinja2
import pygments
import markdown
config_yaml_template = '''# Site title.
title: My site
# Site meta description.
description: My cool website.
# Site default theme.
theme: default
# Site menu.
menu:
Home: /
# Markdown extensions.
# See https://python-markdown.github.io/extensions/
markdown_extensions:
- admonition
- codehilite
- extra
- meta
- toc
markdown_extension_configs:
codehilite:
noclasses: true
use_pygments: true
pygments_style: default
'''
base_j2_template = '''<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>
{% block title %}{% endblock %}
</title>
{% block head %}{% endblock %}
</head>
<body>
{% if menu %}
<div class="menu">
<ul>
{% for link in menu.keys() %}
<li><a href="{{ menu.get(link) }}">{{ link }}</a></li>
{% endfor %}
</ul>
</div>
{% endif %}
{% block content %}
Nothing here.
{% endblock %}
</body>
</html>
'''
index_j2_template = '''{% extends 'base.j2' %}
{% block title %}{{ title }} | {{ site_title }}{% endblock %}
{% block head %}{{ super() }}
<link rel="stylesheet" href="/themes/{{ theme }}/css/main.css">
<link rel="shortcut icon" href="/themes/{{ theme }}/images/favicon.ico"
type="image/x-icon">
{% endblock %}
{% block content %}
{{ content | safe }}
{% endblock %}
'''
default_main_css = '''body {
margin: 32px;
font-size: 16px;
color: #000000;
background-color: #ffffff;
}
a,
a:active,
a:visited {
color: #3f37c9;
text-decoration: none;
}
a:hover {
border-bottom: 1px dashed;
}
.menu {
margin: 18px 0 28px 0;
}
.menu ul {
padding: 0;
}
.menu ul li {
margin: 0;
padding: 0;
padding-right: 8px;
display: inline-block;
list-style: none;
}
.menu ul li::before {
content: '|';
position: relative;
left: -6px;
}
.menu ul li:first-child::before {
content: none;
}
pre {
padding: 12px;
overflow: auto;
}
dt {
font-weight: bold;
font-style: italic;
}
dd {
margin-top: 8px;
margin-bottom: 12px;
}
details summary {
cursor: pointer;
margin-bottom: 16px;
}
.admonition {
padding: 0 16px;
}
.info {
color: #055160;
background-color: #cff4fc;
border: 1px solid #055160;
}
.note {
color: #084298;
background-color: #cfe2ff;
border: 1px solid #084298;
}
.tip {
color: #0f5132;
background-color: #d1e7dd;
border: 1px solid #0f5132;
}
.warning {
color: #664d03;
background-color: #fff3cd;
border: 1px solid #664d03;
}
.danger {
color: #842029;
background-color: #f8d7da;
border: 1px solid #842029;
}
'''
pages = 'pages'
layouts = 'layouts'
themes = 'themes'
cwd = os.getcwd()
def read_file(filepath: str) -> str:
try:
with open(filepath, 'r') as file:
return file.read()
except IOError:
print('Error: cannot read file: {}'.format(filepath))
def write_to_file(filepath: str, data: str):
try:
with open(filepath, 'w') as file:
return file.write(data)
except IOError:
print('Error: cannot write to file: {}'.format(filepath))
def get_config() -> dict:
config = read_file(os.path.join(cwd, 'config.yaml'))
return yaml.safe_load(config)
def get_markdown_file_list(directory: str) -> list:
"""Return the list of Markdown 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] == '.md':
file_list.append(os.path.join(root, name))
return file_list
def get_new_path(build_dir: str, filename: str) -> str:
"""Make dir and return file path. Example:
Before:
./mysite/pages/index.md
./mysite/pages/subfilder/index.md
After:
./mysite/build/index.html
./mysite/pages/subfilder/index.html
"""
relpath = os.path.relpath(filename, start = pages)
if os.path.dirname(relpath):
os.makedirs(
os.path.join(build_dir, os.path.dirname(relpath)),
exist_ok = True
)
return os.path.join(
build_dir,
os.path.splitext(relpath)[0] + '.html'
)
def copy_files(source_dir: str, destination_dir: str):
shutil.copytree(
source_dir,
destination_dir,
ignore = shutil.ignore_patterns('*.md'),
dirs_exist_ok = True
)
def parse_page_metadata(text: str) -> dict:
"""Parse metadata from Markdown as YAML or return None.
"""
# Find first paragraph that starts and ends with '---'.
pattern = r'\A\s*(^-{3}$)\n((.+\n)+\n*)(^-{3}$)'
metadata = re.search(pattern, text, re.MULTILINE)
if metadata:
metadata = metadata.group().replace('---', '')
return yaml.safe_load(metadata)
else:
return None
def validate_metadata(config: dict, metadata: dict) -> dict:
meta = {}
if metadata:
try:
meta['title'] = metadata['title']
except KeyError:
meta['title'] = ''
try:
meta['theme'] = metadata['theme']
except KeyError:
meta['theme'] = config['theme']
try:
meta['layout'] = metadata['layout']
except KeyError:
meta['layout'] = 'index.j2'
# Validate config meta
try:
meta['site_title'] = config['title']
except KeyError:
meta['site_title'] = ''
try:
meta['description'] = config['description']
except KeyError:
meta['description'] = ''
try:
meta['menu'] = config['menu']
except KeyError:
meta['menu'] = False
return meta
else:
return None
def render_html_from_markdown(config: dict, text: str) -> str:
html = markdown.Markdown(
extensions = config['markdown_extensions'],
extension_configs = config['markdown_extension_configs']
)
return html.convert(text)
def render_template(template: str, **kwargs) -> str:
"""Render template from file.
Usage:
render_template('index.j2', title = 'My title')
"""
env = jinja2.Environment(loader = jinja2.FileSystemLoader(layouts))
return env.get_template(template).render(**kwargs)
def check_site(cwd: str):
"""Check site config existence and exit if not exist."""
if not os.path.exists(os.path.join(cwd, 'config.yaml')):
click.echo(
'Error: No such file: config.yaml\nAre you in site directory?',
err = True)
sys.exit(1)
else:
return True
def make_site_dirs(site: str):
default_dirs = [
'layouts',
'pages',
'themes',
'themes/default/css',
'themes/default/images',
'themes/default/js'
]
dirs = [os.path.join(site, dir) for dir in default_dirs]
for dir in dirs:
os.makedirs(os.path.join(cwd, dir), exist_ok = True)
def create_site(site: str):
make_site_dirs(site)
write_to_file(
os.path.join(site, 'config.yaml'),
config_yaml_template
)
write_to_file(
os.path.join(site, 'layouts/base.j2'),
base_j2_template
)
write_to_file(
os.path.join(site, 'layouts/index.j2'),
index_j2_template
)
write_to_file(
os.path.join(site, 'themes/default/css/main.css'),
default_main_css
)
def build_site(build_dir: str):
"""Render HTML files and place site data to `build_dir`."""
config = get_config()
markdown_files = get_markdown_file_list(pages)
for file in markdown_files:
text = read_file(file)
meta = validate_metadata(
config,
parse_page_metadata(text)
)
if meta:
md = render_html_from_markdown(config, text)
html = render_template(
meta['layout'],
theme = meta['theme'],
title = meta['title'],
site_title = meta['site_title'],
description = meta['description'],
menu = meta['menu'],
content = md
)
# Copy (or replace if exists) files to 'build/' dir.
copy_files(themes, os.path.join(build_dir, themes))
copy_files(pages, build_dir)
# Write rendered HTML into file.
write_to_file(get_new_path(build_dir, file), html)
else:
print('Error: Not rendered: No metadata found in: {}'.format(file))
@click.version_option(
version = __version__,
prog_name = 'Pēji')
@click.group()
def cli():
"""Static site generator."""
pass
@cli.command()
@click.argument('site')
def create(site):
"""Create new site."""
site = os.path.join(cwd, site)
create_site(site)
click.echo('Site created: %s' % site)
@cli.command()
@click.option('-d', '--dir', default = './build/', metavar = 'DIR',
help = 'Set destination directory. Default: ./build')
def build(dir):
"""Render HTML files."""
check_site(cwd)
build_site(dir)
click.echo('Built in: %s' % dir)
if __name__ == '__main__':
cli()