#!/usr/bin/env python3
"""Build a self-contained skin file that any Fossil repository can import.
python3 skin/build.py # writes dist/craftdesign-skin.cfg
The server skin (css.txt, header.txt, footer.txt) references files that Caddy
serves next to the repos: the fonts, a11y.js, the favicon, and the hub link.
This script folds all of that into the skin itself:
* fonts -> data: URIs inside the CSS
* a11y.js -> inline <script nonce="$nonce"> in the header, run on DOMContentLoaded
* favicon -> data: URI
* hub link -> the repo's own home page
* details -> white-foreground so Fossil draws pikchr and graphs for a dark page
* mainmenu -> Fossil's default menu plus Search
It then lets Fossil itself write the export, so the result is exactly what
`fossil config export skin` produces and `fossil config import` expects.
"""
import base64, os, re, sqlite3, subprocess, sys, tempfile, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SKIN = os.path.join(ROOT, "skin")
WWW = os.path.join(ROOT, "www")
OUT = os.path.join(ROOT, "dist", "craftdesign-skin.cfg")
MAINMENU = (
"Home /home * {}\n"
"Timeline /timeline {o r j} {}\n"
"Files /dir?ci=tip oh desktoponly\n"
"Branches /brlist o wideonly\n"
"Tags /taglist o wideonly\n"
"Forum /forum {@2 3 4 5 6} wideonly\n"
"Chat /chat C wideonly\n"
"Tickets /ticket r wideonly\n"
"Wiki /wiki j wideonly\n"
"Search /search {o r j 2} wideonly\n"
"Admin /setup {a s} desktoponly\n"
"Logout /logout L wideonly\n"
"Login /login !L wideonly\n"
)
DETAILS = (
"timeline-arrowheads: 1\n"
"timeline-circle-nodes: 1\n"
"timeline-color-graph-lines: 1\n"
"white-foreground: 1\n"
)
def read(path, mode="r"):
with open(path, mode) as f:
return f.read()
def data_uri(path, mime):
return f"data:{mime};base64,{base64.b64encode(read(path, 'rb')).decode()}"
def build_css():
css = read(os.path.join(SKIN, "css.txt"))
def inline_font(m):
name = m.group(1)
return f"src: url({data_uri(os.path.join(WWW, 'fonts', name), 'font/woff2')}) format(\"woff2\");"
css, n = re.subn(r"src: url\(/fonts/([^)]+)\) format\(\"woff2\"\);", inline_font, css)
assert n == 4, f"expected 4 font faces, found {n}"
css = css.replace("Instrument Sans (OFL) is self-hosted from /var/fossil/www/fonts via Caddy.",
"Instrument Sans (OFL) is embedded below so the skin needs no web server of its own.")
return css
def build_header():
hdr = read(os.path.join(SKIN, "header.txt"))
js = read(os.path.join(WWW, "a11y.js"))
js = re.sub(r"\A/\*\*.*?\*/\s*", "", js, flags=re.S) # drop the file banner, it names this server
# The server version loads this deferred so it runs after Fossil's own scripts.
# Inline, it must wait for DOMContentLoaded to get the same ordering.
js = "document.addEventListener('DOMContentLoaded', function () {\n" + js + "\n});"
hdr = hdr.replace('<script defer src="/a11y.js"></script>',
'<script nonce="$nonce">\n' + js + '\n</script>')
hdr = hdr.replace('href="/favicon.svg"',
f'href="{data_uri(os.path.join(WWW, "favicon.svg"), "image/svg+xml")}"')
hdr = hdr.replace('href="https://fossil.craftdesign.group/" class="hub-link" title="Back to hub"',
'href="$home/home" class="hub-link" title="Home"')
assert 'src="/a11y.js"' not in hdr and "https://fossil.craftdesign.group" not in hdr
return hdr
def main():
parts = {
"css": build_css(),
"header": build_header(),
"footer": read(os.path.join(SKIN, "footer.txt")),
"details": DETAILS,
"logo-image": read(os.path.join(SKIN, "logo.png"), "rb"),
"logo-mimetype": "image/png",
"mainmenu": MAINMENU,
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
repo = os.path.join(tmp, "skin.fossil")
subprocess.run(["fossil", "new", "--admin-user", "skin", repo], check=True, capture_output=True)
con = sqlite3.connect(repo)
now = int(time.time())
for name, value in parts.items():
con.execute("INSERT OR REPLACE INTO config(name,value,mtime) VALUES(?,?,?)", (name, value, now))
con.commit()
con.close()
subprocess.run(["fossil", "config", "export", "skin", OUT, "-R", repo], check=True, capture_output=True)
exported = read(OUT)
# Fossil's "skin" export area does not include the main menu; carry it along
# in the same statement format so a single import sets everything.
if "'mainmenu'" not in exported:
con = sqlite3.connect(repo)
row = con.execute("SELECT quote(value) FROM config WHERE name='mainmenu'").fetchone()
con.close()
exported += f"config /config {len(MAINMENU) + 50}\n{now} 'mainmenu' value {row[0]}\n"
with open(OUT, "w") as f:
f.write(exported)
names = re.findall(r"^\d+ '([a-z-]+)' value", read(OUT), re.M)
print(f"wrote {OUT} ({os.path.getsize(OUT) // 1024} KB): {', '.join(names)}")
if __name__ == "__main__":
main()