]> git.mar77i.info Git - mar77i.info/commitdiff
initial commit
authormar77i <mar77i@protonmail.ch>
Sun, 16 Feb 2025 05:10:18 +0000 (06:10 +0100)
committermar77i <mar77i@protonmail.ch>
Sun, 16 Mar 2025 21:42:41 +0000 (22:42 +0100)
.gitignore [new file with mode: 0644]
content/index.md [new file with mode: 0644]
generate.py [new file with mode: 0755]
md.py [new file with mode: 0644]
post-receive.sh [new file with mode: 0755]
style.css [new file with mode: 0644]
template.html [new file with mode: 0644]
template.py [new file with mode: 0644]
utils.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..2483976
--- /dev/null
@@ -0,0 +1,2 @@
+.idea/
+__pycache__/
diff --git a/content/index.md b/content/index.md
new file mode 100644 (file)
index 0000000..0601254
--- /dev/null
@@ -0,0 +1,5 @@
+# Home
+
+Hi, I'm mar77i, and I'm from Switzerland.
+
+For blog posts, see under [Blog](blog/)...
diff --git a/generate.py b/generate.py
new file mode 100755 (executable)
index 0000000..9f4b50f
--- /dev/null
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+
+import shutil
+from argparse import ArgumentParser
+from pathlib import Path
+
+from md import MDRenderer
+from template import Template
+from utils import get_content, write_content
+
+
+def main():
+    ap = ArgumentParser()
+    ap.add_argument("--output-dir", default="/dev/shm/output")
+    args = ap.parse_args()
+    source_path = Path(__file__).parent
+    content_path = source_path / "content"
+    output_path = Path(args.output_dir)
+    if output_path.exists():
+        shutil.rmtree(output_path)
+    output_path.mkdir(0o755)
+    template = Template(source_path / "template.html")
+    context = {
+        "nav-items": [
+            {
+                "link": "/",
+                "text": "Home",
+            },
+            {
+                "link": "/blog/",
+                "text": "Blog",
+            },
+            {
+                "link": "https://git.mar77i.info/",
+                "text": "Git",
+            },
+        ]
+    }
+    context["title"], context["page"] = MDRenderer(
+        get_content(content_path / "index.md")
+    ).render_html()
+    write_content(output_path / "index.html", template.render(context))
+
+    #context["blogs"] = []
+    #context["hashtags"] = []
+    #for file in (content_path / "blog").iterdir():
+    #    if file.name == "index.md" or not file.name.endswith(".md"):
+    #        continue
+    #    context["blogs"].append(f"blog/{file.name[:-3]}.html")
+    #    context["title"], context["page"] = MDRenderer(get_content(file)).render_html()
+    #    write_content(
+    #        blog_path / f"{file.name[:-3]}.html", template.render(context),
+    #    )
+    #context["title"], context["page"] = MDRenderer(
+    #    get_content(content_path / "blog" / "index.md")
+    #).render_html()
+
+    blog_path = output_path / "blog"
+    blog_path.mkdir(0o755)
+    context["title"], context["page"] = "Blog stub", "<h2>Blog stub</h2>"
+    write_content(blog_path / "index.html", template.render(context))
+
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/md.py b/md.py
new file mode 100644 (file)
index 0000000..acc64d4
--- /dev/null
+++ b/md.py
@@ -0,0 +1,140 @@
+from collections import OrderedDict
+from html import escape
+from io import SEEK_END, StringIO
+
+
+class Paragraph:
+    name = "p"
+
+    def __init__(self):
+        self.lines = []
+        self.attributes = OrderedDict()
+
+    def join_attrs(self):
+        if not self.attributes:
+            return ""
+        return "".join(
+            f' {key}="{escape(value)}"' for key, value in self.attributes.items()
+        )
+
+    def join_lines(self):
+        content = '\n'.join(self.lines)
+        sio = StringIO()
+        backslash = False
+        link = None
+        for i, c in enumerate(content):
+            if backslash:
+                assert c in "\\()[]`*/_\n"
+                if c == "\n":
+                    c = " "
+                sio.write(c)
+            elif link is not None:
+                if c == "[]()"[len(link)]:
+                    link.append(sio.tell())
+                if len(link) == 4:
+                    sio.seek(link[0])
+                    text = sio.read(link[1] - link[0])
+                    sio.read(1 + link[2] - link[1])
+                    url = sio.read()
+                    sio.seek(link[0])
+                    link = None
+                    sio.truncate()
+                    sio.write('<a href="')
+                    sio.write(escape(url.strip()))
+                    sio.write('">')
+                    sio.write(escape(text, False))
+                    sio.write("</a>")
+                    continue
+            if c == "\\":
+                backslash = True
+                continue
+            elif c == "[":
+                link = [sio.tell()]
+                continue
+            sio.write(c)
+        assert backslash is False and link is None
+        return sio.getvalue()
+
+    def join(self):
+        return f"<{self.name}{self.join_attrs()}>{self.join_lines()}</{self.name}>"
+
+
+class Heading2(Paragraph):
+    name = "h2"
+
+
+class BulletList(Paragraph):
+    name = "ul"
+
+    class ListItem(Paragraph):
+        name = "li"
+
+    def __init__(self):
+        self.list_items = []
+        super().__init__()
+
+    @property
+    def lines(self):
+        return
+
+    @lines.setter
+    def lines(self, value):
+        self.list_items.append(self.ListItem())
+
+
+class MDRenderer:
+    """
+    Simplified markdown to html translator.
+    """
+    METACHARS = "\\[*/_~`"
+    INLINE_TAGS = {
+        "*": "b",
+        "/": "i",
+        "_": "u",
+        "~": "del",
+        "`": "code",
+    }
+
+    def __init__(self, page):
+        self.page = page
+        self.tag = None
+        self.sio = StringIO()
+
+    def render_html(self):
+        # let's initially support #/<h2>, <p>, and single level lists <ul><li>
+        # where two spaces after a bullet line continues the list item.
+        # backslash escapes transform newlines to spaces in the output
+        # inline tags are evaluated when a tag is closed,
+        # and the in-between text is then html escaped
+        #
+        # return title, html
+        tags = []
+        tag = None
+        title = None
+        for line in self.page.split("\n"):
+            if line.startswith("# "):
+                if not isinstance(tag, Heading2):
+                    tag = Heading2()
+                    if title is None:
+                        title = tag
+                    tags.append(tag)
+                line = line[2:]
+            elif line.startswith("- "):
+                if not isinstance(tag, BulletList):
+                    tag = BulletList()
+                    tags.append(tag)
+                else:
+                    tag.lines = []
+                line = line[2:]
+            elif line.startswith("  "):
+                assert isinstance(tag, BulletList)
+                line = line[2:]
+            elif line == "":
+                tag = None
+                continue
+            else:
+                if not isinstance(tag, Paragraph):
+                    tag = Paragraph()
+                    tags.append(tag)
+            tag.lines.append(line)
+        return title.join_lines(), "\n".join(t.join() for t in tags)
diff --git a/post-receive.sh b/post-receive.sh
new file mode 100755 (executable)
index 0000000..88793e8
--- /dev/null
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+
+read -r -d '' hook <<'EOF'
+#!/usr/bin/env bash
+while read _ _ refname; do
+    if [[ "${refname}" == refs/heads/master ]]; then
+        . <(git show master:post-receive.sh)
+        break
+    fi
+done
+EOF
+
+hook_hash="$(printf '%s' "${hook}"|sha256sum| cut -d\  -f1)"
+[[ -f "${dest}" ]] && current_hash="$(sha256sum hooks/post-receive| cut -d\  -f1)"
+
+print_and_run() {
+    printf ">>>"
+    for (( i = 1; i < $# + 1; i++ )); do
+        printf ' %q' "${!i}"
+    done
+    printf "\n"
+    "$@"
+}
+
+update_post_receive_hook() {
+    local stat_cmd="\$(stat -c %a hooks/post-receive)"
+    print_and_run bash -c "cat >hooks/post-receive <<'EOF'"$'\n'"${hook}"$'\nEOF\n'
+    print_and_run bash -c "[[ ${stat_cmd} == 755 ]] || chmod 755 hooks/post-receive"
+}
+
+if [[ "${1}" == --install ]]; then
+    if (( $# > 1 )); then
+        echo "Error: No further arguments expected." >&2
+        exit 1
+    fi
+    update_post_receive_hook
+    exit
+fi
+
+[[ "${hook_hash}" != "${current_hash}" ]] && update_post_receive_hook
+
+git_dir="$(realpath -Pe .)"
+generate_dir=/dev/shm/mar77i.info
+[[ -e "${generate_dir}" ]] && rm -rf "${generate_dir}"
+mkdir "${generate_dir}"
+git --work-tree="${generate_dir}" --git-dir="${git_dir}" checkout master -f
+cd "${generate_dir}"
+./generate.py --output-dir "${HOME}/webroot/www.mar77i.info"
+cd ..
+rm -rf "${generate_dir}"
diff --git a/style.css b/style.css
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/template.html b/template.html
new file mode 100644 (file)
index 0000000..a5f2bf6
--- /dev/null
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta http-equiv="X-UA-Compatible" content="ie=edge">
+    <title>mar77i.info ¬ {title}</title>
+    <link rel="stylesheet" href="/style.css">
+  </head>
+  <body>
+    <h1>mar77i.info</h1>
+    <nav>
+      <ul>
+        {start-nav-items}
+        <li><a href="{link|attrquote}">{text}</a></li>
+        {end-nav-items}
+      </ul>
+    </nav>
+    {page|safe}
+  </body>
+</html>
diff --git a/template.py b/template.py
new file mode 100644 (file)
index 0000000..1bc0df5
--- /dev/null
@@ -0,0 +1,100 @@
+from collections.abc import Sequence
+
+from html import escape
+from io import StringIO
+
+from utils import find_or_end, get_content
+
+
+class Template:
+    def __init__(self, path):
+        self.path = path
+        self.tokens = self.get_tokens(get_content(path))
+
+    def get_tokens(self, content):
+        """
+        {{ -> literal {
+        }} -> literal }
+        {tag} -> html-escaped tag
+        {tag|safe} -> unescaped tag
+        """
+        tokens = []
+        tokens_stack = [(None, tokens)]
+        current = tokens
+        len_content = len(content)
+        pos = 0
+        while True:
+            new_pos = min(find_or_end(content, c, pos) for c in ("{", "}"))
+            if new_pos - pos > 0:
+                current.append({"text": content[pos:new_pos]})
+            if new_pos == len_content:
+                break
+            pos = new_pos
+            if content[pos] == "}" and (
+                pos == len_content - 1 or content[pos + 1] != "}"
+            ):
+                line = content[:pos].count("\n") + 1
+                raise ValueError(
+                    f"Found '}}' without previous '{{': {pos} (line {line})"
+                )
+            if content[pos + 1] in ("{", "}"):
+                if len(current) > 0 and set(current[-1]) == {"text"}:
+                    current[-1]["text"] = f"{current[-1]['text']}{content[pos]}"
+                else:
+                    current.append({"text": content[pos]})
+                continue
+            closing_tag = content.find("}", pos)
+            if closing_tag == -1:
+                line = content[:pos].count("\n") + 1
+                raise ValueError(
+                    f"Found '{{' without subsequent '}}': {pos} (line {line})"
+                )
+            tag, *pipes = content[pos + 1:closing_tag].split("|")
+            pos = closing_tag + 1
+            if tag.startswith("start-"):
+                tag_dict = {
+                    "tag": tag[6:],
+                    "pipes": pipes,
+                    "children": [],
+                }
+                current.append(tag_dict)
+                current = tag_dict["children"]
+                tokens_stack.append((tag[6:], current))
+                continue
+            elif not tag.startswith("end-"):
+                current.append({"tag": tag, "pipes": pipes})
+                continue
+            elif pipes:
+                raise ValueError(f"Unexpected pipes: '{tag}' | {', '.join(pipes)}")
+            elif tag[4:] != tokens_stack.pop()[0]:
+                raise ValueError(f"Mismatched closing tag: '{tag}'")
+            current = tokens_stack[-1][1]
+        if len(tokens_stack) > 1:
+            raise ValueError(f"Found unclosed tag: 'start-{tokens_stack[-1][0]}'")
+        return tokens
+
+    @staticmethod
+    def run_pipes(value, pipes):
+        if pipes == []:
+            value = escape(value, False)
+        else:
+            for pipe in pipes:
+                if pipe == "attrquote":
+                    value = escape(value)
+                elif pipe == "safe":
+                    pass
+                else:
+                    raise ValueError(f"Invalid pipe: '{pipe}'")
+        return value
+
+    def render(self, context, tokens=None):
+        sio = StringIO()
+        for token in self.tokens if tokens is None else tokens:
+            if set(token) == {"text"}:
+                sio.write(token["text"])
+            elif "children" not in token:
+                sio.write(self.run_pipes(context[token["tag"]], token["pipes"]))
+            elif isinstance(token["tag"], Sequence):
+                for ctx in context[token["tag"]]:
+                    sio.write(self.render(ctx, token["children"]))
+        return sio.getvalue()
diff --git a/utils.py b/utils.py
new file mode 100644 (file)
index 0000000..a5f2d80
--- /dev/null
+++ b/utils.py
@@ -0,0 +1,14 @@
+def get_content(path):
+    with path.open("rt") as fh:
+        return fh.read()
+
+
+def write_content(path, content):
+    print(f"writing {path}")
+    with path.open("wt") as fh:
+        fh.write(content)
+
+
+def find_or_end(s, sub, pos=0):
+    pos = s.find(sub, pos)
+    return pos if pos >= 0 else len(s)