aboutsummaryrefslogtreecommitdiff
path: root/blog/src
diff options
context:
space:
mode:
Diffstat (limited to 'blog/src')
-rw-r--r--blog/src/blog/__init__.py61
-rw-r--r--blog/src/blog/__main__.py36
-rw-r--r--blog/src/blog/blog_pages.py148
-rw-r--r--blog/src/blog/gemtext.py223
-rw-r--r--blog/src/blog/html.py140
-rw-r--r--blog/src/blog/meta.py15
-rw-r--r--blog/src/blog/page.py37
-rw-r--r--blog/src/blog/pretty.py5
8 files changed, 665 insertions, 0 deletions
diff --git a/blog/src/blog/__init__.py b/blog/src/blog/__init__.py
new file mode 100644
index 00000000..511e241c
--- /dev/null
+++ b/blog/src/blog/__init__.py
@@ -0,0 +1,61 @@
+import importlib.resources
+import pathlib
+import re
+
+import bicephalus
+
+import htmlgenerator as h
+
+from blog import blog_pages, page, html, pretty, gemtext
+
+
+STATIC = importlib.resources.files("static").iterdir().__next__().parent
+
+
+class SimplePage(page.BasePage):
+ def __init__(self, request, url, title):
+ super().__init__(request)
+ self.url = url
+ self.title = title
+
+ def get_gemini_content(self):
+ file = (STATIC / self.url[1:] / "index.gmi")
+ return (
+ bicephalus.Status.OK,
+ "text/gemini",
+ file.read_text(),
+ )
+
+ def get_http_content(self):
+ return (
+ bicephalus.Status.OK,
+ "text/html",
+ pretty.pretty_html(h.render(
+ h.HTML(
+ h.HEAD(
+ h.TITLE(self.title),
+ ),
+ h.BODY(*html.gemini_to_html(gemtext.parse(self.get_gemini_content()[2])))
+ ), {})),
+ )
+
+
+def handler(request: bicephalus.Request) -> bicephalus.Response:
+ if not request.path.endswith("/"):
+ return bicephalus.Response(request.path + "/", None, bicephalus.Status.PERMANENT_REDIRECTION)
+ if request.path == "/":
+ return blog_pages.Root(request).response()
+ if re.match(r"/\d{4}/\d{2}/.*/", request.path):
+ blog_file = blog_pages.CONTENT / (request.path[1:-1] + ".gmi")
+ if blog_file.exists():
+ return blog_pages.EntryPage(request, blog_file).response()
+ if request.path == "/feed/" and request.proto == bicephalus.Proto.HTTP:
+ return blog_pages.Root(request).feed()
+ if request.path == "/about/":
+ return SimplePage(request, request.path, "About Álex Córcoles").response()
+ if request.path == "/laspelis/":
+ return SimplePage(request, request.path, "laspelis").response()
+ if re.match(r"/laspelis/\d+/", request.path):
+ return SimplePage(request, request.path.removesuffix("/") + "/", request.path).response()
+
+ return page.NotFound(request).response()
diff --git a/blog/src/blog/__main__.py b/blog/src/blog/__main__.py
new file mode 100644
index 00000000..8ed5f19b
--- /dev/null
+++ b/blog/src/blog/__main__.py
@@ -0,0 +1,36 @@
+import argparse
+import logging
+import sys
+
+from bicephalus import main as bicephalus_main
+from bicephalus import otel
+from bicephalus import ssl
+
+import blog
+
+from blog import meta
+
+
+def main():
+ otel.configure(log_level=logging.INFO)
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--key-cert", nargs=2, metavar=("KEY", "CERT",), help="Path to a key and a file")
+ parser.add_argument("schema")
+ parser.add_argument("host")
+ args = parser.parse_args()
+ meta.SCHEMA = args.schema
+ meta.HOST = args.host
+
+ if args.key_cert:
+ key, cert = args.key_cert
+ with ssl.ssl_context_from_files(cert, key) as ssl_context:
+ bicephalus_main.main(blog.handler, ssl_context, 8000)
+ sys.exit(0)
+
+ with ssl.temporary_ssl_context("localhost") as ssl_context:
+ bicephalus_main.main(blog.handler, ssl_context, 8000)
+ sys.exit(0)
+
+if __name__ == "__main__":
+ main()
diff --git a/blog/src/blog/blog_pages.py b/blog/src/blog/blog_pages.py
new file mode 100644
index 00000000..9c6d5ee9
--- /dev/null
+++ b/blog/src/blog/blog_pages.py
@@ -0,0 +1,148 @@
+import datetime
+import importlib.resources
+import itertools
+import pathlib
+import textwrap
+
+import bicephalus
+
+import htmlgenerator as h
+
+from feedgen import feed
+
+from blog import html, page, gemtext, meta, pretty
+
+
+CONTENT = importlib.resources.files("content").iterdir().__next__().parent
+
+def gemini_links():
+ return "\n".join([f"=> {url} {text}" for text, url in meta.LINKS])
+
+
+class Entry:
+ def __init__(self, path: pathlib.Path):
+ assert path.is_relative_to(CONTENT), f"bad path {path} not relative to {CONTENT}"
+ self.path = path
+ self.content = path.read_text()
+ self.relative_path = path.relative_to(CONTENT)
+
+ @property
+ def title(self):
+ return self.content.splitlines()[0][2:]
+
+ @property
+ def posted(self):
+ return datetime.date.fromisoformat(self.content.splitlines()[1])
+
+ @property
+ def uri(self):
+ return f"/{self.relative_path.parts[0]}/{self.relative_path.parts[1]}/{self.relative_path.stem}/"
+
+ @property
+ def edit_url(self):
+ return f"https://github.com/alexpdp7/alexpdp7/edit/master/blog/content{self.uri[:-1]}.gmi"
+
+ def html(self):
+ parsed = gemtext.parse(self.content)
+
+ assert isinstance(parsed[0], gemtext.Header)
+ assert parsed[0].level == 1
+ assert isinstance(parsed[1], gemtext.Line)
+ assert parsed[2] == gemtext.Line("")
+
+ result = html.gemini_to_html(parsed[3:])
+ result += [
+ h.P(meta.EMAIL_TEXT),
+ h.P(h.A("Editar", href=self.edit_url)),
+ ]
+ return result
+
+
+class Root(page.BasePage):
+ def entries(self):
+ entries = map(Entry, CONTENT.glob("*/*/*.gmi"))
+ return sorted(entries, key=lambda e: e.posted, reverse=True)
+
+ def get_gemini_content(self):
+ posts = "\n".join([f"=> {e.uri} {e.posted} {e.title}" for e in self.entries()])
+ content = (
+ textwrap.dedent(
+ f"""\
+ # {meta.TITLE}
+
+ ## {meta.SUBTITLE}
+
+ """
+ )
+ + gemini_links()
+ + f"\n{meta.EMAIL_TEXT}\n"
+ + "\n"
+ + posts
+ )
+ return bicephalus.Status.OK, "text/gemini", content
+
+ def get_http_content(self):
+ posts = [
+ (h.H3(h.A(f"{e.title} ({e.posted})", href=e.uri))) for e in self.entries()
+ ]
+ return (
+ bicephalus.Status.OK,
+ "text/html",
+ html.html_template(*itertools.chain(posts), path=self.request.path, full=True),
+ )
+
+ def feed(self):
+ fg = feed.FeedGenerator()
+ fg.title(meta.TITLE)
+ fg.subtitle(meta.SUBTITLE)
+ fg.link(href=f"{meta.SCHEMA}://{meta.HOST}", rel="self")
+
+ for entry in self.entries()[0:10]:
+ fe = fg.add_entry()
+ url = f"{meta.SCHEMA}://{meta.HOST}/{entry.uri}"
+ fe.link(href=url)
+ fe.published(datetime.datetime.combine(entry.posted, datetime.datetime.min.time(), tzinfo=datetime.timezone.utc))
+ fe.title(entry.title)
+ html = h.render(h.BaseElement(*entry.html()), {})
+ html = pretty.pretty_html(html)
+ fe.content(html, type="html")
+
+ return bicephalus.Response(
+ status=bicephalus.Status.OK,
+ content_type="application/rss+xml",
+ content=fg.rss_str(pretty=True),
+ )
+
+
+class EntryPage(page.BasePage):
+ def __init__(self, request, path):
+ super().__init__(request)
+ self.path = path
+ self.entry = Entry(path)
+
+ def get_gemini_content(self):
+ content = (
+ textwrap.dedent(f"""\
+ => gemini://{meta.HOST} alex.corcoles.net
+ {meta.EMAIL_TEXT}
+
+ """) +
+ self.entry.content +
+ textwrap.dedent(f"""\
+ => {self.entry.edit_url} Editar
+ """)
+ )
+
+ return bicephalus.Status.OK, "text/gemini", content
+
+ def get_http_content(self):
+ return (
+ bicephalus.Status.OK,
+ "text/html",
+ html.html_template(
+ *self.entry.html(),
+ page_title=f"{self.entry.title} - {self.entry.posted}",
+ path=self.request.path,
+ full=False,
+ ),
+ )
diff --git a/blog/src/blog/gemtext.py b/blog/src/blog/gemtext.py
new file mode 100644
index 00000000..66298e3f
--- /dev/null
+++ b/blog/src/blog/gemtext.py
@@ -0,0 +1,223 @@
+import dataclasses
+import re
+import typing
+
+
+def parse(s):
+ """
+ >>> parse('''# Header 1
+ ...
+ ... ## Header 2
+ ...
+ ... ### Header 3
+ ...
+ ... * List 1
+ ... * List 2
+ ...
+ ... > First line quote.
+ ... > Second line of quote.
+ ...
+ ... ```
+ ... Fenced
+ ... Lines
+ ... ```
+ ...
+ ... Paragraph.
+ ...
+ ... Another paragraph.
+ ... ''')
+ [Header(level=1, text='Header 1'),
+ Line(text=''),
+ Header(level=2, text='Header 2'),
+ Line(text=''),
+ Header(level=3, text='Header 3'),
+ Line(text=''),
+ List(items=[ListItem(text='List 1'),
+ ListItem(text='List 2')]),
+ Line(text=''),
+ BlockQuote(lines=[BlockQuoteLine(text='First line quote.'),
+ BlockQuoteLine(text='Second line of quote.')]),
+ Line(text=''),
+ Pre(content='Fenced\\nLines\\n'),
+ Line(text=''),
+ Line(text='Paragraph.'),
+ Line(text=''),
+ Line(text='Another paragraph.')]
+ """
+
+ lines = s.splitlines()
+
+ i = 0
+ gem = []
+
+ while i < len(lines):
+ line = parse_line(lines[i])
+
+ if isinstance(line, Link):
+ gem.append(line)
+ i = i + 1
+ continue
+
+ if isinstance(line, Header):
+ gem.append(line)
+ i = i + 1
+ continue
+
+ if isinstance(line, ListItem):
+ items = []
+ while i < len(lines) and isinstance(parse_line(lines[i]), ListItem):
+ items.append(parse_line(lines[i]))
+ i = i + 1
+ gem.append(List(items))
+ continue
+
+ if isinstance(line, BlockQuoteLine):
+ quotes = []
+ while i < len(lines) and isinstance(parse_line(lines[i]), BlockQuoteLine):
+ quotes.append(parse_line(lines[i]))
+ i = i + 1
+ gem.append(BlockQuote(quotes))
+ continue
+
+ if isinstance(line, PreFence):
+ content = ""
+ i = i + 1
+ while i < len(lines) and not isinstance(parse_line(lines[i]), PreFence):
+ content += lines[i]
+ content += "\n"
+ i = i + 1
+ gem.append(Pre(content))
+ i = i + 1
+ continue
+
+ gem.append(line)
+ i = i + 1
+
+ return gem
+
+
+def parse_line(l):
+ if Link.is_link(l):
+ return Link(l)
+ if Header.is_header(l):
+ return Header(l)
+ if ListItem.is_list_item(l):
+ return ListItem(l)
+ if BlockQuoteLine.is_block_quote_line(l):
+ return BlockQuoteLine(l)
+ if PreFence.is_pre_fence(l):
+ return PreFence()
+ return Line(l)
+
+
+@dataclasses.dataclass
+class Link:
+ """
+ >>> Link("=> http://example.com")
+ Link(url='http://example.com', text=None)
+
+ >>> Link("=> http://example.com Example text")
+ Link(url='http://example.com', text='Example text')
+ """
+
+ url: str
+ text: typing.Optional[str]
+
+ def __init__(self, line: str):
+ assert Link.is_link(line)
+ parts = line.split(None, 2)
+ self.url = parts[1]
+ self.text = parts[2] if len(parts) > 2 else None
+
+ @staticmethod
+ def is_link(line: str):
+ return line.startswith("=>")
+
+@dataclasses.dataclass
+class Header:
+ """
+ >>> Header("# Level one")
+ Header(level=1, text='Level one')
+
+ >>> Header("## Level two")
+ Header(level=2, text='Level two')
+
+ >>> Header("### Level three")
+ Header(level=3, text='Level three')
+ """
+
+ level: int
+ text: str
+
+ def __init__(self, line: str):
+ assert Header.is_header(line)
+ hashes, self.text = line.split(None, 1)
+ self.level = len(hashes)
+
+ @staticmethod
+ def is_header(line: str):
+ return re.match("#{1,3} .*", line)
+
+@dataclasses.dataclass
+class ListItem:
+ """
+ >>> ListItem("* foo")
+ ListItem(text='foo')
+ """
+
+ text: str
+
+ def __init__(self, line: str):
+ assert ListItem.is_list_item(line)
+ self.text = line[2:]
+
+ @staticmethod
+ def is_list_item(line: str):
+ return line.startswith("* ")
+
+
+@dataclasses.dataclass
+class BlockQuoteLine:
+ """
+ >>> BlockQuoteLine("> foo")
+ BlockQuoteLine(text='foo')
+
+ >>> BlockQuoteLine(">foo")
+ BlockQuoteLine(text='foo')
+ """
+
+ text: str
+
+ def __init__(self, line: str):
+ assert BlockQuoteLine.is_block_quote_line(line)
+ self.text = line[2:] if line.startswith("> ") else line[1:]
+
+ @staticmethod
+ def is_block_quote_line(line: str):
+ return line.startswith(">")
+
+
+class PreFence:
+ @staticmethod
+ def is_pre_fence(line: str):
+ return line == "```"
+
+
+@dataclasses.dataclass
+class Line:
+ text: str
+
+
+@dataclasses.dataclass
+class List:
+ items: typing.List[ListItem]
+
+
+@dataclasses.dataclass
+class BlockQuote:
+ lines: typing.List[BlockQuoteLine]
+
+
+@dataclasses.dataclass
+class Pre:
+ content: str
diff --git a/blog/src/blog/html.py b/blog/src/blog/html.py
new file mode 100644
index 00000000..1cda61c1
--- /dev/null
+++ b/blog/src/blog/html.py
@@ -0,0 +1,140 @@
+import itertools
+import textwrap
+
+import htmlgenerator as h
+
+from blog import meta, pretty, gemtext
+
+
+def html_template(*content, page_title=None, path, full):
+ title = [h.A(meta.TITLE, href=f"{meta.SCHEMA}://{meta.HOST}")]
+ if page_title:
+ title += f" - {page_title}"
+
+ title = h.BaseElement(*title)
+
+ links = list(itertools.chain(*[(h.A(text, href=href), ", ") for text, href in meta.LINKS]))
+
+ links += [h.BaseElement(f" {meta.EMAIL_TEXT}")]
+
+ full_part = []
+ if full:
+ full_part = [
+ h.H2(meta.SUBTITLE),
+ h.P(h.A("Buscar con DuckDuckGo en esta página", href="https://html.duckduckgo.com/html/?q=site:alex.corcoles.net")),
+ h.P(*links),
+ ]
+
+ gemini_url = f"gemini://alex.corcoles.net{path}"
+
+ return pretty.pretty_html(h.render(
+ h.HTML(
+ h.HEAD(
+ h.TITLE(meta.TITLE + (f" - {page_title}" if page_title else "")),
+ h.LINK(rel="alternate", type="application/rss+xml", title=meta.TITLE, href=f"{meta.SCHEMA}://{meta.HOST}/feed/"),
+ h.STYLE(textwrap.dedent("""
+ body {
+ max-width: 40em;
+ margin-left: auto;
+ margin-right: auto;
+ padding-left: 2em;
+ padding-right: 2em;
+ background-color: #fffffa;
+ color: #000000;
+ font-family: system-ui;
+ }
+ @media (prefers-color-scheme: dark) {
+ body {
+ background-color: #000000;
+ color: #fffffa;
+ }
+ }
+ p, blockquote, li {
+ /* from Mozilla reader mode */
+ line-height: 1.6em;
+ font-size: 20px;
+ }
+ """).lstrip())
+ ),
+ h.BODY(
+ h.P(
+ "Contenido tambien disponible en Gemini en ",
+ h.A(gemini_url, href=gemini_url),
+ ". ",
+ h.A("Información sobre Gemini.", href="https://geminiprotocol.net/"),
+ ),
+ h.H1(title),
+ *full_part,
+ *content,
+ ),
+ doctype="html",
+ ),
+ {},
+ ))
+
+
+def gemini_to_html(parsed):
+ i = 0
+ result = []
+ while i < len(parsed):
+ gem_element = parsed[i]
+
+ if isinstance(gem_element, gemtext.Header):
+ header = [h.H1, h.H2, h.H3, h.H4, h.H5, h.H6][gem_element.level - 1]
+ result.append(header(gem_element.text))
+ i = i + 1
+ continue
+
+ if isinstance(gem_element, gemtext.List):
+ result.append(h.UL(*[h.LI(i.text) for i in gem_element.items]))
+ i = i + 1
+ continue
+
+ if isinstance(gem_element, gemtext.Link):
+ url = gem_element.url
+ if url.startswith("gemini://"):
+ if url.startswith("gemini://alex.corcoles.net/"):
+ url = url.replace("gemini://alex.corcoles.net/", f"{meta.SCHEMA}://{meta.HOST}/")
+ else:
+ url = url.replace("gemini://", "https://portal.mozz.us/gemini/")
+
+ result.append(h.P(h.A(gem_element.text or gem_element.url, href=url)))
+ i = i + 1
+ continue
+
+ if gem_element == gemtext.Line(""):
+ i = i + 1
+ continue
+
+ if isinstance(gem_element, gemtext.BlockQuote):
+ content = []
+ for line in gem_element.lines:
+ if line.text:
+ content.append(line.text)
+ content.append(h.BR())
+ result.append(h.BLOCKQUOTE(*content))
+ i = i + 1
+ continue
+
+ if isinstance(gem_element, gemtext.Line):
+ paragraph = [gem_element.text]
+ i = i + 1
+ while i < len(parsed):
+ gem_element = parsed[i]
+ if isinstance(gem_element, gemtext.Line) and gem_element.text != "":
+ paragraph.append(h.BR())
+ paragraph.append(gem_element.text)
+ i = i + 1
+ else:
+ break
+ result.append(h.P(*paragraph))
+ continue
+
+ if isinstance(gem_element, gemtext.Pre):
+ result.append(h.PRE(gem_element.content))
+ i = i + 1
+ continue
+
+ assert False, f"unknown element {gem_element}"
+
+ return result
diff --git a/blog/src/blog/meta.py b/blog/src/blog/meta.py
new file mode 100644
index 00000000..6796671a
--- /dev/null
+++ b/blog/src/blog/meta.py
@@ -0,0 +1,15 @@
+TITLE = "El blog es mío"
+SUBTITLE = "Hay otros como él, pero este es el mío"
+HOST = None
+SCHEMA = None
+
+LINKS = (
+ ("@yo@alex.femto.pub", "https://alex.femto.pub/@yo@alex.femto.pub/"),
+ ("GitHub", "https://github.com/alexpdp7/"),
+ ("TVmaze", "https://www.tvmaze.com/users/35495/koala/stats"),
+ ("LinkedIn", "https://es.linkedin.com/in/alexcorcoles"),
+ ("Project Euler", "https://projecteuler.net/profile/koalillo.png"),
+ ("Stack Exchange", "https://stackexchange.com/users/13361/alex"),
+)
+
+EMAIL_TEXT = "envíame un email cogiendo el dominio de esta web y cambiando el primer punto por una arroba"
diff --git a/blog/src/blog/page.py b/blog/src/blog/page.py
new file mode 100644
index 00000000..fcc4841a
--- /dev/null
+++ b/blog/src/blog/page.py
@@ -0,0 +1,37 @@
+import bicephalus
+
+
+class BasePage:
+ def __init__(self, request):
+ self.request = request
+
+ def response(self):
+ if self.request.proto == bicephalus.Proto.GEMINI:
+ status, content_type, content = self.get_gemini_content()
+ elif self.request.proto == bicephalus.Proto.HTTP:
+ status, content_type, content = self.get_http_content()
+ else:
+ assert False, f"unknown protocol {self.request.proto}"
+
+ return bicephalus.Response(
+ content=content.encode("utf8"),
+ content_type=content_type,
+ status=bicephalus.Status.OK,
+ )
+
+
+class NotFound(BasePage):
+ def get_gemini_content(self):
+ # TODO: does not work!
+ return (
+ bicephalus.Status.NOT_FOUND,
+ "text/gemini",
+ f"{self.request.path} not found",
+ )
+
+ def get_http_content(self):
+ return (
+ bicephalus.Status.NOT_FOUND,
+ "text/html",
+ f"{self.request.path} not found",
+ )
diff --git a/blog/src/blog/pretty.py b/blog/src/blog/pretty.py
new file mode 100644
index 00000000..2ae916a7
--- /dev/null
+++ b/blog/src/blog/pretty.py
@@ -0,0 +1,5 @@
+from lxml import etree, html
+
+
+def pretty_html(s):
+ return etree.tostring(html.fromstring(s), pretty_print=True).decode("utf8")