#!/usr/bin/env python3 import html import os import sys import shutil html_self_closing_tags = {"!DOCTYPE", "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr", "command", "keygen", "menuitem", "frame"} html_xml_content_tags = {"svg"} text_url_match = {"/", "http://", "https://"} traversed_links = set() config_values = { "logo": "", "logo_url": "/", "blank": False, "icon": "/favicon.ico", "stylesheet": "/stylesheet.css", "title_format": "%t", "logo_width": "192", "logo_height": "32" } default_theme = { "background-color": "#f0f0f0", "text-color": "#7f687f", "link-color": "#af8faf", "header-color": "#e0e0e0", "footer-color": "#e0e0e0", "top-line-color": "#c0c0c0", "bottom-line-color": "#c0c0c0", "container-color": "#f0f0f0", "content-box-color": "#e8e8e8", "content-border-tl-color": "#f8f8f8", "content-border-br-color": "#c8c8c8", "container-padding": 80, "content-min-height": 36, "content-margin": 5, "content-line-pad": 5, "content-side-pad": 10, "header-height": 40, "header-padding-left": 10, "header-padding-right": 10, "header-gap": 5, "header-elem-size": 32, "header-line-pad": 4, "header-side-pad": 10, "footer-height": 32, "footer-padding-left": 15, "footer-padding-right": 15, "footer-gap": 8, "footer-elem-size": 20, "footer-line-pad": 2, "footer-side-pad": 7, "line-height": 20, "text-size": 14, "header-text-size": 14, "footer-text-size": 14 } base_stylesheet = [ ("a", { "color": "[#link-color]", "text-decoration": "none" }), ("body", { "box-sizing": "border-box", "line-height": "[#line-height]px", "display": "flex", "flex-direction": "column", "background-color": "[#background-color]", "margin": "0", "color": "[#text-color]", "font-size": "[#text-size]px" }), (".flex-container", { "box-sizing": "border-box", "display": "flex", "justify-content": "space-between", "margin": "0" }), ("nav.flex-container", { "background-color": "[#header-color]", "border-style": "solid", "border-width": "0 0 1px 0", "border-color": "[#top-line-color]" }), ("footer.flex-container", { "background-color": "[#footer-color]", "border-style": "solid", "border-width": "1px 0 0 0", "border-color": "[#bottom-line-color]" }), (".full-container", { "box-sizing": "border-box", "padding": "0 0 [#container-padding]px 0", "flex-grow": "1", "background-color": "[#container-color]", "margin": "0" }), (".base-container", { "min-height": "[#content-min-height]px", "align-items": "center", "box-sizing": "border-box", "padding": "[#content-line-pad]px [#content-side-pad]px", "background-color": "[#content-box-color]", "margin": "[#content-margin]px", "border-style": "solid", "border-width": "1px", "border-color": "[#content-border-tl-color] [#content-border-br-color] [#content-border-br-color] [#content-border-tl-color]" }), (".list-container", { "column-gap": "[#header-gap]px", "display": "flex", "min-height": "[#header-height]px", "row-gap": "[#header-gap]px", "align-items": "center", "box-sizing": "border-box", "flex-wrap": "wrap", "padding": "0 [#header-padding-right]px 0 [#header-padding-left]px", "margin": "0" }), (".compact-container", { "column-gap": "[#footer-gap]px", "display": "flex", "min-height": "[#footer-height]px", "row-gap": "[#footer-gap]px", "align-items": "center", "box-sizing": "border-box", "flex-wrap": "wrap", "padding": "0 [#footer-padding-right]px 0 [#footer-padding-left]px", "margin": "0" }), (".list-item", { "align-items": "center", "align-self": "center", "box-sizing": "border-box", "display": "flex", "flex-basis": "auto", "flex-grow": "0", "flex-shrink": "0", "min-height": "[#header-elem-size]px", "min-width": "[#header-elem-size]px", "padding": "[#header-line-pad]px [#header-side-pad]px", "margin": "0", "flex-wrap": "wrap", "font-size": "[#header-text-size]px" }), (".compact-item", { "align-items": "center", "align-self": "center", "box-sizing": "border-box", "display": "flex", "flex-basis": "auto", "flex-grow": "0", "flex-shrink": "0", "min-height": "[#footer-elem-size]px", "min-width": "[#footer-elem-size]px", "padding": "[#footer-line-pad]px [#footer-side-pad]px", "margin": "0", "flex-wrap": "wrap", "font-size": "[#footer-text-size]px" }), ("a.nocolor", { "color": "inherit" }) ] def tree2html(tree, indent=0): lines = [] for node in tree: if type(node) is not tuple or len(node) < 1 or len(node) > 3 or type(node[0]) is not str or (len(node) == 2 and type(node[1]) not in {dict, list}) or (len(node) == 3 and (type(node[1]) is not dict or type(node[2]) is not list)): raise Exception("element ist not a valid html tuple: (tag), (tag, properties), (tag, children) or (tag, properties, children), got " + str(type(node)) + " (" + str(node) + ")") props = "" inner = "" if len(node) > 1 and type(node[1]) is dict: for prop, value in node[1].items(): props += ' ' + html.escape(prop) + (('="' + html.escape(value) + '"') if value is not None else "") if len(node) == 3 or (len(node) == 2 and not props): if node[0] in html_self_closing_tags: raise Exception("self closing tag <" + node[0] + "> cannot have children") lines.append((indent * "\t") + "<" + node[0] + props + ">") for child in node[2 if len(node) == 3 else 1]: if type(child) is list: lines.extend(tree2html(child, indent + 1)) elif type(child) is tuple: lines.extend(tree2html([child], indent + 1)) elif type(child) is str: lines.append(((indent + 1) * "\t") + (child if node[0] in html_xml_content_tags else html.escape(child))) else: raise Exception("element must be tuple, list or str, got " + str(type(child)) + " (" + str(child) + ")") lines.append((indent * "\t") + "") else: lines.append((indent * "\t") + "<" + node[0] + props + ">" + (("") if node[0] not in html_self_closing_tags else "")) return lines def get_key_value(text, separator=" "): space = text.find(separator) if space < 0: return (text, None) else: return (text[:space], text[space + 1:]) def get_html_tags(line, variables): if not line: return None elif line.startswith("###"): return [("h1", text2tree(line[3:]))] elif line.startswith("##"): return [("h2", text2tree(line[2:]))] elif line.startswith("#"): return [("h3", text2tree(line[1:]))] elif line.startswith("[#") and line.endswith("]"): key, value = get_key_value(line[2:-1]) match key: case "svg": svg = value.split(" ", 6) return [("svg", {"width": svg[0], "height": svg[1], "viewBox": svg[2] + " " + svg[3] + " " + svg[4] + " " + svg[5]}, [svg[6]])] case "img": img = value.split(" ", 2) src, alt = get_key_value(img[0], ":") props = {"src": src} if src.startswith("/"): global traversed_links traversed_links.add(src) if alt is not None: props["alt"] = alt if len(img) >= 2 and (img[1].startswith("w:") or img[1].startswith("h:")): props["width" if img[1].startswith("w:") else "height"] = img[1][2:] elif len(img) >= 3: props["width"] = img[1] props["height"] = img[2] return [("a", {"href": src}, [("img", props)])] if key in variables.keys(): return variables[key] return None def text2tree(line, nocolor=False): blank = config_values["blank"] tree = [] tpos = pos = 0 while True: lpos = line.find("[", pos) if lpos < 0: break epos = line.find("]", lpos + 1) if epos < 0: break sub = line[lpos + 1:epos] for prefix in text_url_match: if sub.startswith(prefix): url, alt = get_key_value(sub) props = {"href": url} if nocolor: props["class"] = "nocolor" if prefix == "/": global traversed_links traversed_links.add(url) if blank: props["target"] = "_blank" if tpos < lpos: tree.append(line[tpos:lpos]) tree.append(("a", props, [alt if alt else url])) tpos = epos + 1; break pos = epos + 1; if tpos < len(line): tree.append(line[tpos:]) return tree def lines2tree(text, variables): tree = [] paragraph = [] for line in text: tags = get_html_tags(line, variables) if tags: if paragraph: tree.append(("p", paragraph)) paragraph = [] tree.extend(tags) continue if paragraph: paragraph.append(("br",)) paragraph.extend(text2tree(line)) if paragraph: tree.append(("p", paragraph)) return tree def line2tree(line, variables, nocolor): tags = get_html_tags(line, variables) if tags: return tags return text2tree(line, nocolor) def filter_text(data): for elem in data: if type(elem) is tuple and elem[0] in {"svg", "img"}: return [("span", [elem]) if type(elem) is str else elem for elem in data] return [("span", data)] def filter_entry(entry, clazz): elem = entry if type(entry) is not list or len(entry) == 0 else entry[0] if type(elem) == tuple and len(elem) >= 2 and type(elem[1]) == dict and "class" in elem[1].keys() and clazz + "-item" in elem[1]["class"].split(" "): return entry if type(entry) == list else [entry] else: return [("p", {"class": clazz + "-item"}, filter_text(entry))] def make_element(elems, variables, clazz): tree = [] arr = None for elem in elems: if type(elem) is list: data = elem elif type(elem) is tuple: data = [elem] elif type(elem) is str: if elem == "[": if arr is not None: data = arr arr = [] else: arr = [] continue elif elem == "]": if arr is None: continue data = arr arr = None else: data = line2tree(elem, variables, clazz == "list") if arr is not None: arr.extend(data) continue else: data = str(elem) tree.extend(filter_entry(data, clazz)) if arr is not None: tree.extend(filter_entry(arr, clazz)) return ("div", {"class": clazz + "-container"}, tree) def make_head(title): return [ ("meta", {"http-equiv": "content-type", "content": "text/html; charset=UTF-8"}), ("title", [config_values["title_format"].replace("%t", title)]), ("link", {"rel": "icon", "type": "image/x-icon", "href": config_values["icon"]}), ("link", {"rel": "stylesheet", "href": config_values["stylesheet"]}) ] def make_links(pages, clazz): logo = config_values["logo"] logo_url = config_values["logo_url"] logo_width = config_values["logo_width"] logo_height = config_values["logo_height"] nav = [] if logo: alt = "Logo" for page in pages: if page["url"] is logo_url: if page["name"]: alt = page["name"] break nav.append(("a", {"href": logo_url, "class": clazz + "-item nocolor"}, [("img", {"width": logo_width, "height": logo_height, "src": logo, "alt": "Logo"})])) nav.extend([("a", {"href": page["url"], "class": clazz + "-item nocolor"}, [page["name"]]) for page in pages if page["name"] and (not logo or page["url"] is not logo_url)]) return nav def make_bar(left, center, right, pages, variables, clazz, tag): variables = variables.copy() variables["nav"] = make_links(pages, clazz) bar = [make_element(left, variables, clazz), make_element(right, variables, clazz)] if center: bar = [bar[0], make_element(center, variables, clazz), bar[1]] return (tag, {"class": "flex-container"}, bar) def make_content(sections, variables): tree = [] for section in sections: tree.append(("div", {"class": "base-container"}, lines2tree(section, variables))) return ("div", {"class": "full-container"}, tree) def compile_page(page, header, footer, variables): return [("!DOCTYPE", {"html": None}), ("html", [("head", make_head(page["title"])), ("body", [header, make_content(page["sections"], variables), footer])])] def sort_pages(page): return page["sort"] def compile_pages(pages): pages.sort(key=sort_pages) variables = { } header = make_bar(read_list("header/left", ["[#nav]"]), read_list("header/center"), read_list("header/right"), pages, variables, "list", "nav") footer = make_bar(read_list("footer/left"), read_list("footer/center"), read_list("footer/right"), pages, variables, "compact", "footer") return [(page["file"], compile_page(page, header, footer, variables)) for page in pages] def read_page(filename): path = os.path.join(config_values["indir"], filename + ".sml") print("Reading " + path) with open(path, "r") as fd: data = fd.read().splitlines() page = { "name": None, "title": filename, "sort": 0 } sections = [] subpage = [] for line in data: if line.startswith("[#") and line.endswith("]"): key, value = get_key_value(line[2:-1]) match key: case "name": page["name"] = value case "title": page["title"] = value case "sort": page["sort"] = int(value) case "index": if os.path.basename(filename) != "index": filename = os.path.join(filename, "index") case "": sections.append(subpage) subpage = [] case _: subpage.append(line) else: subpage.append(line) if subpage: sections.append(subpage) page["sections"] = sections page["url"] = get_file_url(filename) page["file"] = filename return page def read_list(filename, default=[], directory=None): path = os.path.join(config_values["indir"] if directory is None else directory, filename + ".sml") if not os.path.exists(path): if default is not None: print("Writing default " + path) base = os.path.dirname(path) if not os.path.exists(base): os.makedirs(base) with open(path, "w") as fd: fd.write('\n'.join(default)) return default return [] print("Reading " + path) with open(path, "r") as fd: data = fd.read().splitlines() return data def write_page(filename, data, extension="html"): filename += (("." + extension) if extension else "") path = os.path.join(config_values["outdir"], filename) print("Writing " + path) base = os.path.dirname(path) if not os.path.exists(base): os.makedirs(base) with open(path, "w") as fd: fd.write('\n'.join(data)) return filename def read_pages(): directory = config_values["indir"] pages = [] for root, dirs, files in os.walk(directory): for filename in files: if not filename.endswith(".sml"): continue path = os.path.join(os.path.relpath(root, directory), filename) path = path[2:-4] if path.split(os.sep)[0] == "." else path[:-4] if os.path.dirname(path) not in {"header", "footer"} and path != "config" and path != "theme": pages.append(read_page(path)) return pages def copy_file(filename): ipath = os.path.join(config_values["indir"], filename) opath = os.path.join(config_values["outdir"], filename) if not os.path.exists(ipath): print("Warning: file " + ipath + " does not exist") return print("Copying " + ipath + " to " + opath) base = os.path.dirname(opath) if not os.path.exists(base): os.makedirs(base) shutil.copy(ipath, opath) def read_config(directory, output): global config_values config = read_list("config", [" [#" + (prop if type(value) is bool else prop + " " + str(value)) + "]" for prop, value in config_values.items()], directory) for cfg in config: if cfg.startswith("[#") and cfg.endswith("]"): prop, value = get_key_value(cfg[2:-1]) if prop not in config_values.keys(): print("Warning: config value '" + prop + "' is unknown") continue ovalue = config_values[prop] if value is None: if type(ovalue) is not bool: print("Warning: config value '" + prop + "' needs an argument") continue value = True elif type(ovalue) is bool: if value.lower() != "true" and value.lower() != "false": print("Warning: unknown bool value for config value '" + prop + "'") continue value = value.lower() == "true" print("Warning: redundant bool value for config value '" + prop + "'") config_values[prop] = value config_values["indir"] = directory config_values["outdir"] = output for prop, value in config_values.items(): print(prop + " = " + str(value)) def add_default_links(): global traversed_links for key in "logo", "icon", "stylesheet": if config_values[key].startswith("/"): traversed_links.add(config_values[key]) def read_theme(): theme = read_list("theme", ["[#" + prop + " " + str(value) + "]" for prop, value in default_theme.items()]) style = {prop: str(value) for prop, value in default_theme.items()} for line in theme: if line.startswith("[#") and line.endswith("]"): prop, value = get_key_value(line[2:-1]) if prop not in default_theme.keys(): print("Warning: theme property '" + prop + "' is unknown") continue if value is None: continue if type(default_theme[prop]) is int: test = int(value) style[prop] = value return style def compile_stylesheet(classes, variables): lines = [] for clazz, props in classes: lines.append(clazz + " {") for prop, value in props.items(): plain = "" tpos = pos = 0 while True: lpos = value.find("[#", pos) if lpos < 0: break epos = value.find("]", lpos + 2) if epos < 0: break key, _ = get_key_value(value[lpos + 2:epos]) if key in variables.keys(): if tpos < lpos: plain += value[tpos:lpos] plain += variables[key] tpos = epos + 1; pos = epos + 1; if tpos < len(value): plain += value[tpos:] lines.append("\t" + prop + ": " + plain + ";") lines.append("}") return lines def write_stylesheet(style): return write_page(config_values["stylesheet"][1:], style, extension=None) def write_pages(pages): written = set() for path, tree in pages: written.add(write_page(path, tree2html(tree))) return written def get_url_file(url): url = url[1:] return url if "." in url else ("index.html" if not url else os.path.join(url[:-1] if url.endswith("/") else url, "index.html")) def get_file_url(filename): return "/" + (("" if filename == "index" else os.path.dirname(filename)) if os.path.basename(filename) == "index" else (filename + ".html")) def copy_linked_files(written): for url in traversed_links: filename = get_url_file(url) if filename not in written: copy_file(filename) def clean_directory(): output = config_values["outdir"] if os.path.exists(output): shutil.rmtree(output) def main(): if len(sys.argv) < 2: print(sys.argv[0] + " ") return read_config(sys.argv[1], sys.argv[2]) add_default_links() pages = read_pages() theme = read_theme() pages = compile_pages(pages) style = compile_stylesheet(base_stylesheet, theme) clean_directory() written = write_pages(pages) written.add(write_stylesheet(style)) copy_linked_files(written) if __name__ == "__main__": main()