#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Manage blogs""" import json import os import random import readline import string import sys from atexit import register as fn_register from base64 import b64decode, b64encode from datetime import datetime from glob import iglob from html import escape as html_escape from shutil import rmtree from threading import Thread from timeit import default_timer as code_timer from typing import Dict, List, Set, Tuple from warnings import filterwarnings as filter_warnings from css_html_js_minify import html_minify # type: ignore from css_html_js_minify import process_single_css_file from markdown import markdown # type: ignore from plumbum.commands.processes import ProcessExecutionError # type: ignore from pyfzf import FzfPrompt # type: ignore EXIT_OK: int = 0 EXIT_ERR: int = 1 DEFAULT_CONFIG: Dict = { "editor-command": f"{os.environ.get('EDITOR', 'vim')} -- %s", "blog-dir": "b", "git-url": "/git", "py-markdown-extensions": [ "markdown.extensions.abbr", "markdown.extensions.def_list", "markdown.extensions.fenced_code", "markdown.extensions.footnotes", "markdown.extensions.md_in_html", "markdown.extensions.tables", "markdown.extensions.admonition", "markdown.extensions.sane_lists", "markdown.extensions.toc", "markdown.extensions.wikilinks", "pymdownx.betterem", "pymdownx.caret", "pymdownx.magiclink", "pymdownx.mark", "pymdownx.tilde", ], "default-keywords": ["website", "blog", "opinion", "article", "ari-web", "ari"], "page-title": "Ari::web -> Blog", "page-description": "My blog page", "colourscheme-type": "dark", "short-name": "Ari's blogs", "home-keywords": ["ari", "ari-web", "blog", "ari-archer", "foss", "free", "linux"], "base-homepage": "https://ari-web.xyz/", "meta-icons": [{"src": "/favicon.ico", "sizes": "128x128", "type": "image/png"}], "theme-colour": "#f9f6e8", "background-colour": "#262220", "blogs": {}, } DEFAULT_CONFIG_FILE: str = "blog.json" HISTORY_FILE: str = ".blog_history" BLOG_VERSION: int = 1 BLOG_MARKDOWN_TEMPLATE: str = """# %s
%s
""" HTML_HEADER: str = """ {title} """ BLOG_HTML_TEMPLATE: str = f""" {HTML_HEADER}
{{blog}}
""" HOME_PAGE_HTML_TEMPLATE: str = f""" {HTML_HEADER}

My blogs

