Skip to content

Instantly share code, notes, and snippets.

@cgranade
Created August 26, 2024 03:14
Show Gist options
  • Save cgranade/65d9b4f86c56ddbe5f5009215d5715e5 to your computer and use it in GitHub Desktop.
Save cgranade/65d9b4f86c56ddbe5f5009215d5715e5 to your computer and use it in GitHub Desktop.
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.12"
# dependencies = ["htpy", "click"]
# ///
"""
This is a terrible tool, make your peace with that first and foremost.
Still here? OK, this tool reads all gemtext files (*.gmi) in a given
directory, makes HTML out of them by using a terrible template, then
saves those files out to a different folder.
The HTML template is terrible because:
- It's hardcoded in.
- All CSS is in <head> instead of in factored out into stylesheets
that can be downloaded and cached.
- Well, except for the one CSS file whose location is hardcoded. Oops.
TODO:
- Make this tool part of a gitea-runner workflow that publishes gemtext
"""
import click
from htpy import html, head, body, style, link, h1, h2, h3, p, a, tt, div
from markupsafe import Markup
import pathlib
import glob
import re
def template(contents):
return html[
head[
link(rel="shortcut icon", href="https://linkshells.net/linkshells.png"),
link(rel="stylesheet", href="https://linkshells.net/fonts/reactor7/webfont.css"),
style[Markup("""
:root {
--status-color: #be10c4;
--mp-color: #45d69a;
--hp-color: #205aa3;
--lv-color: #15dedf;
--drk-txt-color: #696a69;
--summon-materia-light:#fe5a5a;
--summon-materia-dark: #210000;
--magic-materia-light: #5bb65b;
--magic-materia-dark: #2d442d;
--command-materia-light: #e4e44d;
--command-materia-dark: #44440e;
--support-materia-light: #9191e4;
--support-materia-dark: #2e2e44;
--independent-materia-light: #de58de;
--independent-materia-dark: #2e0f2e;
}
body {
font-family: "Reactor7";
display: flex;
align-items: center;
justify-content: center;
margin-top: 2em;
color: white;
background-color: black;
font-size: 24pt;
text-shadow: 2px 2px 4px black;
}
h1 {
font-size: 24pt;
padding: 0.4em;
margin-left: -2em;
width: 60%;
margin-top: 0em;
margin-bottom: 0.5em;
}
h2 {
font-size: 24pt;
color: var(--lv-color);
}
.panel, h1 {
background: linear-gradient(-45deg, rgb(0, 0, 32), rgb(0, 0, 176));
border: 2px solid black;
border-radius: 0.3em;
}
.panel { padding: 1em; max-width: 80%; }
.panel table th {
font-size: 14pt;
}
.panel, h1 {
/* using values from https://www.finalfantasyforums.net/threads/default-window-colors.30391/ */
border-radius: 0.3em;
border: 3px solid white;
box-shadow: 2px 2px black;
}
a {
margin-left: 1em;
font-weight: bold;
color: var(--mp-color);
text-decoration: none;
text-shadow: 2px 2px 4px #444;
}
@keyframes blinker {
50% {
opacity: 0;
}
}
a:visited {
color: var(--status-color);
text-decoration: none;
}
a:focus::before, a:hover::before {
position: fixed;
transform: translate(-56px, 3px);
content: url("data:;base64,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");
}
a:hover::before {
animation: blinker 1s linear infinite;
position: fixed;
}
a:focus {
outline: none;
}
th {
color: var(--status-color)
}
""")]
],
body[div(".panel")[
*contents
]]
]
def parse_to_html(gemtext):
# Gemtext has the nice property that you only need to look at the start
# of each line to figure out what kind of line it is.
accumulator = ""
for line in gemtext.split("\n"):
if line.startswith("###"):
if accumulator:
yield p[accumulator]
accumulator = ""
yield h3[line.removeprefix("###").strip()]
elif line.startswith("##"):
if accumulator:
yield p[accumulator]
accumulator = ""
yield h2[line.removeprefix("##").strip()]
elif line.startswith("#"):
if accumulator:
yield p[accumulator]
accumulator = ""
yield h1[line.removeprefix("#").strip()]
elif line.startswith("=>"):
if accumulator:
yield p[accumulator]
accumulator = ""
parts = line.removeprefix("=>").strip().split(" ", 1)
href = re.sub("\\.gmi$", ".html", parts[0])
text = parts[1].strip() if len(parts) > 1 else tt[href]
yield p[a(href=href)[text]]
elif not line.strip():
if accumulator:
yield p[accumulator]
accumulator = ""
# TODO: images and ``` blocks
else:
accumulator = accumulator + "\n" + line
if accumulator.strip():
yield p[accumulator]
@click.command()
@click.argument("source", type=click.Path(file_okay=False, dir_okay=True))
@click.argument("dest", type=click.Path(file_okay=False, dir_okay=True))
def main(source, dest):
source = pathlib.Path(source)
dest = pathlib.Path(dest)
for gemfile in glob.glob("**/*.gmi", root_dir=source, recursive=True):
with open(source / gemfile) as f:
contents = template(parse_to_html(f.read()))
(dest / gemfile).parent.mkdir(parents=True, exist_ok=True)
with open((dest / gemfile).with_suffix(".html"), "w") as f:
f.write(str(contents))
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment