commit 183d7b63965ec73474984ff6793dff73ba60f8ca Author: gd Date: Mon Apr 12 01:45:44 2021 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4cf7cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.egg-info/ +build/ +dist/ +*.pyc +*.swp +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..a658fa3 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Pēji + +**Pēji** (Japanese: ページ, "page") is simple way to generate small static sites (one or more pages). + +If you need to collect several pages from the Markdown, then Pēji are great for you. + +**Note**: Pēji is not intended to generate a blog site. Its single pages only. You can link pages with hyper-links, but you have to do it manually. + +Features: + +- [Python-Markdown](https://python-markdown.github.io/) is used. +- Code syntax highlighting via [Pygments](https://pygments.org/). +- [Jinja2](https://jinja.palletsprojects.com/en/2.11.x/) template engine. +- Custom style and layout for specific page. +- Site menu bar can be edited through the config. +- YAML config file. + +## Installation and quickstart + +Install Pēji globally or into virtual environment: + +```bash +pip install Peji +``` + +Create your site: + +```bash +peji create mysite +cd mysite/ +``` + +Create your first page and place it into `mysite/pages/`: + +`index.md`: + +```markdown +--- +title: My first page +--- + +# My heading + +It works! +``` + +Build your site: + +``` +peji build +``` + +Site will be placed in `./mysite/build`. + +Also you can run Python built-in HTTP Server: + +```bash +python3 -m http.server --directory build/ +``` + +## License + +Pēji is released under The Unlicense. See [https://unlicense.org/](https://unlicense.org/) for detais. diff --git a/peji.py b/peji.py new file mode 100644 index 0000000..7ef9418 --- /dev/null +++ b/peji.py @@ -0,0 +1,413 @@ +########################################################################## +# +# 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 + +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_matadata(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_matadata( + 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() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..85186a6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +click>=7.1.2 +markdown>=3.3.4 +Jinja2>=2.11.3 +Pygments>=2.8.1 +PyYAML>=5.4.1 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ec32246 --- /dev/null +++ b/setup.py @@ -0,0 +1,34 @@ +from setuptools import setup + + +with open("README.md", "r") as long_descr: + long_description = long_descr.read() + +setup( + name='Peji', + version='1.0', + author='gd', + description='Static site generator.', + long_description=long_description, + long_description_content_type="text/markdown", + url='https://peji.gch.icu/', + classifiers=[ + "Programming Language :: Python :: 3.9", + "License :: OSI Approved :: The Unlicense (Unlicense)", + "Operating System :: OS Independent", + ], + python_requires='>=3.6', + py_modules=['peji'], + install_requires = [ + 'click>=7.1.2', + 'markdown>=3.3.4', + 'Jinja2>=2.11.3', + 'Pygments>=2.8.1', + 'PyYAML>=5.4.1' + ], + entry_points = { + 'console_scripts': [ + 'peji = peji:cli' + ] + } +)