{{content}}
""" def log(message: str, header: str = "ERROR", code: int = EXIT_ERR) -> int: sys.stderr.write(f"{header}: {message}\n") return code def sanitise_title(title: str, titleset: Dict) -> str: _title: str = "" for char in title: _title += char if char not in string.whitespace + string.punctuation else "-" _title = _title.lower() return ( _title if _title not in titleset and _title.strip() else sanitise_title(_title + random.choice(string.digits), titleset) ) def format_time(timestamp: float) -> str: return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") def iinput(prompt: str, default_text: str = "") -> str: default_text = default_text.strip() def hook(): if not default_text: return readline.insert_text(default_text) readline.redisplay() readline.set_pre_input_hook(hook) user_inpt: str = input(f"({prompt}) ").strip() readline.set_pre_input_hook() return user_inpt def new_config() -> None: log("Making new config...", "INFO") with open(DEFAULT_CONFIG_FILE, "w") as cfg: json.dump(DEFAULT_CONFIG, cfg, indent=4) def pick_blog(config: Dict) -> str: try: blog_id: str = ( FzfPrompt() .prompt( map( lambda key: f"{key} | {b64decode(config['blogs'][key]['title']).decode()!r}", config["blogs"].keys(), ), "--prompt='Pick blog: '", )[0] .split()[0] ) except ProcessExecutionError: log("Fzf process exited unexpectedly") return "" if blog_id not in config["blogs"]: log(f"Blog {blog_id!r} does not exist") return "" return blog_id def new_blog(config: Dict) -> Tuple[int, Dict]: """Make a new blog""" if title := iinput("blog title"): readline.add_history(title) us_title: str = title s_title: str = sanitise_title(us_title, config["blogs"]) blog = { "title": b64encode(us_title.encode()).decode(), "content": "", "version": BLOG_VERSION, "time": 0.0, "keywords": "", "minimise": True, } file: str = f"/tmp/{s_title}.md" open(file, "w").close() os.system(config["editor-command"] % file) if not os.path.isfile(file): return log(f"{file!r} does not exist"), config with open(file, "r") as md: blog["content"] = b64encode(md.read().encode()).decode() os.remove(file) if not blog["content"].strip(): # type: ignore return log("Blog cannot be empty"), config user_keywords: str = iinput("keywords (seperated by spaces)") readline.add_history(user_keywords) blog["keywords"] = html_escape( user_keywords + " " + " ".join(config["default-keywords"]) ) blog["minimise"] = (iinput("Minimise blog? (Y/n)") + "y").lower()[0] == "y" blog["time"] = datetime.now().timestamp() config["blogs"][s_title] = blog return EXIT_OK, config def build(config: Dict) -> Tuple[int, Dict]: """Build, minimise and generate site""" if len(config["blogs"]) < 1: return log("Cannot build no blogs"), config if os.path.isdir(config["blog-dir"]): rmtree(config["blog-dir"]) os.makedirs(config["blog-dir"], exist_ok=True) log("Minifying CSS...", "MINIFY") def build_css() -> None: saved_stdout = sys.stdout sys.stdout = open(os.devnull, "w") if os.path.isfile("content/styles.css"): process_single_css_file("content/styles.css") sys.stdout.close() sys.stdout = saved_stdout log("Done minifying CSS", "MINIFY") Thread(target=build_css, daemon=True).start() log("Building blogs...", "INFO") def thread(blog_name: str, blog_meta: Dict): if blog_meta["version"] != BLOG_VERSION: log( f"{blog_name}: unmatching version between {blog_meta['version']} and {BLOG_VERSION}", "WARNING", ) blog_dir: str = os.path.join(config["blog-dir"], blog_name) os.makedirs(blog_dir, exist_ok=True) with open(os.path.join(blog_dir, "index.html"), "w") as blog_html: blog_time: str = format_time(blog_meta["time"]) blog_title: str = html_escape(b64decode(blog_meta["title"]).decode()) blog_base_html: str = markdown( BLOG_MARKDOWN_TEMPLATE % ( blog_title, blog_time, config["base-homepage"], config["git-url"], markdown(b64decode(blog_meta["content"]).decode()) .replace("

", "

") .replace("

", "

"), ), extensions=config["py-markdown-extensions"], ) blog_html_full: str = BLOG_HTML_TEMPLATE.format( title=config["page-title"], theme_type=config["colourscheme-type"], keywords=blog_meta["keywords"], blog_description=f"Blog on {blog_time} -- {blog_title}", blog=blog_base_html, ) if blog_meta["minimise"]: log(f"Minifying {blog_name!r} HTML", "MINIFY") blog_html_full = html_minify(blog_html_full) log(f"Done minifying HTML of {blog_name!r}", "MINIFY") blog_html.write(blog_html_full) log(f"Finished building blog {blog_name!r}", "BUILD") _tmp_threads: List = [] for blog_name, blog_meta in config["blogs"].items(): t: Thread = Thread(target=thread, args=(blog_name, blog_meta), daemon=True) _tmp_threads.append(t) t.start() for awaiting_thread in _tmp_threads: awaiting_thread.join() log("Building blog index...", "INFO") with open("index.html", "w") as index: latest_blog_id: str = tuple(config["blogs"].keys())[-1] lastest_blog: Dict = config["blogs"][latest_blog_id] lastest_blog_time: str = format_time(lastest_blog["time"]) blog_list = "" index.write( html_minify( HOME_PAGE_HTML_TEMPLATE.format( title=config["page-title"], theme_type=config["colourscheme-type"], keywords=config["home-keywords"], home_page_description=config["page-description"], lastest_blog_time=lastest_blog_time, latest_blog_url=os.path.join(config["blog-dir"], latest_blog_id), latest_blog_title=b64decode( html_escape(lastest_blog["title"]) ).decode(), git_url=config["git-url"], content=blog_list, ) ) ) return EXIT_OK, config def list_blogs(config: Dict) -> Tuple[int, Dict]: """List blogs""" if not config["blogs"]: return log("No blogs to list"), config for blog_id, blog_meta in config["blogs"].items(): print( f"""ID: {blog_id} Title: {b64decode(blog_meta['title']).decode()!r} Version: {blog_meta['version']} Time of creation: {format_time(blog_meta['time'])} Keywords: {blog_meta['keywords'].replace(' ', ', ')} Minimise: {'Yes' if blog_meta['minimise'] else 'No'} """ ) return EXIT_OK, config def remove_blog(config: Dict) -> Tuple[int, Dict]: """Remove a blog page""" if not config["blogs"]: return log("No blogs to remove"), config blog_id: str = pick_blog(config) if not blog_id: return EXIT_ERR, config del config["blogs"][blog_id] return EXIT_OK, config def dummy() -> None: """Print help/usage information""" def edit_title(blog: str, config: Dict) -> int: new_title: str = iinput( "edit title", b64decode(config["blogs"][blog]["title"]).decode() ) if not new_title.strip(): return log("New title cannot be empty") old_blog: dict = config["blogs"][blog].copy() old_blog["title"] = b64encode(new_title.encode()).decode() del config["blogs"][blog] config["blogs"][sanitise_title(new_title, config["blogs"])] = old_blog del old_blog return EXIT_OK def edit_keywords(blog: str, config: Dict) -> int: new_keywords: str = iinput("edit keywords", config["blogs"][blog]["keywords"]) if not new_keywords.strip(): return log("Keywords cannot be empty") config["blogs"][blog]["keywords"] = new_keywords return EXIT_OK def edit_content(blog: str, config: Dict) -> int: file: str = f"/tmp/{blog}.md" with open(file, "w") as blog_md: blog_md.write(b64decode(config["blogs"][blog]["content"]).decode()) os.system(config["editor-command"] % (file)) with open(file, "r") as blog_md_new: content: str = blog_md_new.read() if not content.strip(): blog_md_new.close() return log("Content of a blog cannot be empty") config["blogs"][blog]["content"] = b64encode(content.encode()).decode() return EXIT_OK def edit_minimise(blog: str, config: Dict) -> int: minimise: bool = config["blogs"][blog]["minimise"] config["blogs"][blog]["minimise"] = ( iinput( f"Minimise this blog's HTML? [{minimise}] (y/n)", "y" if minimise else "n" ) + "y" ).lower()[0] == "y" return EXIT_OK EDIT_HOOKS: Dict = { "title": edit_title, "keywords": edit_keywords, "content": edit_content, "minimise": edit_minimise, } def edit(config: Dict) -> Tuple[int, Dict]: """Edit a blog""" if not config["blogs"]: return log("No blogs to edit"), config blog_id: str = pick_blog(config) if not blog_id: return EXIT_ERR, config try: hook: str = FzfPrompt().prompt(EDIT_HOOKS.keys(), "--prompt='What to edit: '")[ 0 ] if hook not in EDIT_HOOKS: return log(f"Hook {hook!r} does not exist"), config EDIT_HOOKS[hook](blog_id, config) except ProcessExecutionError: return log("Fzf unexpectedly exited"), config return EXIT_OK, config def gen_new_config(config: Dict) -> Tuple[int, Dict]: """Make default config""" if os.path.exists(DEFAULT_CONFIG_FILE): if iinput("Do you want to overwite config? (y/n)").lower()[0] != "y": return log("Not overwritting config", "INFO", EXIT_OK), config new_config() with open(DEFAULT_CONFIG_FILE, "r") as cfg: config = json.load(cfg) return EXIT_OK, config def clean(config: Dict) -> Tuple[int, Dict]: """Clean up current directory""" TRASH: Set[str] = { HISTORY_FILE, config["blog-dir"], "index.html", "content/*.min.*", "*.hash", "manifest.json", } def remove(file: str) -> None: log(f"Removing {file!r}", "REMOVE") try: os.remove(file) except IsADirectoryError: rmtree(file) for glob_ex in TRASH: for file in iglob(glob_ex, recursive=True): remove(file) open(HISTORY_FILE, "w").close() return EXIT_OK, config def generate_metadata(config: Dict) -> Tuple[int, Dict]: """Generate metadata""" with open("manifest.json", "w") as manifest: log(f"Generating {manifest.name}...", "GENERATE") json.dump( { "$schema": "https://json.schemastore.org/web-manifest-combined.json", "short_name": config["short-name"], "name": config["page-title"], "description": config["page-description"], "icons": config["meta-icons"], "start_url": ".", "display": "standalone", "theme_color": config["theme-colour"], "background_color": config["background-colour"], }, manifest, ) return EXIT_OK, config def generate_static_full(config: Dict) -> Tuple[int, Dict]: """Generate full static site""" BUILD_CFG: Dict = { "Cleaning up": clean, "Building static site": build, "Generating metatata": generate_metadata, } for logger_msg, function in BUILD_CFG.items(): log(f"{logger_msg}...", "STATIC") code, config = function(config) if code != EXIT_OK: log("Failed to generate static site") return EXIT_ERR, config return EXIT_OK, config SUBCOMMANDS: Dict = { "help": dummy, "new": new_blog, "build": build, "list": list_blogs, "rm": remove_blog, "edit": edit, "new-config": gen_new_config, "clean": clean, "metadata": generate_metadata, "static": generate_static_full, } def usage(code: int = EXIT_ERR, config: Dict = None) -> int: sys.stderr.write(f"Usage: {sys.argv[0]} \n") for subcommand, func in SUBCOMMANDS.items(): sys.stderr.write(f" {subcommand:20s}{func.__doc__ or ''}\n") return code def main() -> int: """Entry/main function""" if not os.path.isfile(HISTORY_FILE): open(HISTORY_FILE, "w").close() readline.parse_and_bind("tab: complete") fn_register(readline.write_history_file, HISTORY_FILE) fn_register(readline.read_history_file, HISTORY_FILE) readline.read_history_file(HISTORY_FILE) readline.set_history_length(5000) readline.set_auto_history(False) if not os.path.isfile(DEFAULT_CONFIG_FILE): new_config() return EXIT_ERR if len(sys.argv) != 2: return usage() elif sys.argv[1] not in SUBCOMMANDS: return log(f"{sys.argv[1]!r} is not a subcommand, try `{sys.argv[0]} help`") elif sys.argv[1] == "help": return usage(EXIT_OK) with open(DEFAULT_CONFIG_FILE, "r") as lcfg: cmd_time_init = code_timer() code, config = SUBCOMMANDS[sys.argv[1]](config=json.load(lcfg)) log( f"Finished in {code_timer() - cmd_time_init} seconds with code {code}", "TIME", ) not_ci_build = not os.getenv("CI_BUILD") if config["blogs"] and not_ci_build: log("Sorting blogs by creation time...", "CLEANUP") sort_timer = code_timer() config["blogs"] = dict( map( lambda k: (k, config["blogs"][k]), sorted(config["blogs"], key=lambda k: config["blogs"][k]["time"]), ) ) log(f"Sorted in {code_timer() - sort_timer} seconds", "TIME") if not_ci_build: log("Redumping config", "CONFIG") dump_timer = code_timer() with open(DEFAULT_CONFIG_FILE, "w") as dcfg: json.dump(config, dcfg, indent=4) log(f"Dumped config in {code_timer() - dump_timer} seconds", "TIME") return code return EXIT_OK if __name__ == "__main__": assert main.__annotations__.get("return") is int, "main() should return an integer" filter_warnings("error", category=Warning) sys.exit(main())