| 1 | #!/usr/bin/env python |
| 2 | import argparse |
| 3 | import os |
| 4 | import io |
| 5 | import sys |
| 6 | import xml.etree.ElementTree as ET |
| 7 | from io import StringIO |
| 8 | from datetime import datetime |
| 9 | |
| 10 | import markdown |
| 11 | import yaml |
| 12 | |
| 13 | from bs4 import BeautifulSoup |
| 14 | from jinja2 import Environment, FunctionLoader |
| 15 | |
| 16 | DEVMODE_WEBSOCKET_ADDR = "ws://localhost:8888/ws" |
| 17 | |
| 18 | |
| 19 | def _set_dates(entries): |
| 20 | for entry in entries: |
| 21 | if "date" in entry: |
| 22 | entry["date"] = datetime.fromisoformat(entry["date"]) |
| 23 | if "others" in entry: |
| 24 | return _set_dates(entry["others"]) |
| 25 | |
| 26 | |
| 27 | def _get_dates(entries): |
| 28 | dates = [] |
| 29 | for entry in entries: |
| 30 | if "date" in entry: |
| 31 | dates.append(entry["date"]) |
| 32 | if "others" in entry: |
| 33 | for other in _get_dates(entry["others"]): |
| 34 | dates.append(other) |
| 35 | return dates |
| 36 | |
| 37 | |
| 38 | def _get_latest(entries): |
| 39 | latest = None |
| 40 | for other in _get_dates(entries): |
| 41 | if latest is None: |
| 42 | latest = other |
| 43 | else: |
| 44 | if other > latest: |
| 45 | latest = other |
| 46 | return latest |
| 47 | |
| 48 | |
| 49 | def _load_sitemap(path, current_path): |
| 50 | by_name = dict() |
| 51 | flattened = [] |
| 52 | with open(path, "r") as fp: |
| 53 | _sitemap = yaml.safe_load(fp.read()) |
| 54 | |
| 55 | if not _sitemap["enabled"]: |
| 56 | return None |
| 57 | |
| 58 | variables = dict() |
| 59 | if "variables" in _sitemap: |
| 60 | variables = _sitemap["variables"] |
| 61 | |
| 62 | def _filter_entries(entries): |
| 63 | entries = list( |
| 64 | filter( |
| 65 | lambda entry: "enabled" not in entry or entry["enabled"] is True, |
| 66 | entries, |
| 67 | ) |
| 68 | ) |
| 69 | for entry in entries: |
| 70 | if "others" in entry: |
| 71 | entry["others"] = _filter_entries(entry["others"]) |
| 72 | return entries |
| 73 | |
| 74 | entries = _filter_entries(_sitemap["entries"]) |
| 75 | |
| 76 | def _link(entries, parent=None): |
| 77 | for entry in entries: |
| 78 | flattened.append(entry) |
| 79 | entry["parent"] = parent |
| 80 | if "others" in entry: |
| 81 | _link(entry["others"], parent=entry) |
| 82 | |
| 83 | def _make_url(link): |
| 84 | path = [link["name"]] |
| 85 | parent = link["parent"] |
| 86 | while parent is not None: |
| 87 | path.append(parent["name"]) |
| 88 | parent = parent["parent"] |
| 89 | path.reverse() |
| 90 | return "/".join(path) |
| 91 | |
| 92 | _link(entries, parent=None) |
| 93 | |
| 94 | for entry in flattened: |
| 95 | target = "/" + _make_url(entry) |
| 96 | if target == current_path: |
| 97 | entry["active"] = True |
| 98 | else: |
| 99 | entry["active"] = False |
| 100 | entry["url"] = target |
| 101 | by_name[target] = entry |
| 102 | |
| 103 | _set_dates(entries) |
| 104 | latest = _get_latest(entries) |
| 105 | |
| 106 | return dict(by_name=by_name, variables=variables, links=entries, latest=_get_latest(entries)) |
| 107 | |
| 108 | |
| 109 | def _make_tree(links): |
| 110 | def _populate(root, links): |
| 111 | for link in links: |
| 112 | elm = ET.Element("li") |
| 113 | if "directory" in link and link["directory"]: |
| 114 | elm.text = link["name"] |
| 115 | else: |
| 116 | lref = ET.Element("a", href=link["url"]) |
| 117 | link_text = link["name"] |
| 118 | if "date" in link: |
| 119 | link_text = link_text + " [" + str(link["date"].strftime("%Y-%m-%d")) + "]" |
| 120 | lref.text = link_text |
| 121 | if link["active"]: |
| 122 | lref.text = lref.text + " <" |
| 123 | elm.append(lref) |
| 124 | if "others" in link: |
| 125 | others = link["others"] |
| 126 | ul = ET.Element("ul") |
| 127 | elm.append(ul) |
| 128 | _populate(root=ul, links=others) |
| 129 | root.append(elm) |
| 130 | |
| 131 | params = {"class": "tree"} |
| 132 | |
| 133 | root = ET.Element("ul", **params) |
| 134 | _populate(root=root, links=links) |
| 135 | return ET.tostring(root).decode() |
| 136 | |
| 137 | |
| 138 | def load_content(path): |
| 139 | with open(path) as fp: |
| 140 | text = fp.read() |
| 141 | content = markdown.markdown( |
| 142 | text, |
| 143 | extensions=["fenced_code", "tables"], |
| 144 | ) |
| 145 | return content |
| 146 | |
| 147 | |
| 148 | def generate(template, content_path, stylesheet, link, sitemap, devmode=False): |
| 149 | tree = _make_tree(sitemap["links"]) |
| 150 | with open(template, "r") as fp: |
| 151 | template = fp.read() |
| 152 | env = Environment(loader=FunctionLoader(lambda name: template)) |
| 153 | base = env.get_template(template) |
| 154 | with open(stylesheet) as fp: |
| 155 | style = fp.read() |
| 156 | content = load_content(content_path) |
| 157 | env_content = Environment(loader=FunctionLoader(lambda name: content)) |
| 158 | out = base.render( |
| 159 | { |
| 160 | "content": env_content.get_template("").render( |
| 161 | {"sitemap": sitemap, "tree": tree, "variables": sitemap["variables"]} |
| 162 | ), |
| 163 | "variables": sitemap["variables"], |
| 164 | "is_index": False, |
| 165 | "link": link, |
| 166 | "style": style, |
| 167 | "sitemap": sitemap, |
| 168 | "tree": tree, |
| 169 | "devmode": devmode, |
| 170 | "devmode_websocket_addr": DEVMODE_WEBSOCKET_ADDR, |
| 171 | "current_time": datetime.now(), |
| 172 | } |
| 173 | ) |
| 174 | sys.stdout.write(out) |
| 175 | |
| 176 | |
| 177 | def generate_rss(sitemap): |
| 178 | sitemap = _load_sitemap(sitemap, "/") |
| 179 | |
| 180 | feed = ET.Element("feed", attrib={"xmlns": "http://www.w3.org/2005/Atom"}) |
| 181 | |
| 182 | feed_id = ET.SubElement(feed, "id") |
| 183 | feed_id.text = "https://kevinschoon.com/" |
| 184 | title = ET.SubElement(feed, "title") |
| 185 | title.text = "Blog of Kevin Schoon" |
| 186 | link = ET.SubElement(feed, "link") |
| 187 | link.attrib["rel"] = "self" |
| 188 | link.attrib["href"] = "https://kevinschoon.com" |
| 189 | updated = ET.SubElement(feed, "updated") |
| 190 | updated.text = sitemap["latest"].isoformat() |
| 191 | author = ET.SubElement(feed, "author") |
| 192 | name = ET.SubElement(author, "name") |
| 193 | name.text = "Kevin Schoon" |
| 194 | |
| 195 | for entry in sitemap["by_name"]["/blog"]["others"]: |
| 196 | absolute_url = "https://kevinschoon.com/blog/" + entry["name"] |
| 197 | atom_entry = ET.SubElement(feed, "entry") |
| 198 | link = ET.SubElement(atom_entry, "link") |
| 199 | link.attrib["href"] = absolute_url |
| 200 | link.attrib["rel"] = "self" |
| 201 | _id = ET.SubElement(atom_entry, "id") |
| 202 | _id.text = absolute_url |
| 203 | title = ET.SubElement(atom_entry, "title") |
| 204 | title.text = entry["tagline"] |
| 205 | updated = ET.SubElement(atom_entry, "updated") |
| 206 | updated.text = entry["date"].isoformat() |
| 207 | content_path = "content/blog/" + entry["name"] + "/README.md" |
| 208 | content = load_content(content_path) |
| 209 | content_element = ET.SubElement(atom_entry, "content") |
| 210 | content_element.attrib["type"] = "xhtml" |
| 211 | content_element.attrib["xml:lang"] = "en" |
| 212 | content_tree = ET.parse(io.StringIO(f"<div>{content}</div>")).getroot() |
| 213 | content_tree.attrib["xmlns"] = "http://www.w3.org/1999/xhtml" |
| 214 | content_element.append(content_tree) |
| 215 | |
| 216 | |
| 217 | xml_str = ET.tostring(feed, xml_declaration=True, encoding="unicode", method="xml") |
| 218 | xml_str = xml_str.replace( |
| 219 | "<?xml version='1.0' encoding='utf-8'?>", |
| 220 | "<?xml version='1.0' encoding='utf-8'?><?xml-stylesheet href='/style.xsl' type='text/xsl'?>", |
| 221 | ) |
| 222 | sys.stdout.buffer.write(bytes(xml_str, encoding="utf-8")) |
| 223 | |
| 224 | |
| 225 | if __name__ == "__main__": |
| 226 | parser = argparse.ArgumentParser(description="static renderer") |
| 227 | parser.add_argument("-rss", help="if rendering the rss feed", action="store_true") |
| 228 | parser.add_argument("-content", help="markdown file with content") |
| 229 | parser.add_argument( |
| 230 | "-template", default="index.html.jinja", help="jinja template file" |
| 231 | ) |
| 232 | parser.add_argument("-stylesheet", default="./assets/main.css", help="style sheet") |
| 233 | parser.add_argument("-sitemap", default="sitemap.yaml", help="sitemap") |
| 234 | parser.add_argument("-link", default="/", help="current url") |
| 235 | parser.add_argument( |
| 236 | "-devmode", action="store_true", help="developer mode (websocket page refresh)" |
| 237 | ) |
| 238 | args = parser.parse_args() |
| 239 | sitemap = _load_sitemap(args.sitemap, args.link) |
| 240 | if args.rss: |
| 241 | generate_rss(args.sitemap) |
| 242 | else: |
| 243 | generate( |
| 244 | template=args.template, |
| 245 | content_path=args.content, |
| 246 | stylesheet=args.stylesheet, |
| 247 | link=args.link, |
| 248 | sitemap=sitemap, |
| 249 | devmode=args.devmode, |
| 250 | ) |