Blob Blame Raw
#!/usr/bin/env python3
# coding=utf-8
#
# build.py Create lapwing.org website
# Copyright (C) 2014, 2017 Sam Black <samwwwblack@lapwing.org>
# 
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero 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 Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
import distutils.dir_util
import logging
import os
import shutil
import time

import click
import staticjinja
import webassets

from datetime import datetime
from http.server import HTTPServer
from http.server import SimpleHTTPRequestHandler
from multiprocessing import Lock
from multiprocessing import Process
from webassets.ext.jinja2 import AssetsExtension

logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler())

pwd = os.getcwd()
config = {
    "site": {
        "name": "Lapwing.Org",
        "email": "contact@lapwing.org",
    },
    "output": os.path.join(pwd, "build", "html"),
    "templates": os.path.join(pwd, "templates"),
    "assets": os.path.join(pwd, "assets"),
    "asset_cache": os.path.join(pwd, "build", "webassets-cache")
}


def generate_assets(assets_path, output_path, debug=False):
    """
    Generate a webasset.Environment to pass into the Jinja2 template.

    :param assets_path: directory path to asset items.
    :type assets_path: str
    :param output_path: directory path to output files.
    :type output_path: str
    :param debug: minimize JS and CSS assets, default no
    :type debug: bool
    :return: webassets environment
    :rtype: webassets.Environment
    """
    webasset_env = webassets.Environment(output_path, "/")
    webasset_env.append_path(assets_path)

    webasset_env.debug = debug

    # Javascript
    # jQuery must be first here
    js_lapwing = webassets.Bundle("js/jquery-3.2.1.js", "uikit/uikit.js",
                                  filters="rjsmin",
                                  output="js/lapwing.%(version)s.js")
    webasset_env.register("js_lapwing", js_lapwing)

    # SCSS -> CSS
    css_lapwing = webassets.Bundle("website/scss/lapwing.scss",
                                   "fontawesome/scss/font-awesome.scss",
                                   filters="scss,cssmin",
                                   output="css/lapwing.%(version)s.css")
    webasset_env.register("css_lapwing", css_lapwing)

    return webasset_env


def safe_copy(src, dest):
    """
    Copy files or directories.

    Source and destination must be absolute paths.

    :param src: source file or directory
    :type src: str
    :param dest: destination of the file or directory
    :type dest: str
    """
    src = src.rstrip("/")
    if os.path.isdir(dest) and (os.path.isfile(src) or os.path.isdir(src)):
        dest = os.path.join(dest, os.path.basename(src))

    if os.path.isdir(src):
        if os.path.isdir(dest):
            distutils.dir_util.copy_tree(src, dest, preserve_symlinks=True)
        else:
            shutil.copytree(src, dest, True)
    else:
        shutil.copy2(src, dest)


def build_website(minimize=False, watch=False, lock=None):
    """
    Build website.

    :param minimize: minimize the CSS, JS or other assets.
    :type minimize: bool
    :param watch: re-run renderer if templates change
    :type watch: bool
    :param lock: multiprocessing lock
    :type lock: multiprocessing.Lock or None
    """
    # If the paths aren't absolute, bail.
    for path_name, path in config.items():
        if path_name == "site":
            continue
        if not os.path.isabs(path):
            raise Exception("Path for '{}' is not absolute".format(path_name))

    # We have to remove the output dir first
    if os.path.exists(config["output"]):
        logger.info("Removing output directory")
        if lock:
            with lock:
                shutil.rmtree(config["output"])
        else:
            shutil.rmtree(config["output"])

    webasset_debug = not minimize

    webassets_env = generate_assets(config["assets"], config["output"],
                                    webasset_debug)

    if webasset_debug and os.path.isdir(config["asset_cache"]):
        shutil.rmtree(config["asset_cache"])
    for d in (config["output"], config["asset_cache"]):
        if not os.path.isdir(d):
            os.makedirs(d, 0o755, True)

    webassets_env.cache = config["asset_cache"]

    # Static files need to be relative paths to the template path
    # to be copied into the HTML output
    staticfiles = ["fontawesome/fonts", "website/fonts", "website/img",
                   "website/favicon.ico", "website/robots.txt"]
    for staticpath in staticfiles:
        srcpath = os.path.join(pwd, "assets", staticpath)
        safe_copy(srcpath, config["output"])

    env_globals = {
        "site_name": config["site"]["name"],
        "site_email": config["site"]["email"],
        "current_year": datetime.now().strftime("%Y")
    }

    renderer = staticjinja.make_site(config["templates"], config["output"],
                                     extensions=[AssetsExtension])
    # We need to add webassets and globals to the Jinja Environment
    renderer._env.assets_environment = webassets_env
    renderer._env.globals.update(env_globals)

    renderer.render(watch)


def run_server(lock=None):
    """
    Run a server.

    :param lock: multiprocess data pipe
    :type lock: multiprocess.Lock or None
    """
    if lock:
        logger.info("Awaiting renderer")
        while True:
            time.sleep(2)
            if lock.acquire(False):
                lock.release()
                break
            logger.info("Still waiting for renderer")

    os.chdir("build/html")
    httpd = HTTPServer(("", 8000), SimpleHTTPRequestHandler)
    httpd.serve_forever()
    logger.info("Serving website: http://127.0.0.1:8000")


@click.group()
def cli():
    """
    Build and deploy static websites using
    Jinja2 and SCSS templates.
    """
    pass


@cli.command()
@click.option("--minimize", is_flag=True, default=False,
              help="minimize the CSS, JS and other assets.")
def build(minimize):
    """
    Build website.

    :param minimize: minimize the CSS, JS or other assets.
    :type minimize: bool
    """
    build_website(minimize)


@cli.command()
@click.option("--watch", is_flag=True, default=False,
              help="watch for template changes and re run renderer")
@click.option("--minimize", is_flag=True, default=False,
              help="minimize the CSS, JS and other assets.")
def serve(watch, minimize):
    """
    Build website and run a "http.server" instance in the output directory.

    :param watch: re-render HTML output if templates change
    :type watch: bool
    :param minimize: minimize CSS/JS output
    :type minimize: bool
    """
    if watch:
        lock = Lock()
    else:
        lock = None, None

    build_proc = Process(target=build_website, args=(minimize, watch, lock))
    serve_proc = Process(target=run_server, args=(lock,))
    build_proc.start()
    serve_proc.start()


@cli.command()
@click.option("--rebuild", is_flag=True, default=False,
              help="force (re)building the website before deploying.")
def deploy(rebuild):
    """
    Deploy website to remote host.

    :param rebuild: force (re)building the website before deploying.
    :type rebuild: bool
    """
    if rebuild:
        build_website()


if __name__ == "__main__":
    cli()