########################################################################## # # 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 # ########################################################################## __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 = ''' {% block title %}{% endblock %} {% block head %}{% endblock %} {% if menu %} {% endif %} {% block content %} Nothing here. {% endblock %} ''' index_j2_template = '''{% extends 'base.j2' %} {% block title %}{{ title }} | {{ site_title }}{% endblock %} {% block head %}{{ super() }} {% 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()