Skip to content
fossil-skin Artifact [93fa749b6e]
Login

Artifact [93fa749b6e]

Artifact 93fa749b6eec559dce1f3323dc185952a458caeab13691cf3fb6835f4d221918:


#!/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 = {   # file: (embed Instrument Sans?, light?)
    "craftdesign-skin.cfg": (False, False),
    "craftdesign-skin-instrument-sans.cfg": (True, False),
    "craftdesign-skin-light.cfg": (False, True),
    "craftdesign-skin-light-instrument-sans.cfg": (True, True),
}

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:           %d\n"
)

# The light skin: the same CSS with these tokens in place of the dark ones.
# white-foreground is off for it, so Fossil sends its own pastel user, branch
# and pikchr colors, which the dark skin's re-tinting is then not needed for.
LIGHT_TOKENS = """:root {
  --bg:       #ffffff;
  --surface:  #f4f5f7;
  --surface2: #e8ebef;
  --border:   #d3d7dd;
  --text:     #1c1e22;   /* 16:1 on --bg */
  --muted:    #565e68;   /* 6.6:1 on --bg */
  --link:     #2b67a8;   /* 5.8:1 on --bg; near Fossil default's #3779BF */
  --link-hover: #1d4f86;
  --link-on-tint: #1d4f86;   /* 5.0-6.4:1 on Fossil's pastel rows */
  --title:    #3a7bc0;   /* large text; Fossil default's #4183C4 */
  --active:   #d26911;   /* Fossil default's menu accent */
  --green:    #1d7a3c;
  --red:      #b3372a;
  --yellow:   #8a6100;
  --font:     "Instrument Sans", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --mono:     ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
  color-scheme: light;
  --input-bg: #ffffff; --on-accent: #ffffff; --visited: #5a4fa8;
  --sel-bg: #f7e3cc; --sec-bg: #d9e7f6; --stripe: #f7f8fa; --rule: #e5e8ec;
  --btn-hover: #dde1e6; --band-top: #fafbfc; --warn-bg: #fbf0d0; --err-bg: #fbe1dc;
  --green-rgb: 29 122 60; --red-rgb: 179 55 42; --yellow-rgb: 190 140 0;
  --link-rgb: 43 103 168; --teal-rgb: 20 140 130; --violet-rgb: 90 90 200;
  --muted-rgb: 86 94 104; --veil-rgb: 255 255 255; --lift-rgb: 0 0 0; --shadow-rgb: 0 0 0;
  --tint-rgb: 255 255 255; --tint-mute: 0; --chat-mute: 0; --bubble-shadow: 0.10; --popup-shadow: 0.16;
  --veil: 0;
}"""


def lighten(css):
    """The dark css.txt as the light skin."""
    css, n = re.subn(r":root \{.*?\n\}", lambda m: LIGHT_TOKENS, css, count=1, flags=re.S)
    assert n == 1
    css, n = re.subn(r"/\* @dark-only.*?/\* @end-dark-only \*/\n", "", css, flags=re.S)
    assert n == 1
    css = css.replace("stroke='%23b3b8c0'", "stroke='%23565e68'")   # select arrows
    return css.replace("A dark rendering of Fossil's default skin", "A light rendering of Fossil's default skin", 1)


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, light=False):
    css = read(os.path.join(SKIN, "css.txt"))
    if light:
        css = lighten(css)
    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"')
    # The server preloads its font files; these builds embed the fonts instead.
    hdr = re.sub(r'<link rel="preload" href="/fonts/[^"]+"[^>]*>\n', "", hdr)
    assert 'src="/a11y.js"' not in hdr and "https://fossil.craftdesign.group" not in hdr and "/fonts/" not in hdr
    return hdr


def build(out, with_font, light=False):
    parts = {
        "css": build_css(with_font, light),
        "header": build_header(),
        "footer": read(os.path.join(SKIN, "footer.txt")),
        "details": DETAILS % (0 if light else 1),
        "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, light) in BUILDS.items():
        build(os.path.join(DIST, name), with_font, light)


if __name__ == "__main__":
    main()