Skip to content
fossil-skin Artifact [26e531d7e6]
Login

Artifact [26e531d7e6]

Artifact 26e531d7e690e055e55efaa5910bd867ab81cc7e9d87831839b1665fa73f6467:


#!/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()