diff options
Diffstat (limited to 'blog_experiment/blog')
| -rw-r--r-- | blog_experiment/blog/__init__.py | 16 | ||||
| -rw-r--r-- | blog_experiment/blog/__main__.py | 17 | ||||
| -rw-r--r-- | blog_experiment/blog/blog_pages.py | 80 | ||||
| -rw-r--r-- | blog_experiment/blog/gemtext.py | 223 | ||||
| -rw-r--r-- | blog_experiment/blog/html.py | 29 | ||||
| -rw-r--r-- | blog_experiment/blog/page.py | 37 |
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", + ) |
