website/build.py

306 lines
9.2 KiB
Python
Executable File

#!/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 subprocess
import time
import click
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 staticjinja import Site
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",
},
"menu": (
("Blog", "https://samwwwblack.lapwing.org"),
("Code", "https://code.lapwing.org")
),
"projects": (
("Vowel", "Virtual Online Workspace for Education and Learning.",
"https://code.lapwing.org/vowel/vowel"),
("Sponson",
"Sponson is a tool to create and setup systemd-nspawn containers in "
"a Docker-like way, without using Docker.",
"https://code.lapwing.org/devops/sponson"),
),
"datestamps": (
("privacy", "templates/privacy.html"),
("termsconditions", "templates/termsconditions.html"),
),
"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")
}
paths = ["output", "templates", "assets", "asset_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
js_lapwing = webassets.Bundle("uikit/uikit.js",
"website/js/faconfig.js", # This must be before fontawesome.js
"fontawesome/js/all.js",
filters="uglifyjs",
output="js/lapwing.%(version)s.js")
webasset_env.register("js_lapwing", js_lapwing)
# SCSS -> CSS
css_lapwing = webassets.Bundle("website/scss/lapwing.scss",
"fontawesome/css/svg-with-js.css",
filters="libsass,cssutils",
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 git_date_stamp(file_path):
"""
Get the git recorded date stamp for the given file.
:param file_path: path to the file to get the date stamp
:type file_path: str
:return: date stamp of the file
:rtype: str
"""
cmd = ["git", "log", "-1", "--format=%cd", "--date=format:%Y-%m-%d",
file_path]
today = datetime.now().strftime("%Y-%m-%d")
try:
file_date = subprocess.run(cmd, stdout=subprocess.PIPE, check=True,
universal_newlines=True).stdout.strip()
except subprocess.CalledProcessError:
return today
return file_date if file_date else today
def build_website(minimize=False, lock=None):
"""
Build website.
:param minimize: minimize the CSS, JS or other assets.
:type minimize: 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 not in paths:
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 = ["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"])
now = datetime.now()
current_date = now.strftime("%Y-%m-%d")
current_year = now.year
if now > datetime(current_year, 12, 25):
current_year += 1
env_globals = {
"site_name": config["site"]["name"],
"site_email": config["site"]["email"],
"current_year": current_year,
"current_date": current_date,
"menu": config["menu"],
"projects": config["projects"]
}
for datestamp, datestamp_file in config["datestamps"]:
env_globals["datestamp_{}".format(datestamp)] = git_date_stamp(
datestamp_file)
renderer = Site.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()
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)
logger.info("Serving website: http://127.0.0.1:8000")
httpd.serve_forever()
@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("--minimize", is_flag=True, default=False,
help="minimize the CSS, JS and other assets.")
def serve(minimize):
"""
Build website and run a "http.server" instance in the output directory.
:param minimize: minimize CSS/JS output
:type minimize: bool
"""
lock = Lock()
build_proc = Process(target=build_website, args=(minimize, 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()