Skip to content
fossil-skin Artifact [67db9a3d0d]
Login

Artifact [67db9a3d0d]

Artifact 67db9a3d0d20c5fd8f64c0fc6e4074dde18d3734a44d448a9e72a82e1edc1041:


#!/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
                                     #    and dist/craftdesign-skin-instrument-sans.cfg

Two files come out. The plain one uses the viewer's system font and is small;
the other embeds Instrument Sans for the exact look of fossil.craftdesign.group.

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")
DIST = os.path.join(ROOT, "dist")
BUILDS = {
    "craftdesign-skin.cfg": False,                  # system font
    "craftdesign-skin-instrument-sans.cfg": True,   # Instrument Sans embedded
}

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(with_font):
    css = read(os.path.join(SKIN, "css.txt"))
    if not with_font:
        css = re.sub(r"@font-face \{.*?\}\n", "", css, flags=re.S)
        css = css.replace('--font:     "Instrument Sans", system-ui', "--font:     system-ui")
        assert "Instrument Sans\"" not in css and "/fonts/" not in css
        return css.replace("Instrument Sans (OFL) is self-hosted from /var/fossil/www/fonts via Caddy.",
                           "This build uses the system font; the -instrument-sans build embeds the web font.")

    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 build(out, with_font):
    parts = {
        "css": build_css(with_font),
        "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,
    }
    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)}")


def main():
    os.makedirs(DIST, exist_ok=True)
    for name, with_font in BUILDS.items():
        build(os.path.join(DIST, name), with_font)


if __name__ == "__main__":
    main()