websitez/pagebuilder.py

604 lines
18 KiB
Python
Raw Normal View History

2025-05-01 02:56:20 +02:00
#!/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",
"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",
"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") + "</" + node[0] + ">")
else:
lines.append((indent * "\t") + "<" + node[0] + props + ">" + (("</" + node[0] + ">") 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] + " <indir> <outdir>")
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()