aboutsummaryrefslogtreecommitdiff
path: root/blog_experiment/blog
diff options
context:
space:
mode:
authoralex <alex@pdp7.net>2023-09-17 17:39:54 +0200
committeralex <alex@pdp7.net>2023-09-17 17:39:54 +0200
commite7d04e802ea9fcf4a56210be16aaa0b131e5e797 (patch)
tree071fc39e31a08dd9c91fe5c5ab4d8c05fde3feab /blog_experiment/blog
parente5a7e9667c709c20988158b30b29e5ac019c0fe2 (diff)
Refactor in modules, add gemtext parser
Diffstat (limited to 'blog_experiment/blog')
-rw-r--r--blog_experiment/blog/__init__.py16
-rw-r--r--blog_experiment/blog/__main__.py17
-rw-r--r--blog_experiment/blog/blog_pages.py80
-rw-r--r--blog_experiment/blog/gemtext.py223
-rw-r--r--blog_experiment/blog/html.py29
-rw-r--r--blog_experiment/blog/page.py37
6 files changed, 402 insertions, 0 deletions
diff --git a/blog_experiment/blog/__init__.py b/blog_experiment/blog/__init__.py
new file mode 100644
index 00000000..4b1e0ba5
--- /dev/null
+++ b/blog_experiment/blog/__init__.py
@@ -0,0 +1,16 @@
+import pathlib
+import re
+
+import bicephalus
+
+from blog import blog_pages, page
+
+
+def handler(request: bicephalus.Request) -> bicephalus.Response:
+ if request.path == "/":
+ return blog_pages.Root(request).response()
+ if re.match(r"/\d{4}/\d{2}/.*/", request.path):
+ blog_file = pathlib.Path("content") / (request.path[1:-1] + ".gmi")
+ if blog_file.exists():
+ return blog_pages.EntryPage(request, blog_file).response()
+ return page.NotFound(request).response()
diff --git a/blog_experiment/blog/__main__.py b/blog_experiment/blog/__main__.py
new file mode 100644
index 00000000..b936500d
--- /dev/null
+++ b/blog_experiment/blog/__main__.py
@@ -0,0 +1,17 @@
+import logging
+
+from bicephalus import main as bicephalus_main
+from bicephalus import otel
+from bicephalus import ssl
+
+import blog
+
+
+def main():
+ otel.configure_logging(logging.INFO)
+ with ssl.temporary_ssl_context("localhost") as ssl_context:
+ bicephalus_main.main(blog.handler, ssl_context, 8000)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/blog_experiment/blog/blog_pages.py b/blog_experiment/blog/blog_pages.py
new file mode 100644
index 00000000..7808c16d
--- /dev/null
+++ b/blog_experiment/blog/blog_pages.py
@@ -0,0 +1,80 @@
+import datetime
+import itertools
+import pathlib
+import textwrap
+
+import bicephalus
+
+import htmlgenerator as h
+
+from blog import html, page
+
+
+class Entry:
+ def __init__(self, path: pathlib.Path):
+ assert path.is_relative_to(pathlib.Path("content")), f"bad path {path}"
+ self.path = path
+ self.content = path.read_text()
+
+ @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.path.parts[1]}/{self.path.parts[2]}/{self.path.stem}/"
+
+
+class Root(page.BasePage):
+ def entries(self):
+ entries = map(Entry, pathlib.Path("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(
+ """\
+ # El blog es mío
+
+ ## Hay otros como él, pero este es el mío
+
+ ____
+ """
+ )
+ + 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)),
+ )
+
+
+class EntryPage(page.BasePage):
+ def __init__(self, request, path):
+ super().__init__(request)
+ self.path = path
+ self.entry = Entry(path)
+
+ def get_gemini_content(self):
+ return bicephalus.Status.OK, "text/gemini", self.entry.content
+
+ def get_http_content(self):
+ return (
+ bicephalus.Status.OK,
+ "text/html",
+ html.html_template(
+ h.PRE(self.entry.content),
+ ),
+ )
diff --git a/blog_experiment/blog/gemtext.py b/blog_experiment/blog/gemtext.py
new file mode 100644
index 00000000..66298e3f
--- /dev/null
+++ b/blog_experiment/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_experiment/blog/html.py b/blog_experiment/blog/html.py
new file mode 100644
index 00000000..7293d395
--- /dev/null
+++ b/blog_experiment/blog/html.py
@@ -0,0 +1,29 @@
+import subprocess
+
+import htmlgenerator as h
+
+
+def tidy(s):
+ p = subprocess.run(
+ ["tidy", "--indent", "yes", "-q", "-wrap", "160"],
+ input=s,
+ stdout=subprocess.PIPE,
+ encoding="UTF8",
+ )
+ return p.stdout
+
+
+def html_template(*content):
+ return tidy(
+ h.render(
+ h.HTML(
+ h.HEAD(h.TITLE("El blog es mío")),
+ h.BODY(
+ h.H1("El blog es mío"),
+ h.H2("Hay otros como él, pero este es el mío"),
+ *content,
+ ),
+ ),
+ {},
+ )
+ )
diff --git a/blog_experiment/blog/page.py b/blog_experiment/blog/page.py
new file mode 100644
index 00000000..fcc4841a
--- /dev/null
+++ b/blog_experiment/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",
+ )