diff options
Diffstat (limited to 'blog/src')
| -rw-r--r-- | blog/src/blog/__init__.py | 61 | ||||
| -rw-r--r-- | blog/src/blog/__main__.py | 36 | ||||
| -rw-r--r-- | blog/src/blog/blog_pages.py | 148 | ||||
| -rw-r--r-- | blog/src/blog/gemtext.py | 223 | ||||
| -rw-r--r-- | blog/src/blog/html.py | 140 | ||||
| -rw-r--r-- | blog/src/blog/meta.py | 15 | ||||
| -rw-r--r-- | blog/src/blog/page.py | 37 | ||||
| -rw-r--r-- | blog/src/blog/pretty.py | 5 |
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") |
